mirror of
https://github.com/jorijn/meshcore-stats.git
synced 2026-03-28 17:42:55 +01:00
Compare commits
1 Commits
main
...
fix/renova
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159878aa8b |
140
.claude/agents/test-engineer.md
Normal file
140
.claude/agents/test-engineer.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: test-engineer
|
||||
description: Use this agent when you need to write, review, or improve tests for code. This includes unit tests, integration tests, end-to-end tests, and test infrastructure. Also use when evaluating test coverage, identifying untested edge cases, or refactoring existing tests for better maintainability. Examples:\n\n<example>\nContext: User has just written a new function and wants tests for it.\nuser: "I just wrote this battery voltage calculation function, can you help me test it?"\nassistant: "Let me use the test-engineer agent to create comprehensive tests for your battery calculation function."\n<commentary>\nSince the user wants tests written for new code, use the test-engineer agent to create thorough test cases covering normal operation, edge cases, and error conditions.\n</commentary>\n</example>\n\n<example>\nContext: User wants to improve existing test coverage.\nuser: "Our test coverage for the database module is only 60%, can you help improve it?"\nassistant: "I'll use the test-engineer agent to analyze the gaps in your database module tests and create additional test cases."\n<commentary>\nThe user needs help identifying untested code paths and writing new tests, which is exactly what the test-engineer agent specializes in.\n</commentary>\n</example>\n\n<example>\nContext: User has written code and the assistant should proactively suggest testing.\nuser: "Here's my new retry logic with exponential backoff"\nassistant: "I've reviewed your retry logic implementation. Now let me use the test-engineer agent to create tests that verify the backoff behavior, edge cases like max retries, and error handling."\n<commentary>\nAfter code is written, proactively use the test-engineer agent to ensure the new functionality has proper test coverage.\n</commentary>\n</example>
|
||||
model: opus
|
||||
---
|
||||
|
||||
You are an expert test engineer with deep expertise in software testing methodologies, test-driven development, and quality assurance. Your primary focus is Python testing (pytest, unittest, hypothesis), but you are also proficient in testing frameworks across JavaScript/TypeScript (Jest, Vitest, Mocha), Go, Rust, and other languages.
|
||||
|
||||
## Core Expertise
|
||||
|
||||
### Testing Principles
|
||||
- Write tests that are fast, isolated, repeatable, self-validating, and timely (F.I.R.S.T.)
|
||||
- Follow the Arrange-Act-Assert (AAA) pattern for clear test structure
|
||||
- Apply the testing pyramid: prioritize unit tests, supplement with integration tests, minimize end-to-end tests
|
||||
- Test behavior, not implementation details
|
||||
- Each test should verify one specific behavior
|
||||
|
||||
### Python Testing (Primary Focus)
|
||||
- **pytest**: fixtures, parametrization, markers, conftest.py organization, plugins
|
||||
- **unittest**: TestCase classes, setUp/tearDown, mock module
|
||||
- **hypothesis**: property-based testing, strategies, shrinking
|
||||
- **coverage.py**: measuring and improving test coverage
|
||||
- **mocking**: unittest.mock, pytest-mock, when and how to mock appropriately
|
||||
- **async testing**: pytest-asyncio, testing coroutines and async generators
|
||||
|
||||
### Test Categories You Handle
|
||||
1. **Unit Tests**: Isolated function/method testing with mocked dependencies
|
||||
2. **Integration Tests**: Testing component interactions, database operations, API calls
|
||||
3. **End-to-End Tests**: Full system testing, UI automation
|
||||
4. **Property-Based Tests**: Generating test cases to find edge cases
|
||||
5. **Regression Tests**: Preventing bug recurrence
|
||||
6. **Performance Tests**: Benchmarking, load testing considerations
|
||||
|
||||
## Your Approach
|
||||
|
||||
### When Writing Tests
|
||||
1. Identify the function/module's contract: inputs, outputs, side effects, exceptions
|
||||
2. List test cases covering:
|
||||
- Happy path (normal operation)
|
||||
- Edge cases (empty inputs, boundaries, None/null values)
|
||||
- Error conditions (invalid inputs, exceptions)
|
||||
- State transitions (if applicable)
|
||||
3. Write clear, descriptive test names that explain what is being tested
|
||||
4. Use fixtures for common setup, parametrize for similar test variations
|
||||
5. Keep tests independent - no test should depend on another's execution
|
||||
|
||||
### When Reviewing Tests
|
||||
1. Check for missing edge cases and error scenarios
|
||||
2. Identify flaky tests (time-dependent, order-dependent, external dependencies)
|
||||
3. Look for over-mocking that makes tests meaningless
|
||||
4. Verify assertions are specific and meaningful
|
||||
5. Ensure test names clearly describe what they verify
|
||||
6. Check for proper cleanup and resource management
|
||||
|
||||
### Test Naming Convention
|
||||
Use descriptive names that explain the scenario:
|
||||
- `test_<function>_<scenario>_<expected_result>`
|
||||
- Example: `test_calculate_battery_percentage_at_minimum_voltage_returns_zero`
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### Test Structure
|
||||
```python
|
||||
def test_function_name_describes_behavior():
|
||||
# Arrange - set up test data and dependencies
|
||||
input_data = create_test_data()
|
||||
|
||||
# Act - call the function under test
|
||||
result = function_under_test(input_data)
|
||||
|
||||
# Assert - verify the expected outcome
|
||||
assert result == expected_value
|
||||
```
|
||||
|
||||
### Fixture Best Practices
|
||||
- Use fixtures for reusable setup, not for test logic
|
||||
- Prefer function-scoped fixtures unless sharing is necessary
|
||||
- Use `yield` for cleanup in fixtures
|
||||
- Document what each fixture provides
|
||||
|
||||
### Mocking Guidelines
|
||||
- Mock at the boundary (external services, databases, file systems)
|
||||
- Don't mock the thing you're testing
|
||||
- Verify mock calls when the interaction itself is the behavior being tested
|
||||
- Use `autospec=True` to catch interface mismatches
|
||||
|
||||
## Edge Cases to Always Consider
|
||||
|
||||
### For Numeric Functions
|
||||
- Zero, negative numbers, very large numbers
|
||||
- Floating point precision issues
|
||||
- Integer overflow (in typed languages)
|
||||
- Division by zero scenarios
|
||||
|
||||
### For String/Text Functions
|
||||
- Empty strings, whitespace-only strings
|
||||
- Unicode characters, emoji, RTL text
|
||||
- Very long strings
|
||||
- Special characters and escape sequences
|
||||
|
||||
### For Collections
|
||||
- Empty collections
|
||||
- Single-element collections
|
||||
- Very large collections
|
||||
- None/null elements within collections
|
||||
- Duplicate elements
|
||||
|
||||
### For Time/Date Functions
|
||||
- Timezone boundaries, DST transitions
|
||||
- Leap years, month boundaries
|
||||
- Unix epoch edge cases
|
||||
- Far future/past dates
|
||||
|
||||
### For I/O Operations
|
||||
- File not found, permission denied
|
||||
- Network timeouts, connection failures
|
||||
- Partial reads/writes
|
||||
- Concurrent access
|
||||
|
||||
## Output Format
|
||||
|
||||
When writing tests, provide:
|
||||
1. Complete, runnable test code
|
||||
2. Brief explanation of what each test verifies
|
||||
3. Any additional test cases that should be considered
|
||||
4. Required fixtures or test utilities
|
||||
|
||||
When reviewing tests, provide:
|
||||
1. Specific issues found with line references
|
||||
2. Missing test cases that should be added
|
||||
3. Suggested improvements with code examples
|
||||
4. Overall assessment of test quality and coverage
|
||||
|
||||
## Project-Specific Considerations
|
||||
|
||||
When working in projects with existing test conventions:
|
||||
- Follow the established test file organization
|
||||
- Use existing fixtures and utilities where appropriate
|
||||
- Match the naming conventions already in use
|
||||
- Respect any project-specific testing requirements from documentation like CLAUDE.md
|
||||
49
.github/workflows/docker-publish.yml
vendored
49
.github/workflows/docker-publish.yml
vendored
@@ -20,17 +20,6 @@ on:
|
||||
# Daily at 4 AM UTC - rebuild with fresh base image
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile
|
||||
- .dockerignore
|
||||
- docker/**
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- src/**
|
||||
- scripts/**
|
||||
- .github/workflows/docker-publish.yml
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
@@ -56,13 +45,12 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
# For nightly builds, get the latest release version
|
||||
- name: Get latest release version
|
||||
@@ -99,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -219,7 +207,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/upload-sarif@6e4b8622b82fab3c6ad2a7814fad1effc7615bc8 # v3.28.4
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
continue-on-error: true
|
||||
@@ -240,37 +228,8 @@ jobs:
|
||||
# Attestation (releases only)
|
||||
- name: Generate attestation
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
uses: actions/attest-build-provenance@46a583fd92dfbf46b772907a9740f888f4324bb9 # v3.1.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build-release.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
build-pr:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build image (PR)
|
||||
id: build-pr
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: meshcore-stats:pr-${{ github.event.pull_request.number }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Smoke test (PR)
|
||||
run: |
|
||||
docker run --rm meshcore-stats:pr-${{ github.event.pull_request.number }} \
|
||||
python -c "from meshmon.db import init_db; from meshmon.env import get_config; print('Smoke test passed')"
|
||||
|
||||
2
.github/workflows/release-please.yml
vendored
2
.github/workflows/release-please.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Release Please
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4
|
||||
uses: googleapis/release-please-action@c3fc4de07084f75a2b61a5b933069bda6edf3d5c # v4
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
|
||||
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -68,8 +68,8 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload coverage HTML report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always() && matrix.python-version == '3.14'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always() && matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-report-html-${{ matrix.python-version }}
|
||||
path: htmlcov/
|
||||
@@ -77,8 +77,8 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload coverage XML report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always() && matrix.python-version == '3.14'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always() && matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-report-xml-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results-${{ matrix.python-version }}
|
||||
@@ -97,17 +97,17 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.14"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install linters
|
||||
run: uv sync --locked --extra dev --no-install-project
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.2.18"
|
||||
".": "0.2.11"
|
||||
}
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -366,11 +366,10 @@ Jobs configured in `docker/ofelia.ini`:
|
||||
| Release | `X.Y.Z`, `X.Y`, `latest` |
|
||||
| Nightly (4 AM UTC) | Rebuilds all version tags + `nightly`, `nightly-YYYYMMDD` |
|
||||
| Manual | `sha-xxxxxx` |
|
||||
| Pull request | Builds image (linux/amd64) without pushing and runs a smoke test |
|
||||
|
||||
**Nightly rebuilds** ensure version tags always include the latest OS security patches. This is a common pattern used by official Docker images (nginx, postgres, node). Users needing reproducibility should pin by SHA digest or use dated nightly tags.
|
||||
|
||||
GitHub Actions use version tags in workflows, and Renovate is configured in `renovate.json` to pin action digests, maintain lockfiles, and auto-merge patch + digest updates once required checks pass (with automatic rebases when behind `main`).
|
||||
All GitHub Actions are pinned by full SHA for security. Renovate is configured in `renovate.json` to update dependencies and maintain lockfiles.
|
||||
|
||||
The test and lint workflow (`.github/workflows/test.yml`) installs dependencies with uv (`uv sync --locked --extra dev`) and runs commands via `uv run`, using `uv.lock` as the source of truth.
|
||||
|
||||
@@ -485,8 +484,6 @@ All configuration via `meshcore.conf` or environment variables. The config file
|
||||
- `TELEMETRY_TIMEOUT_S`: Timeout for telemetry requests (default: 10)
|
||||
- `TELEMETRY_RETRY_ATTEMPTS`: Retry attempts for telemetry (default: 2)
|
||||
- `TELEMETRY_RETRY_BACKOFF_S`: Backoff between telemetry retries (default: 4)
|
||||
- When enabled, repeater telemetry charts are auto-discovered from `telemetry.*` metrics present in the database.
|
||||
- `telemetry.voltage.*` and `telemetry.gps.*` metrics are intentionally excluded from chart rendering.
|
||||
|
||||
### Intervals
|
||||
- `COMPANION_STEP`: Collection interval for companion (default: 60s)
|
||||
@@ -499,7 +496,6 @@ All configuration via `meshcore.conf` or environment variables. The config file
|
||||
- `REPORT_LON`: Longitude in decimal degrees (default: 0.0)
|
||||
- `REPORT_ELEV`: Elevation (default: 0.0)
|
||||
- `REPORT_ELEV_UNIT`: Elevation unit, "m" or "ft" (default: "m")
|
||||
- `DISPLAY_UNIT_SYSTEM`: `metric` or `imperial` for telemetry display formatting (default: `metric`)
|
||||
- `REPEATER_DISPLAY_NAME`: Display name for repeater in UI (default: "Repeater Node")
|
||||
- `COMPANION_DISPLAY_NAME`: Display name for companion in UI (default: "Companion Node")
|
||||
- `REPEATER_HARDWARE`: Repeater hardware model for sidebar (default: "LoRa Repeater")
|
||||
@@ -545,9 +541,6 @@ Counter metrics are converted to rates during chart rendering by calculating del
|
||||
- Channel number distinguishes multiple sensors of the same type
|
||||
- Compound values (e.g., GPS) stored as: `telemetry.gps.0.latitude`, `telemetry.gps.0.longitude`
|
||||
- Telemetry collection does NOT affect circuit breaker state
|
||||
- Repeater telemetry charts are auto-discovered from available `telemetry.*` metrics
|
||||
- `telemetry.voltage.*` and `telemetry.gps.*` are collected but not charted
|
||||
- Display conversion is chart/UI-only (DB values remain raw firmware values)
|
||||
|
||||
## Database Schema
|
||||
|
||||
@@ -684,7 +677,6 @@ The static site uses a modern, responsive design with the following features:
|
||||
- **Repeater pages at root**: `/day.html`, `/week.html`, etc. (entry point)
|
||||
- **Companion pages**: `/companion/day.html`, `/companion/week.html`, etc.
|
||||
- **`.htaccess`**: Sets `DirectoryIndex day.html` so `/` loads repeater day view
|
||||
- **Relative links**: All internal navigation and static asset references are relative (no leading `/`) so the dashboard can be served from a reverse-proxy subpath.
|
||||
|
||||
### Page Layout
|
||||
1. **Header**: Site branding, node name, pubkey prefix, status indicator, last updated time
|
||||
@@ -704,7 +696,6 @@ Color-coded based on data freshness:
|
||||
- Shows datetime and value when hovering over chart data
|
||||
- Works without JavaScript (charts still display, just no tooltips)
|
||||
- Uses `data-points`, `data-x-start`, `data-x-end` attributes embedded in SVG
|
||||
- Telemetry tooltip units/precision follow `DISPLAY_UNIT_SYSTEM`
|
||||
|
||||
### Social Sharing
|
||||
Open Graph and Twitter Card meta tags for link previews:
|
||||
@@ -739,18 +730,6 @@ Charts are generated as inline SVGs using matplotlib (`src/meshmon/charts.py`).
|
||||
- **Inline**: SVGs are embedded directly in HTML for zero additional requests
|
||||
- **Tooltips**: Data points embedded as JSON in SVG `data-points` attribute
|
||||
|
||||
### Telemetry Chart Discovery
|
||||
- Applies to repeater charts only (companion telemetry is not grouped/rendered in dashboard UI)
|
||||
- Active only when `TELEMETRY_ENABLED=1`
|
||||
- Discovers all `telemetry.<type>.<channel>[.<subkey>]` metrics found in DB metadata
|
||||
- Excludes `telemetry.voltage.*` and `telemetry.gps.*` from charts
|
||||
- Appends a `Telemetry` chart section at the end of the repeater dashboard when metrics are present
|
||||
- Uses display-only unit conversion based on `DISPLAY_UNIT_SYSTEM`:
|
||||
- `temperature`: `°C` -> `°F` (imperial)
|
||||
- `barometer`/`pressure`: `hPa` -> `inHg` (imperial)
|
||||
- `altitude`: `m` -> `ft` (imperial)
|
||||
- `humidity`: unchanged (`%`)
|
||||
|
||||
### Time Aggregation (Binning)
|
||||
Data points are aggregated into bins to keep chart file sizes reasonable and lines clean:
|
||||
|
||||
@@ -792,9 +771,6 @@ Metrics use firmware field names directly from `req_status_sync`:
|
||||
| `sent_direct` | counter | Packets/min | Direct packets transmitted |
|
||||
| `recv_direct` | counter | Packets/min | Direct packets received |
|
||||
|
||||
Telemetry charts are discovered dynamically when telemetry is enabled and data exists.
|
||||
Units/labels are generated from metric keys at runtime, with display conversion controlled by `DISPLAY_UNIT_SYSTEM`.
|
||||
|
||||
### Companion Metrics Summary
|
||||
|
||||
Metrics use firmware field names directly from `get_stats_*`:
|
||||
@@ -883,7 +859,6 @@ With the EAV schema, adding new metrics is simple:
|
||||
- `METRIC_CONFIG` in `src/meshmon/metrics.py` (label, unit, type, transform)
|
||||
- `COMPANION_CHART_METRICS` or `REPEATER_CHART_METRICS` in `src/meshmon/metrics.py`
|
||||
- `COMPANION_CHART_GROUPS` or `REPEATER_CHART_GROUPS` in `src/meshmon/html.py`
|
||||
- Exception: repeater `telemetry.*` metrics are auto-discovered, so they do not need to be added to static chart lists/groups.
|
||||
|
||||
3. **To display in reports**: Add the firmware field name to:
|
||||
- `COMPANION_REPORT_METRICS` or `REPEATER_REPORT_METRICS` in `src/meshmon/reports.py`
|
||||
|
||||
144
CHANGELOG.md
144
CHANGELOG.md
@@ -4,150 +4,6 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
This changelog is automatically generated by [release-please](https://github.com/googleapis/release-please) based on [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
## [0.2.18](https://github.com/jorijn/meshcore-stats/compare/v0.2.17...v0.2.18) (2026-03-09)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** lock file maintenance ([#122](https://github.com/jorijn/meshcore-stats/issues/122)) ([f22e111](https://github.com/jorijn/meshcore-stats/commit/f22e111d681ccc2a90f4052c6e671f081fd68772))
|
||||
* **deps:** lock file maintenance ([#130](https://github.com/jorijn/meshcore-stats/issues/130)) ([b3acafb](https://github.com/jorijn/meshcore-stats/commit/b3acafbfd888f644b9b6ad11890f4d45c55ccdd7))
|
||||
* **deps:** lock file maintenance ([#140](https://github.com/jorijn/meshcore-stats/issues/140)) ([611d98a](https://github.com/jorijn/meshcore-stats/commit/611d98a4438df2ed071cfb4ff166181b2c542ea0))
|
||||
* **deps:** update astral-sh/setup-uv action to v7.3.1 ([#127](https://github.com/jorijn/meshcore-stats/issues/127)) ([6d7e027](https://github.com/jorijn/meshcore-stats/commit/6d7e027100d0dfdc7452ef79917b9ca153fe0f3a))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.4 ([#119](https://github.com/jorijn/meshcore-stats/issues/119)) ([90b863d](https://github.com/jorijn/meshcore-stats/commit/90b863d6b56c0518ce8dd8b9e6138fba0fb76833))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.5 ([#123](https://github.com/jorijn/meshcore-stats/issues/123)) ([98912ad](https://github.com/jorijn/meshcore-stats/commit/98912ad68c6cf76ccef7f11d21c38f89da4e04a0))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.6 ([#125](https://github.com/jorijn/meshcore-stats/issues/125)) ([1734676](https://github.com/jorijn/meshcore-stats/commit/173467686e041fb0f1d6a61e11f203c0c3f616ad))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.7 ([#128](https://github.com/jorijn/meshcore-stats/issues/128)) ([8ca8039](https://github.com/jorijn/meshcore-stats/commit/8ca8039956a9de1a4a795e459b352f2b72ac4aaa))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.8 ([#132](https://github.com/jorijn/meshcore-stats/issues/132)) ([21b9fbf](https://github.com/jorijn/meshcore-stats/commit/21b9fbfe6bbd8c944dedcfda0ee894e4d011fdf4))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.9 ([#139](https://github.com/jorijn/meshcore-stats/issues/139)) ([3c1ae50](https://github.com/jorijn/meshcore-stats/commit/3c1ae50f6d49b5945ef773dcac2d2c6ac3c7a357))
|
||||
* **deps:** update github/codeql-action action to v4.32.4 ([#121](https://github.com/jorijn/meshcore-stats/issues/121)) ([baf3c26](https://github.com/jorijn/meshcore-stats/commit/baf3c2688b2b798c705f03f4fd8be0e86e597b8f))
|
||||
* **deps:** update github/codeql-action action to v4.32.5 ([#131](https://github.com/jorijn/meshcore-stats/issues/131)) ([6520256](https://github.com/jorijn/meshcore-stats/commit/652025664a6ef5a6325f40b63a056c3251a89306))
|
||||
* **deps:** update github/codeql-action action to v4.32.6 ([#136](https://github.com/jorijn/meshcore-stats/issues/136)) ([1da22f4](https://github.com/jorijn/meshcore-stats/commit/1da22f4a43f93298d5e13813e01d56129e6aec83))
|
||||
* **deps:** update python:3.14-slim-bookworm docker digest to 5404df0 ([#124](https://github.com/jorijn/meshcore-stats/issues/124)) ([e990c6c](https://github.com/jorijn/meshcore-stats/commit/e990c6c2e05b0734134465eaec2ca762737fd1ca))
|
||||
|
||||
## [0.2.17](https://github.com/jorijn/meshcore-stats/compare/v0.2.16...v0.2.17) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add configurable custom HTML head injection ([#118](https://github.com/jorijn/meshcore-stats/issues/118)) ([edde12f](https://github.com/jorijn/meshcore-stats/commit/edde12f17c3ff34dc1310bbcbda333897b876c56))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** lock file maintenance ([#116](https://github.com/jorijn/meshcore-stats/issues/116)) ([b23710b](https://github.com/jorijn/meshcore-stats/commit/b23710b5aebbce34692d4f37ef1108591e916142))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.1 ([#110](https://github.com/jorijn/meshcore-stats/issues/110)) ([06517c5](https://github.com/jorijn/meshcore-stats/commit/06517c58056d8820c236e29a0d0ce39748a5e335))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.2 ([#112](https://github.com/jorijn/meshcore-stats/issues/112)) ([e99df4c](https://github.com/jorijn/meshcore-stats/commit/e99df4cac13a09b070ab2cc6fab4d2f6b5ba2a9c))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.3 ([#117](https://github.com/jorijn/meshcore-stats/issues/117)) ([de22906](https://github.com/jorijn/meshcore-stats/commit/de2290639f97ab08662de63d1a3c8cc589ac8654))
|
||||
* **deps:** update github/codeql-action action to v4.32.3 ([#115](https://github.com/jorijn/meshcore-stats/issues/115)) ([19b04be](https://github.com/jorijn/meshcore-stats/commit/19b04be430292298e7158ef2330efdcd324059c4))
|
||||
|
||||
## [0.2.16](https://github.com/jorijn/meshcore-stats/compare/v0.2.15...v0.2.16) (2026-02-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add telemetry chart discovery and unit display ([#109](https://github.com/jorijn/meshcore-stats/issues/109)) ([137bbe3](https://github.com/jorijn/meshcore-stats/commit/137bbe3c663004ddad549c47c7502822a79775b6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **html:** use relative asset and nav paths for subpath deploys ([#84](https://github.com/jorijn/meshcore-stats/issues/84)) ([f21a378](https://github.com/jorijn/meshcore-stats/commit/f21a3788bd0ee7c327f4d8bd484e183a8f656c27))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** lock file maintenance ([#108](https://github.com/jorijn/meshcore-stats/issues/108)) ([3c765a3](https://github.com/jorijn/meshcore-stats/commit/3c765a35f2b37adbdba68aa37928e739a4ad5e20))
|
||||
* **deps:** lock file maintenance ([#85](https://github.com/jorijn/meshcore-stats/issues/85)) ([410eee4](https://github.com/jorijn/meshcore-stats/commit/410eee439e3f7d9f6d8a0cec18ceafd42e0aff62))
|
||||
* **deps:** lock file maintenance ([#89](https://github.com/jorijn/meshcore-stats/issues/89)) ([d636f5c](https://github.com/jorijn/meshcore-stats/commit/d636f5cbe3705e91ff37378d839c6919fc1d44b2))
|
||||
* **deps:** lock file maintenance ([#98](https://github.com/jorijn/meshcore-stats/issues/98)) ([471ebcf](https://github.com/jorijn/meshcore-stats/commit/471ebcff45c02bb099a2c2bdf69839a83f9c87a3))
|
||||
* **deps:** update actions/attest-build-provenance action to v3.2.0 ([#90](https://github.com/jorijn/meshcore-stats/issues/90)) ([23c8622](https://github.com/jorijn/meshcore-stats/commit/23c86226b830736d21d3fc5b081ffb0b21844d75))
|
||||
* **deps:** update actions/checkout action to v6.0.2 ([#87](https://github.com/jorijn/meshcore-stats/issues/87)) ([b789cbc](https://github.com/jorijn/meshcore-stats/commit/b789cbcc56f50e62164dbfad15f55530c84dd9df))
|
||||
* **deps:** update actions/checkout digest to de0fac2 ([#100](https://github.com/jorijn/meshcore-stats/issues/100)) ([a84b0c3](https://github.com/jorijn/meshcore-stats/commit/a84b0c30c1252aac15b4a26e2834a1ff4e821eb7))
|
||||
* **deps:** update actions/setup-python digest to a309ff8 ([#86](https://github.com/jorijn/meshcore-stats/issues/86)) ([43e07d3](https://github.com/jorijn/meshcore-stats/commit/43e07d3ffc2dfaab10adc763129ceb2f31ff44c9))
|
||||
* **deps:** update astral-sh/setup-uv action to v7.2.1 ([#96](https://github.com/jorijn/meshcore-stats/issues/96)) ([6168a0b](https://github.com/jorijn/meshcore-stats/commit/6168a0b4e9ac6e9f0093901d8775a5ae2169c648))
|
||||
* **deps:** update astral-sh/setup-uv action to v7.3.0 ([#106](https://github.com/jorijn/meshcore-stats/issues/106)) ([b56add8](https://github.com/jorijn/meshcore-stats/commit/b56add874822f2d630718d509c1a59c546d3c48c))
|
||||
* **deps:** update docker/login-action action to v3.7.0 ([#94](https://github.com/jorijn/meshcore-stats/issues/94)) ([d1770cf](https://github.com/jorijn/meshcore-stats/commit/d1770cfc631a0d5a874dd97b454aed61a77dcb3c))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.10.0 ([#105](https://github.com/jorijn/meshcore-stats/issues/105)) ([dc477b6](https://github.com/jorijn/meshcore-stats/commit/dc477b6532777ac4d142626a62715a9fcff01f74))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.25 ([#77](https://github.com/jorijn/meshcore-stats/issues/77)) ([df9bfff](https://github.com/jorijn/meshcore-stats/commit/df9bfffa78202ceb285c13e9d652e132d5d3fd96))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.26 ([#82](https://github.com/jorijn/meshcore-stats/issues/82)) ([c0758f4](https://github.com/jorijn/meshcore-stats/commit/c0758f4c0dd92b22e6c7f69b13adcc0615b9e48c))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.27 ([#92](https://github.com/jorijn/meshcore-stats/issues/92)) ([6f89953](https://github.com/jorijn/meshcore-stats/commit/6f899536b0745d834621f7b4dc72b7f559cad2f8))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.28 ([#95](https://github.com/jorijn/meshcore-stats/issues/95)) ([159fb02](https://github.com/jorijn/meshcore-stats/commit/159fb02379be99939531cb00f1f94a36f6054ebf))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.29 ([#101](https://github.com/jorijn/meshcore-stats/issues/101)) ([69108a9](https://github.com/jorijn/meshcore-stats/commit/69108a90b7c19ed9837a5263da4cd73d4086d2fe))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.30 ([#103](https://github.com/jorijn/meshcore-stats/issues/103)) ([70f0b0c](https://github.com/jorijn/meshcore-stats/commit/70f0b0c74687b4e2ef6d68976eacc3f2ff94c8bb))
|
||||
* **deps:** update github/codeql-action action to v4.31.11 ([#88](https://github.com/jorijn/meshcore-stats/issues/88)) ([453231c](https://github.com/jorijn/meshcore-stats/commit/453231c65093a4b25013190f30fe0acfde8969de))
|
||||
* **deps:** update github/codeql-action action to v4.32.2 ([#91](https://github.com/jorijn/meshcore-stats/issues/91)) ([8bee466](https://github.com/jorijn/meshcore-stats/commit/8bee46645b65295ca590a1153db5dc3a5618558e))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to 1d13701 ([#107](https://github.com/jorijn/meshcore-stats/issues/107)) ([d9b413b](https://github.com/jorijn/meshcore-stats/commit/d9b413b18f7d1bf280e5313ecc6de8ce39aed767))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to 4870c12 ([#97](https://github.com/jorijn/meshcore-stats/issues/97)) ([88df0ff](https://github.com/jorijn/meshcore-stats/commit/88df0ffd128c7915d2fe48ed1c93e06a75156a96))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to 5878d06 ([#104](https://github.com/jorijn/meshcore-stats/issues/104)) ([34d5990](https://github.com/jorijn/meshcore-stats/commit/34d5990ca80c4a24ae3d75520afb0b9e0eb7fce6))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to 66d420c ([#78](https://github.com/jorijn/meshcore-stats/issues/78)) ([2455f35](https://github.com/jorijn/meshcore-stats/commit/2455f35d3226ec4222099556c1251f8fc2bcf877))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to 7d7a15b ([#93](https://github.com/jorijn/meshcore-stats/issues/93)) ([5fecc33](https://github.com/jorijn/meshcore-stats/commit/5fecc3317d1a1721f2e188bf8d6b02400c0be70f))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to b0f7830 ([#81](https://github.com/jorijn/meshcore-stats/issues/81)) ([6afd70b](https://github.com/jorijn/meshcore-stats/commit/6afd70b0d9eddc36533cf04075d994c97ed28f75))
|
||||
* **deps:** update python:3.14-slim-bookworm docker digest to adb6bdf ([#79](https://github.com/jorijn/meshcore-stats/issues/79)) ([3007845](https://github.com/jorijn/meshcore-stats/commit/3007845bd22754452afd6c66a6f48098678307e6))
|
||||
* **deps:** update python:3.14-slim-bookworm docker digest to e87711e ([#99](https://github.com/jorijn/meshcore-stats/issues/99)) ([81ba1ef](https://github.com/jorijn/meshcore-stats/commit/81ba1efaf2e5439d82525b2e3f622868252c4431))
|
||||
* **deps:** update python:3.14-slim-bookworm docker digest to f0540d0 ([#102](https://github.com/jorijn/meshcore-stats/issues/102)) ([ddcee7f](https://github.com/jorijn/meshcore-stats/commit/ddcee7fa72d9bdabed076a3e10f10ab22b235185))
|
||||
|
||||
## [0.2.15](https://github.com/jorijn/meshcore-stats/compare/v0.2.14...v0.2.15) (2026-01-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **charts:** skip short counter intervals ([#73](https://github.com/jorijn/meshcore-stats/issues/73)) ([97ebba4](https://github.com/jorijn/meshcore-stats/commit/97ebba4f2da723100ec87d21b6f8780ee0793e46))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** update python:3.14-slim-bookworm docker digest to 55b18d5 ([#69](https://github.com/jorijn/meshcore-stats/issues/69)) ([392ba22](https://github.com/jorijn/meshcore-stats/commit/392ba226babdaa7bd4beb0c6ff7b832a3aca5e71))
|
||||
|
||||
## [0.2.14](https://github.com/jorijn/meshcore-stats/compare/v0.2.13...v0.2.14) (2026-01-13)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* add lockFileMaintenance to update types ([#65](https://github.com/jorijn/meshcore-stats/issues/65)) ([b249a21](https://github.com/jorijn/meshcore-stats/commit/b249a217e85031a0ce73865e577d37583c3af5ea))
|
||||
* **deps:** lock file maintenance ([#66](https://github.com/jorijn/meshcore-stats/issues/66)) ([a89d745](https://github.com/jorijn/meshcore-stats/commit/a89d745d6bcb4aae13fab3f0c0d7dd7c1a643f3a))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.24 ([#61](https://github.com/jorijn/meshcore-stats/issues/61)) ([18ca787](https://github.com/jorijn/meshcore-stats/commit/18ca787f7fe054a425af4fba16306621fead7ced))
|
||||
* **deps:** update github/codeql-action action to v4.31.10 ([#67](https://github.com/jorijn/meshcore-stats/issues/67)) ([c1b8978](https://github.com/jorijn/meshcore-stats/commit/c1b89782eb374bb1f161ef86bebd64dc8ece9e1c))
|
||||
* **deps:** update golang docker tag to v1.25 ([#70](https://github.com/jorijn/meshcore-stats/issues/70)) ([63a8420](https://github.com/jorijn/meshcore-stats/commit/63a842016cd14d0e338840fb4e41abb17bb32ba5))
|
||||
* **deps:** update nginx:1.29-alpine docker digest to c083c37 ([#62](https://github.com/jorijn/meshcore-stats/issues/62)) ([df0c374](https://github.com/jorijn/meshcore-stats/commit/df0c374b654606c2b6d36ae3fa5134691885cd5d))
|
||||
* enable renovate automerge for patch and digest updates ([#64](https://github.com/jorijn/meshcore-stats/issues/64)) ([6fc2e76](https://github.com/jorijn/meshcore-stats/commit/6fc2e762cfbea31ebca4a120d0d0e1a3547b0455))
|
||||
|
||||
|
||||
### Build System
|
||||
|
||||
* **docker:** add armv7 container support ([#68](https://github.com/jorijn/meshcore-stats/issues/68)) ([75e50f7](https://github.com/jorijn/meshcore-stats/commit/75e50f7ee95404b7ab9c0abeec12fa5e17ad24f6))
|
||||
|
||||
## [0.2.13](https://github.com/jorijn/meshcore-stats/compare/v0.2.12...v0.2.13) (2026-01-09)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* drop digest from compose image ([#59](https://github.com/jorijn/meshcore-stats/issues/59)) ([3a03060](https://github.com/jorijn/meshcore-stats/commit/3a0306043c8a8dfeb1b5b6df6fa988322cc64e98))
|
||||
|
||||
## [0.2.12](https://github.com/jorijn/meshcore-stats/compare/v0.2.11...v0.2.12) (2026-01-09)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** lock file maintenance ([#52](https://github.com/jorijn/meshcore-stats/issues/52)) ([d4b5885](https://github.com/jorijn/meshcore-stats/commit/d4b5885379c06988bd8261039c67c6a6724b7704))
|
||||
* **deps:** lock file maintenance ([#58](https://github.com/jorijn/meshcore-stats/issues/58)) ([a3a5964](https://github.com/jorijn/meshcore-stats/commit/a3a5964488e7fbda5b6d792fa9f0f712e0a0d0c3))
|
||||
* **deps:** pin dependencies ([#55](https://github.com/jorijn/meshcore-stats/issues/55)) ([9cb95f8](https://github.com/jorijn/meshcore-stats/commit/9cb95f8108738ff21a8346f8922fcd218843fb7d))
|
||||
* **deps:** pin python docker tag to e8a1ad8 ([#57](https://github.com/jorijn/meshcore-stats/issues/57)) ([f55c236](https://github.com/jorijn/meshcore-stats/commit/f55c236080f6c9bc7a7f090f4382cd53281fc2ac))
|
||||
* **deps:** update actions/attest-build-provenance digest to 00014ed ([#40](https://github.com/jorijn/meshcore-stats/issues/40)) ([e937f2b](https://github.com/jorijn/meshcore-stats/commit/e937f2b0b7a34bb5c7f3f51b60a592f78a78079d))
|
||||
* **deps:** update actions/checkout action to v6 ([#48](https://github.com/jorijn/meshcore-stats/issues/48)) ([3967fd0](https://github.com/jorijn/meshcore-stats/commit/3967fd032ad95873bc50c438351ba52e6448a335))
|
||||
* **deps:** update actions/setup-python action to v6 ([#49](https://github.com/jorijn/meshcore-stats/issues/49)) ([97223f1](https://github.com/jorijn/meshcore-stats/commit/97223f137ca069f6f2632e2e849274cced91a8b3))
|
||||
* **deps:** update actions/upload-artifact action to v6 ([#50](https://github.com/jorijn/meshcore-stats/issues/50)) ([46fc383](https://github.com/jorijn/meshcore-stats/commit/46fc383eaa9cd99185a5b2112e58d5ff163f3185))
|
||||
* **deps:** update ghcr.io/astral-sh/uv docker tag to v0.9.22 ([#44](https://github.com/jorijn/meshcore-stats/issues/44)) ([83cf2bf](https://github.com/jorijn/meshcore-stats/commit/83cf2bf929bfba9f7019e78767abf04abe7700d2))
|
||||
* **deps:** update github/codeql-action action to v4 ([#51](https://github.com/jorijn/meshcore-stats/issues/51)) ([83425a4](https://github.com/jorijn/meshcore-stats/commit/83425a48f67a5d974065b9d33ad0a24a044d67d0))
|
||||
* **deps:** update github/codeql-action digest to ee117c9 ([#41](https://github.com/jorijn/meshcore-stats/issues/41)) ([dd7ec5b](https://github.com/jorijn/meshcore-stats/commit/dd7ec5b46e92365dbf2731f2378b2168c24f0b88))
|
||||
* **deps:** update nginx docker tag to v1.29 ([#47](https://github.com/jorijn/meshcore-stats/issues/47)) ([57a53a8](https://github.com/jorijn/meshcore-stats/commit/57a53a8800c9c97459ef5139310a8c23c7540943))
|
||||
* support python 3.14 in CI and docker ([#56](https://github.com/jorijn/meshcore-stats/issues/56)) ([b66f538](https://github.com/jorijn/meshcore-stats/commit/b66f5380b69108f22d53aaf1a48642c240788d3f))
|
||||
* switch to Renovate and pin uv image ([#38](https://github.com/jorijn/meshcore-stats/issues/38)) ([adc4423](https://github.com/jorijn/meshcore-stats/commit/adc442351bc84beb6216eafedd8e2eaa95109bfd))
|
||||
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
* **docker:** add PR build and smoke test ([#53](https://github.com/jorijn/meshcore-stats/issues/53)) ([40d7d3b](https://github.com/jorijn/meshcore-stats/commit/40d7d3b2faef5ae7c268cd1ecc9616d1dd421f12))
|
||||
* switch actions to version tags for renovate digests ([#54](https://github.com/jorijn/meshcore-stats/issues/54)) ([1f6e7c5](https://github.com/jorijn/meshcore-stats/commit/1f6e7c50935265579be4faadeb5dc88c4098a71c))
|
||||
|
||||
## [0.2.11](https://github.com/jorijn/meshcore-stats/compare/v0.2.10...v0.2.11) (2026-01-08)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# =============================================================================
|
||||
# Stage 0: uv binary
|
||||
# =============================================================================
|
||||
FROM ghcr.io/astral-sh/uv:0.10.9@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.9@sha256:f6e3549ed287fee0ddde2460a2a74a2d74366f84b04aaa34c1f19fec40da8652 AS uv
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: Build dependencies
|
||||
# =============================================================================
|
||||
FROM python:3.14-slim-bookworm@sha256:5404df00cf00e6e7273375f415651837b4d192ac6859c44d3b740888ac798c99 AS builder
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
# Ofelia version and checksums (verified from GitHub releases)
|
||||
ARG OFELIA_VERSION=0.3.12
|
||||
@@ -53,7 +53,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime
|
||||
# =============================================================================
|
||||
FROM python:3.14-slim-bookworm@sha256:5404df00cf00e6e7273375f415651837b4d192ac6859c44d3b740888ac798c99
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# OCI Labels
|
||||
LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats"
|
||||
|
||||
13
README.md
13
README.md
@@ -46,7 +46,6 @@ docker compose logs meshcore-stats | head -20
|
||||
|
||||
- **Data Collection** - Metrics from local companion and remote repeater nodes
|
||||
- **Interactive Charts** - SVG charts with day/week/month/year views and tooltips
|
||||
- **Auto Telemetry Charts** - Repeater `telemetry.*` metrics are charted automatically when telemetry is enabled (`telemetry.voltage.*` excluded)
|
||||
- **Statistics Reports** - Monthly and yearly report generation
|
||||
- **Light/Dark Theme** - Automatic theme switching based on system preference
|
||||
|
||||
@@ -91,16 +90,6 @@ COMPANION_DISPLAY_NAME=My Companion
|
||||
|
||||
See [meshcore.conf.example](meshcore.conf.example) for all available options.
|
||||
|
||||
Optional telemetry display settings:
|
||||
|
||||
```ini
|
||||
# Enable environmental telemetry collection from repeater
|
||||
TELEMETRY_ENABLED=1
|
||||
|
||||
# Telemetry display units only (DB values stay unchanged)
|
||||
DISPLAY_UNIT_SYSTEM=metric # or imperial
|
||||
```
|
||||
|
||||
#### 3. Create Data Directories
|
||||
|
||||
```bash
|
||||
@@ -173,7 +162,7 @@ For environments where Docker is not available.
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Python 3.11+ (3.14 recommended)
|
||||
- Python 3.10+
|
||||
- SQLite3
|
||||
- [uv](https://github.com/astral-sh/uv)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
# MeshCore Stats - Data collection and rendering
|
||||
# ==========================================================================
|
||||
meshcore-stats:
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.2.18 # x-release-please-version
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.2.11 # x-release-please-version
|
||||
container_name: meshcore-stats
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
# nginx - Static site server
|
||||
# ==========================================================================
|
||||
nginx:
|
||||
image: nginx:1.29-alpine@sha256:1d13701a5f9f3fb01aaa88cef2344d65b6b5bf6b7d9fa4cf0dca557a8d7702ba
|
||||
image: nginx:1.27-alpine
|
||||
container_name: meshcore-stats-nginx
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -52,15 +52,6 @@ COMPANION_DISPLAY_NAME=My Companion
|
||||
# REPEATER_PUBKEY_PREFIX=!a1b2c3d4
|
||||
# COMPANION_PUBKEY_PREFIX=!e5f6g7h8
|
||||
|
||||
# =============================================================================
|
||||
# Display Units (telemetry formatting only)
|
||||
# =============================================================================
|
||||
# Select telemetry display unit system:
|
||||
# metric -> °C, hPa, m
|
||||
# imperial -> °F, inHg, ft
|
||||
# Default: metric
|
||||
# DISPLAY_UNIT_SYSTEM=metric
|
||||
|
||||
# =============================================================================
|
||||
# Location Metadata (for reports and sidebar display)
|
||||
# =============================================================================
|
||||
@@ -135,8 +126,6 @@ RADIO_CODING_RATE=CR8
|
||||
# Enable telemetry collection from repeater's environmental sensors
|
||||
# (temperature, humidity, barometric pressure, etc.)
|
||||
# Requires sensor board attached to repeater (e.g., BME280, BME680)
|
||||
# Repeater dashboard charts for telemetry are auto-discovered from telemetry.* keys.
|
||||
# telemetry.voltage.* and telemetry.gps.* are collected but intentionally not charted.
|
||||
# Default: 0 (disabled)
|
||||
# TELEMETRY_ENABLED=1
|
||||
|
||||
@@ -148,14 +137,6 @@ RADIO_CODING_RATE=CR8
|
||||
# TELEMETRY_RETRY_ATTEMPTS=2
|
||||
# TELEMETRY_RETRY_BACKOFF_S=4
|
||||
|
||||
# =============================================================================
|
||||
# Custom HTML (Analytics, etc.)
|
||||
# =============================================================================
|
||||
# Inject custom HTML into the <head> of every page.
|
||||
# Useful for analytics scripts (Plausible, Matomo, etc.) without modifying source.
|
||||
# Example for Plausible:
|
||||
# CUSTOM_HEAD_HTML=<script defer data-domain="stats.example.com" src="https://plausible.io/js/script.js"></script>
|
||||
|
||||
# =============================================================================
|
||||
# Paths (Native installation only)
|
||||
# =============================================================================
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshcore-stats"
|
||||
version = "0.2.18"
|
||||
version = "0.2.11"
|
||||
description = "MeshCore LoRa mesh network monitoring and statistics"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices"
|
||||
"config:recommended"
|
||||
],
|
||||
"rebaseWhen": "behind-base-branch",
|
||||
"automergeType": "pr",
|
||||
"platformAutomerge": true,
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"dependencyDashboard": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"pinDigests": true
|
||||
},
|
||||
{
|
||||
"matchManagers": [
|
||||
"docker-compose"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"ghcr.io/jorijn/meshcore-stats"
|
||||
],
|
||||
"pinDigests": false
|
||||
},
|
||||
{
|
||||
"description": "Auto-merge patch and digest updates once checks pass",
|
||||
"matchUpdateTypes": [
|
||||
"patch",
|
||||
"digest",
|
||||
"lockFileMaintenance"
|
||||
],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
"dependencyDashboard": true
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""MeshCore network monitoring library."""
|
||||
|
||||
__version__ = "0.2.18" # x-release-please-version
|
||||
__version__ = "0.2.11" # x-release-please-version
|
||||
|
||||
@@ -19,10 +19,9 @@ import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from . import log
|
||||
from .db import get_available_metrics, get_metrics_for_period
|
||||
from .db import get_metrics_for_period
|
||||
from .env import get_config
|
||||
from .metrics import (
|
||||
convert_telemetry_value,
|
||||
get_chart_metrics,
|
||||
get_graph_scale,
|
||||
is_counter_metric,
|
||||
@@ -37,7 +36,6 @@ ThemeName = Literal["light", "dark"]
|
||||
BIN_30_MINUTES = 1800 # 30 minutes in seconds
|
||||
BIN_2_HOURS = 7200 # 2 hours in seconds
|
||||
BIN_1_DAY = 86400 # 1 day in seconds
|
||||
MIN_COUNTER_INTERVAL_RATIO = 0.9 # Allow small scheduling jitter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -211,7 +209,6 @@ def load_timeseries_from_db(
|
||||
|
||||
is_counter = is_counter_metric(metric)
|
||||
scale = get_graph_scale(metric)
|
||||
unit_system = get_config().display_unit_system
|
||||
|
||||
# Convert to (datetime, value) tuples with transform applied
|
||||
raw_points: list[tuple[datetime, float]] = []
|
||||
@@ -226,51 +223,31 @@ def load_timeseries_from_db(
|
||||
# For counter metrics, calculate rate of change
|
||||
if is_counter:
|
||||
rate_points: list[tuple[datetime, float]] = []
|
||||
cfg = get_config()
|
||||
min_interval = max(
|
||||
1.0,
|
||||
(cfg.companion_step if role == "companion" else cfg.repeater_step)
|
||||
* MIN_COUNTER_INTERVAL_RATIO,
|
||||
)
|
||||
|
||||
prev_ts, prev_val = raw_points[0]
|
||||
for curr_ts, curr_val in raw_points[1:]:
|
||||
for i in range(1, len(raw_points)):
|
||||
prev_ts, prev_val = raw_points[i - 1]
|
||||
curr_ts, curr_val = raw_points[i]
|
||||
|
||||
delta_val = curr_val - prev_val
|
||||
delta_secs = (curr_ts - prev_ts).total_seconds()
|
||||
|
||||
if delta_secs <= 0:
|
||||
continue
|
||||
if delta_secs < min_interval:
|
||||
log.debug(
|
||||
f"Skipping counter sample for {metric} at {curr_ts} "
|
||||
f"({delta_secs:.1f}s < {min_interval:.1f}s)"
|
||||
)
|
||||
continue
|
||||
|
||||
delta_val = curr_val - prev_val
|
||||
|
||||
# Skip negative deltas (device reboot)
|
||||
if delta_val < 0:
|
||||
log.debug(f"Counter reset detected for {metric} at {curr_ts}")
|
||||
prev_ts, prev_val = curr_ts, curr_val
|
||||
continue
|
||||
|
||||
# Calculate per-second rate, then apply scaling (typically x60 for per-minute)
|
||||
rate = (delta_val / delta_secs) * scale
|
||||
rate_points.append((curr_ts, rate))
|
||||
prev_ts, prev_val = curr_ts, curr_val
|
||||
|
||||
raw_points = rate_points
|
||||
else:
|
||||
# For gauges, just apply scaling
|
||||
raw_points = [(ts, val * scale) for ts, val in raw_points]
|
||||
|
||||
# Convert telemetry values to selected display unit system (display-only)
|
||||
if metric.startswith("telemetry."):
|
||||
raw_points = [
|
||||
(ts, convert_telemetry_value(metric, val, unit_system))
|
||||
for ts, val in raw_points
|
||||
]
|
||||
|
||||
# Apply time binning if configured
|
||||
period_cfg = PERIOD_CONFIG.get(period)
|
||||
if period_cfg and period_cfg.bin_seconds and len(raw_points) > 1:
|
||||
@@ -600,15 +577,10 @@ def render_all_charts(
|
||||
Tuple of (list of generated chart paths, stats dict)
|
||||
Stats dict structure: {metric_name: {period: {min, avg, max, current}}}
|
||||
"""
|
||||
cfg = get_config()
|
||||
if metrics is None:
|
||||
available_metrics = get_available_metrics(role)
|
||||
metrics = get_chart_metrics(
|
||||
role,
|
||||
available_metrics=available_metrics,
|
||||
telemetry_enabled=cfg.telemetry_enabled,
|
||||
)
|
||||
metrics = get_chart_metrics(role)
|
||||
|
||||
cfg = get_config()
|
||||
charts_dir = cfg.out_dir / "assets" / role
|
||||
charts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -126,14 +126,6 @@ def get_path(key: str, default: str) -> Path:
|
||||
return Path(val).expanduser().resolve()
|
||||
|
||||
|
||||
def get_unit_system(key: str, default: str = "metric") -> str:
|
||||
"""Get display unit system env var, normalized to metric/imperial."""
|
||||
val = os.environ.get(key, default).strip().lower()
|
||||
if val in ("metric", "imperial"):
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration loaded from environment variables."""
|
||||
|
||||
@@ -170,7 +162,6 @@ class Config:
|
||||
# Paths
|
||||
state_dir: Path
|
||||
out_dir: Path
|
||||
html_path: str
|
||||
|
||||
# Report location metadata
|
||||
report_location_name: str | None
|
||||
@@ -194,9 +185,6 @@ class Config:
|
||||
radio_spread_factor: str | None
|
||||
radio_coding_rate: str | None
|
||||
|
||||
# Display formatting
|
||||
display_unit_system: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Connection settings
|
||||
self.mesh_transport = get_str("MESH_TRANSPORT", "serial") or "serial"
|
||||
@@ -268,13 +256,6 @@ class Config:
|
||||
self.radio_spread_factor = get_str("RADIO_SPREAD_FACTOR", "SF8")
|
||||
self.radio_coding_rate = get_str("RADIO_CODING_RATE", "CR8")
|
||||
|
||||
# Display formatting
|
||||
self.display_unit_system = get_unit_system("DISPLAY_UNIT_SYSTEM", "metric")
|
||||
|
||||
self.html_path = get_str("HTML_PATH", "") or ""
|
||||
|
||||
# Custom HTML injected into <head> (e.g. analytics scripts)
|
||||
self.custom_head_html = get_str("CUSTOM_HEAD_HTML", "") or ""
|
||||
|
||||
# Global config instance
|
||||
_config: Config | None = None
|
||||
|
||||
@@ -22,13 +22,7 @@ from .formatters import (
|
||||
format_uptime,
|
||||
format_value,
|
||||
)
|
||||
from .metrics import (
|
||||
get_chart_metrics,
|
||||
get_metric_label,
|
||||
get_metric_unit,
|
||||
get_telemetry_metric_decimals,
|
||||
is_telemetry_metric,
|
||||
)
|
||||
from .metrics import get_chart_metrics, get_metric_label
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .reports import MonthlyAggregate, YearlyAggregate
|
||||
@@ -434,14 +428,6 @@ def _format_stat_value(value: float | None, metric: str) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
|
||||
# Telemetry metrics can be auto-discovered and need dynamic unit conversion.
|
||||
if is_telemetry_metric(metric):
|
||||
cfg = get_config()
|
||||
decimals = get_telemetry_metric_decimals(metric, cfg.display_unit_system)
|
||||
unit = get_metric_unit(metric, cfg.display_unit_system)
|
||||
formatted = f"{value:.{decimals}f}"
|
||||
return f"{formatted} {unit}" if unit else formatted
|
||||
|
||||
# Determine format and suffix based on metric (using firmware field names)
|
||||
# Battery voltage (already transformed to volts in charts.py)
|
||||
if metric in ("bat", "battery_mv"):
|
||||
@@ -494,7 +480,6 @@ def build_chart_groups(
|
||||
role: str,
|
||||
period: str,
|
||||
chart_stats: dict | None = None,
|
||||
asset_prefix: str = "",
|
||||
) -> list[dict]:
|
||||
"""Build chart groups for template.
|
||||
|
||||
@@ -505,31 +490,10 @@ def build_chart_groups(
|
||||
role: "companion" or "repeater"
|
||||
period: Time period ("day", "week", etc.)
|
||||
chart_stats: Stats dict from chart_stats.json (optional)
|
||||
asset_prefix: Relative path prefix to reach /assets from page location
|
||||
"""
|
||||
cfg = get_config()
|
||||
available_metrics = sorted(chart_stats.keys()) if chart_stats else []
|
||||
chart_metrics = get_chart_metrics(
|
||||
role,
|
||||
available_metrics=available_metrics,
|
||||
telemetry_enabled=cfg.telemetry_enabled,
|
||||
)
|
||||
groups_config = [
|
||||
{"title": group["title"], "metrics": list(group["metrics"])}
|
||||
for group in (
|
||||
REPEATER_CHART_GROUPS if role == "repeater" else COMPANION_CHART_GROUPS
|
||||
)
|
||||
]
|
||||
|
||||
if role == "repeater" and cfg.telemetry_enabled:
|
||||
telemetry_metrics = [metric for metric in chart_metrics if is_telemetry_metric(metric)]
|
||||
if telemetry_metrics:
|
||||
groups_config.append(
|
||||
{
|
||||
"title": "Telemetry",
|
||||
"metrics": telemetry_metrics,
|
||||
}
|
||||
)
|
||||
groups_config = REPEATER_CHART_GROUPS if role == "repeater" else COMPANION_CHART_GROUPS
|
||||
chart_metrics = get_chart_metrics(role)
|
||||
|
||||
if chart_stats is None:
|
||||
chart_stats = {}
|
||||
@@ -587,9 +551,8 @@ def build_chart_groups(
|
||||
chart_data["use_svg"] = True
|
||||
else:
|
||||
# Fallback to PNG paths
|
||||
asset_base = f"{asset_prefix}assets/{role}/"
|
||||
chart_data["src_light"] = f"{asset_base}{metric}_{period}_light.png"
|
||||
chart_data["src_dark"] = f"{asset_base}{metric}_{period}_dark.png"
|
||||
chart_data["src_light"] = f"/assets/{role}/{metric}_{period}_light.png"
|
||||
chart_data["src_dark"] = f"/assets/{role}/{metric}_{period}_dark.png"
|
||||
chart_data["use_svg"] = False
|
||||
|
||||
charts.append(chart_data)
|
||||
@@ -651,10 +614,7 @@ def build_page_context(
|
||||
|
||||
# Load chart stats and build chart groups
|
||||
chart_stats = load_chart_stats(role)
|
||||
|
||||
# Relative path prefixes (avoid absolute paths for subpath deployments)
|
||||
css_path = "" if at_root else "../"
|
||||
asset_prefix = "" if at_root else "../"
|
||||
chart_groups = build_chart_groups(role, period, chart_stats)
|
||||
|
||||
# Period config
|
||||
page_title, page_subtitle = PERIOD_CONFIG.get(period, ("Observations", "Radio telemetry"))
|
||||
@@ -674,18 +634,9 @@ def build_page_context(
|
||||
),
|
||||
}
|
||||
|
||||
chart_groups = build_chart_groups(role, period, chart_stats, asset_prefix=asset_prefix)
|
||||
|
||||
# Navigation links depend on whether we're at root or in /companion/
|
||||
base_path = ""
|
||||
if at_root:
|
||||
repeater_link = "day.html"
|
||||
companion_link = "companion/day.html"
|
||||
reports_link = "reports/"
|
||||
else:
|
||||
repeater_link = "../day.html"
|
||||
companion_link = "day.html"
|
||||
reports_link = "../reports/"
|
||||
# CSS and link paths - depend on whether we're at root or in /companion/
|
||||
css_path = "/" if at_root else "../"
|
||||
base_path = "" if at_root else "/companion"
|
||||
|
||||
return {
|
||||
# Page meta
|
||||
@@ -693,7 +644,6 @@ def build_page_context(
|
||||
"meta_description": meta_descriptions.get(role, "MeshCore mesh network statistics dashboard."),
|
||||
"og_image": None,
|
||||
"css_path": css_path,
|
||||
"display_unit_system": cfg.display_unit_system,
|
||||
|
||||
# Node info
|
||||
"node_name": node_name,
|
||||
@@ -715,9 +665,9 @@ def build_page_context(
|
||||
# Navigation
|
||||
"period": period,
|
||||
"base_path": base_path,
|
||||
"repeater_link": repeater_link,
|
||||
"companion_link": companion_link,
|
||||
"reports_link": reports_link,
|
||||
"repeater_link": f"{css_path}day.html",
|
||||
"companion_link": f"{css_path}companion/day.html",
|
||||
"reports_link": f"{css_path}reports/",
|
||||
|
||||
# Timestamps
|
||||
"last_updated": last_updated,
|
||||
@@ -727,9 +677,6 @@ def build_page_context(
|
||||
"page_title": page_title,
|
||||
"page_subtitle": page_subtitle,
|
||||
"chart_groups": chart_groups,
|
||||
|
||||
# Custom HTML
|
||||
"custom_head_html": cfg.custom_head_html,
|
||||
}
|
||||
|
||||
|
||||
@@ -1307,7 +1254,6 @@ def render_report_page(
|
||||
"monthly_links": monthly_links,
|
||||
"prev_report": prev_report,
|
||||
"next_report": next_report,
|
||||
"custom_head_html": cfg.custom_head_html,
|
||||
}
|
||||
|
||||
template = env.get_template("report.html")
|
||||
@@ -1345,7 +1291,6 @@ def render_reports_index(report_sections: list[dict]) -> str:
|
||||
"css_path": "../",
|
||||
"report_sections": report_sections,
|
||||
"month_abbrs": month_abbrs,
|
||||
"custom_head_html": cfg.custom_head_html,
|
||||
}
|
||||
|
||||
template = env.get_template("report_index.html")
|
||||
|
||||
@@ -11,25 +11,8 @@ Firmware field names are used directly (e.g., 'bat', 'nb_recv', 'battery_mv').
|
||||
See docs/firmware-responses.md for the complete field reference.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
TELEMETRY_METRIC_RE = re.compile(
|
||||
r"^telemetry\.([a-z0-9_]+)\.(\d+)(?:\.([a-z0-9_]+))?$"
|
||||
)
|
||||
TELEMETRY_EXCLUDED_SENSOR_TYPES = {"gps", "voltage"}
|
||||
HPA_TO_INHG = 0.029529983071445
|
||||
M_TO_FT = 3.280839895013123
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelemetryMetricParts:
|
||||
"""Parsed telemetry metric parts."""
|
||||
|
||||
sensor_type: str
|
||||
channel: int
|
||||
subkey: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricConfig:
|
||||
@@ -222,145 +205,19 @@ REPEATER_CHART_METRICS = [
|
||||
# Helper functions
|
||||
# =============================================================================
|
||||
|
||||
def parse_telemetry_metric(metric: str) -> TelemetryMetricParts | None:
|
||||
"""Parse telemetry metric key into its parts.
|
||||
|
||||
Expected format: telemetry.<type>.<channel>[.<subkey>]
|
||||
"""
|
||||
match = TELEMETRY_METRIC_RE.match(metric)
|
||||
if not match:
|
||||
return None
|
||||
sensor_type, channel_raw, subkey = match.groups()
|
||||
return TelemetryMetricParts(
|
||||
sensor_type=sensor_type,
|
||||
channel=int(channel_raw),
|
||||
subkey=subkey,
|
||||
)
|
||||
|
||||
|
||||
def is_telemetry_metric(metric: str) -> bool:
|
||||
"""Check if metric key is a telemetry metric."""
|
||||
return parse_telemetry_metric(metric) is not None
|
||||
|
||||
|
||||
def _normalize_unit_system(unit_system: str) -> str:
|
||||
"""Normalize unit system string to metric/imperial."""
|
||||
return unit_system if unit_system in ("metric", "imperial") else "metric"
|
||||
|
||||
|
||||
def _humanize_token(token: str) -> str:
|
||||
"""Convert snake_case token to display title, preserving common acronyms."""
|
||||
if token.lower() == "gps":
|
||||
return "GPS"
|
||||
return token.replace("_", " ").title()
|
||||
|
||||
|
||||
def get_telemetry_metric_label(metric: str) -> str:
|
||||
"""Get human-readable label for a telemetry metric key."""
|
||||
parts = parse_telemetry_metric(metric)
|
||||
if parts is None:
|
||||
return metric
|
||||
|
||||
base = _humanize_token(parts.sensor_type)
|
||||
if parts.subkey:
|
||||
base = f"{base} {_humanize_token(parts.subkey)}"
|
||||
return f"{base} (CH{parts.channel})"
|
||||
|
||||
|
||||
def get_telemetry_metric_unit(metric: str, unit_system: str = "metric") -> str:
|
||||
"""Get telemetry unit based on metric type and selected unit system."""
|
||||
parts = parse_telemetry_metric(metric)
|
||||
if parts is None:
|
||||
return ""
|
||||
|
||||
unit_system = _normalize_unit_system(unit_system)
|
||||
|
||||
if parts.sensor_type == "temperature":
|
||||
return "°F" if unit_system == "imperial" else "°C"
|
||||
if parts.sensor_type == "humidity":
|
||||
return "%"
|
||||
if parts.sensor_type in ("barometer", "pressure"):
|
||||
return "inHg" if unit_system == "imperial" else "hPa"
|
||||
if parts.sensor_type == "altitude":
|
||||
return "ft" if unit_system == "imperial" else "m"
|
||||
return ""
|
||||
|
||||
|
||||
def get_telemetry_metric_decimals(metric: str, unit_system: str = "metric") -> int:
|
||||
"""Get display decimal precision for telemetry metrics."""
|
||||
parts = parse_telemetry_metric(metric)
|
||||
if parts is None:
|
||||
return 2
|
||||
|
||||
unit_system = _normalize_unit_system(unit_system)
|
||||
|
||||
if parts.sensor_type in ("temperature", "humidity", "altitude"):
|
||||
return 1
|
||||
if parts.sensor_type in ("barometer", "pressure"):
|
||||
return 2 if unit_system == "imperial" else 1
|
||||
return 2
|
||||
|
||||
|
||||
def convert_telemetry_value(metric: str, value: float, unit_system: str = "metric") -> float:
|
||||
"""Convert telemetry value to selected display unit system."""
|
||||
parts = parse_telemetry_metric(metric)
|
||||
if parts is None:
|
||||
return value
|
||||
|
||||
unit_system = _normalize_unit_system(unit_system)
|
||||
if unit_system != "imperial":
|
||||
return value
|
||||
|
||||
if parts.sensor_type == "temperature":
|
||||
return (value * 9.0 / 5.0) + 32.0
|
||||
if parts.sensor_type in ("barometer", "pressure"):
|
||||
return value * HPA_TO_INHG
|
||||
if parts.sensor_type == "altitude":
|
||||
return value * M_TO_FT
|
||||
return value
|
||||
|
||||
|
||||
def discover_telemetry_chart_metrics(available_metrics: list[str]) -> list[str]:
|
||||
"""Discover telemetry metrics to chart from available metric keys."""
|
||||
discovered: set[str] = set()
|
||||
for metric in available_metrics:
|
||||
parts = parse_telemetry_metric(metric)
|
||||
if parts is None:
|
||||
continue
|
||||
if parts.sensor_type in TELEMETRY_EXCLUDED_SENSOR_TYPES:
|
||||
continue
|
||||
discovered.add(metric)
|
||||
|
||||
return sorted(
|
||||
discovered,
|
||||
key=lambda metric: (get_telemetry_metric_label(metric).lower(), metric),
|
||||
)
|
||||
|
||||
|
||||
def get_chart_metrics(
|
||||
role: str,
|
||||
available_metrics: list[str] | None = None,
|
||||
telemetry_enabled: bool = False,
|
||||
) -> list[str]:
|
||||
def get_chart_metrics(role: str) -> list[str]:
|
||||
"""Get list of metrics to chart for a role.
|
||||
|
||||
Args:
|
||||
role: 'companion' or 'repeater'
|
||||
available_metrics: Optional list of available metrics for discovery
|
||||
telemetry_enabled: Whether telemetry charts should be included
|
||||
|
||||
Returns:
|
||||
List of metric names in display order
|
||||
"""
|
||||
if role == "companion":
|
||||
return list(COMPANION_CHART_METRICS)
|
||||
return COMPANION_CHART_METRICS
|
||||
elif role == "repeater":
|
||||
metrics = list(REPEATER_CHART_METRICS)
|
||||
if telemetry_enabled and available_metrics:
|
||||
for metric in discover_telemetry_chart_metrics(available_metrics):
|
||||
if metric not in metrics:
|
||||
metrics.append(metric)
|
||||
return metrics
|
||||
return REPEATER_CHART_METRICS
|
||||
else:
|
||||
raise ValueError(f"Unknown role: {role}")
|
||||
|
||||
@@ -416,29 +273,20 @@ def get_metric_label(metric: str) -> str:
|
||||
Display label or the metric name if not configured
|
||||
"""
|
||||
config = METRIC_CONFIG.get(metric)
|
||||
if config:
|
||||
return config.label
|
||||
if is_telemetry_metric(metric):
|
||||
return get_telemetry_metric_label(metric)
|
||||
return metric
|
||||
return config.label if config else metric
|
||||
|
||||
|
||||
def get_metric_unit(metric: str, unit_system: str = "metric") -> str:
|
||||
def get_metric_unit(metric: str) -> str:
|
||||
"""Get display unit for a metric.
|
||||
|
||||
Args:
|
||||
metric: Firmware field name
|
||||
unit_system: Unit system for telemetry metrics ('metric' or 'imperial')
|
||||
|
||||
Returns:
|
||||
Unit string or empty string if not configured
|
||||
"""
|
||||
config = METRIC_CONFIG.get(metric)
|
||||
if config:
|
||||
return config.unit
|
||||
if is_telemetry_metric(metric):
|
||||
return get_telemetry_metric_unit(metric, unit_system)
|
||||
return ""
|
||||
return config.unit if config else ""
|
||||
|
||||
|
||||
def transform_value(metric: str, value: float) -> float:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-unit-system="{{ display_unit_system | default('metric') }}">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -19,7 +19,6 @@
|
||||
<meta name="twitter:description" content="{{ meta_description }}">
|
||||
|
||||
<link rel="stylesheet" href="{{ css_path }}styles.css">
|
||||
{% if custom_head_html %}{{ custom_head_html | safe }}{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
@@ -28,16 +28,6 @@
|
||||
dark: { fill: '#f59e0b', stroke: '#0f1114' }
|
||||
}
|
||||
};
|
||||
var UNIT_SYSTEM =
|
||||
(document.documentElement &&
|
||||
document.documentElement.dataset &&
|
||||
document.documentElement.dataset.unitSystem) ||
|
||||
'metric';
|
||||
if (UNIT_SYSTEM !== 'imperial') {
|
||||
UNIT_SYSTEM = 'metric';
|
||||
}
|
||||
|
||||
var TELEMETRY_REGEX = /^telemetry\.([a-z0-9_]+)\.(\d+)(?:\.([a-z0-9_]+))?$/;
|
||||
|
||||
/**
|
||||
* Metric display configuration keyed by firmware field name.
|
||||
@@ -75,68 +65,6 @@
|
||||
// Formatting Utilities
|
||||
// ============================================================================
|
||||
|
||||
function parseTelemetryMetric(metric) {
|
||||
var match = TELEMETRY_REGEX.exec(metric);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sensorType: match[1],
|
||||
channel: parseInt(match[2], 10),
|
||||
subkey: match[3] || null
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeToken(token) {
|
||||
if (!token) {
|
||||
return '';
|
||||
}
|
||||
if (token.toLowerCase() === 'gps') {
|
||||
return 'GPS';
|
||||
}
|
||||
return token
|
||||
.split('_')
|
||||
.map(function (part) {
|
||||
if (!part) {
|
||||
return '';
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getTelemetryLabel(metric) {
|
||||
var telemetry = parseTelemetryMetric(metric);
|
||||
if (!telemetry) {
|
||||
return metric;
|
||||
}
|
||||
var label = humanizeToken(telemetry.sensorType);
|
||||
if (telemetry.subkey) {
|
||||
label += ' ' + humanizeToken(telemetry.subkey);
|
||||
}
|
||||
return label + ' (CH' + telemetry.channel + ')';
|
||||
}
|
||||
|
||||
function getTelemetryFormat(sensorType, unitSystem) {
|
||||
if (sensorType === 'temperature') {
|
||||
return { unit: unitSystem === 'imperial' ? '\u00B0F' : '\u00B0C', decimals: 1 };
|
||||
}
|
||||
if (sensorType === 'humidity') {
|
||||
return { unit: '%', decimals: 1 };
|
||||
}
|
||||
if (sensorType === 'barometer' || sensorType === 'pressure') {
|
||||
return {
|
||||
unit: unitSystem === 'imperial' ? 'inHg' : 'hPa',
|
||||
decimals: unitSystem === 'imperial' ? 2 : 1
|
||||
};
|
||||
}
|
||||
if (sensorType === 'altitude') {
|
||||
return { unit: unitSystem === 'imperial' ? 'ft' : 'm', decimals: 1 };
|
||||
}
|
||||
return { unit: '', decimals: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp as a localized date/time string.
|
||||
* Uses browser language preference for locale (determines 12/24 hour format).
|
||||
@@ -165,32 +93,11 @@
|
||||
* Format a numeric value with the appropriate decimals and unit for a metric.
|
||||
*/
|
||||
function formatMetricValue(value, metric) {
|
||||
var telemetry = parseTelemetryMetric(metric);
|
||||
if (telemetry) {
|
||||
var telemetryFormat = getTelemetryFormat(telemetry.sensorType, UNIT_SYSTEM);
|
||||
var telemetryFormatted = value.toFixed(telemetryFormat.decimals);
|
||||
return telemetryFormat.unit
|
||||
? telemetryFormatted + ' ' + telemetryFormat.unit
|
||||
: telemetryFormatted;
|
||||
}
|
||||
|
||||
var config = METRIC_CONFIG[metric] || { label: metric, unit: '', decimals: 2 };
|
||||
var formatted = value.toFixed(config.decimals);
|
||||
return config.unit ? formatted + ' ' + config.unit : formatted;
|
||||
}
|
||||
|
||||
function getMetricLabel(metric) {
|
||||
var telemetry = parseTelemetryMetric(metric);
|
||||
if (telemetry) {
|
||||
return getTelemetryLabel(metric);
|
||||
}
|
||||
var config = METRIC_CONFIG[metric];
|
||||
if (config && config.label) {
|
||||
return config.label;
|
||||
}
|
||||
return metric;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Point Utilities
|
||||
// ============================================================================
|
||||
@@ -496,7 +403,7 @@
|
||||
showTooltip(
|
||||
event,
|
||||
formatTimestamp(closestPoint.ts, period),
|
||||
getMetricLabel(metric) + ': ' + formatMetricValue(closestPoint.v, metric)
|
||||
formatMetricValue(closestPoint.v, metric)
|
||||
);
|
||||
|
||||
positionIndicator(svg, closestPoint, xStart, xEnd, yMin, yMax, plotArea);
|
||||
|
||||
@@ -113,10 +113,10 @@
|
||||
<main class="main-content">
|
||||
<!-- Period Navigation -->
|
||||
<nav class="period-nav">
|
||||
<a href="{{ base_path }}day.html"{% if period == 'day' %} class="active"{% endif %}>Day</a>
|
||||
<a href="{{ base_path }}week.html"{% if period == 'week' %} class="active"{% endif %}>Week</a>
|
||||
<a href="{{ base_path }}month.html"{% if period == 'month' %} class="active"{% endif %}>Month</a>
|
||||
<a href="{{ base_path }}year.html"{% if period == 'year' %} class="active"{% endif %}>Year</a>
|
||||
<a href="{{ base_path }}/day.html"{% if period == 'day' %} class="active"{% endif %}>Day</a>
|
||||
<a href="{{ base_path }}/week.html"{% if period == 'week' %} class="active"{% endif %}>Week</a>
|
||||
<a href="{{ base_path }}/month.html"{% if period == 'month' %} class="active"{% endif %}>Month</a>
|
||||
<a href="{{ base_path }}/year.html"{% if period == 'year' %} class="active"{% endif %}>Year</a>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Tests for render_all_charts metric selection behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import meshmon.charts as charts
|
||||
|
||||
|
||||
def test_render_all_charts_includes_repeater_telemetry_when_enabled(configured_env, monkeypatch):
|
||||
"""Repeater chart rendering auto-discovers telemetry metrics when enabled."""
|
||||
monkeypatch.setenv("TELEMETRY_ENABLED", "1")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
base_ts = int(datetime(2024, 1, 1, 0, 0, 0).timestamp())
|
||||
|
||||
monkeypatch.setattr(
|
||||
charts,
|
||||
"get_available_metrics",
|
||||
lambda role: [
|
||||
"bat",
|
||||
"telemetry.temperature.1",
|
||||
"telemetry.humidity.1",
|
||||
"telemetry.voltage.1",
|
||||
"telemetry.gps.0.latitude",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
charts,
|
||||
"get_metrics_for_period",
|
||||
lambda role, start_ts, end_ts: {
|
||||
"telemetry.temperature.1": [
|
||||
(base_ts, 6.0),
|
||||
(base_ts + 900, 7.0),
|
||||
],
|
||||
"telemetry.humidity.1": [
|
||||
(base_ts, 84.0),
|
||||
(base_ts + 900, 85.0),
|
||||
],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(charts, "render_chart_svg", lambda *args, **kwargs: "<svg></svg>")
|
||||
|
||||
_generated, stats = charts.render_all_charts("repeater")
|
||||
|
||||
assert "telemetry.temperature.1" in stats
|
||||
assert "telemetry.humidity.1" in stats
|
||||
assert "telemetry.voltage.1" not in stats
|
||||
assert "telemetry.gps.0.latitude" not in stats
|
||||
|
||||
|
||||
def test_render_all_charts_excludes_telemetry_when_disabled(configured_env, monkeypatch):
|
||||
"""Telemetry metrics are not rendered when TELEMETRY_ENABLED=0."""
|
||||
monkeypatch.setenv("TELEMETRY_ENABLED", "0")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
charts,
|
||||
"get_available_metrics",
|
||||
lambda role: ["bat", "telemetry.temperature.1", "telemetry.humidity.1"],
|
||||
)
|
||||
monkeypatch.setattr(charts, "get_metrics_for_period", lambda role, start_ts, end_ts: {})
|
||||
monkeypatch.setattr(charts, "render_chart_svg", lambda *args, **kwargs: "<svg></svg>")
|
||||
|
||||
_generated, stats = charts.render_all_charts("repeater")
|
||||
|
||||
assert not any(metric.startswith("telemetry.") for metric in stats)
|
||||
@@ -185,43 +185,3 @@ class TestLoadTimeseriesFromDb:
|
||||
|
||||
timestamps = [p.timestamp for p in ts.points]
|
||||
assert timestamps == sorted(timestamps)
|
||||
|
||||
def test_telemetry_temperature_converts_to_imperial(self, initialized_db, configured_env, monkeypatch):
|
||||
"""Telemetry temperature converts from C to F when DISPLAY_UNIT_SYSTEM=imperial."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
base_ts = 1704067200
|
||||
insert_metrics(base_ts, "repeater", {"telemetry.temperature.1": 0.0}, initialized_db)
|
||||
insert_metrics(base_ts + 900, "repeater", {"telemetry.temperature.1": 10.0}, initialized_db)
|
||||
|
||||
ts = load_timeseries_from_db(
|
||||
role="repeater",
|
||||
metric="telemetry.temperature.1",
|
||||
end_time=datetime.fromtimestamp(base_ts + 1000),
|
||||
lookback=timedelta(hours=1),
|
||||
period="day",
|
||||
)
|
||||
|
||||
assert [p.value for p in ts.points] == pytest.approx([32.0, 50.0])
|
||||
|
||||
def test_telemetry_temperature_stays_metric(self, initialized_db, configured_env, monkeypatch):
|
||||
"""Telemetry temperature remains Celsius when DISPLAY_UNIT_SYSTEM=metric."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "metric")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
base_ts = 1704067200
|
||||
insert_metrics(base_ts, "repeater", {"telemetry.temperature.1": 0.0}, initialized_db)
|
||||
insert_metrics(base_ts + 900, "repeater", {"telemetry.temperature.1": 10.0}, initialized_db)
|
||||
|
||||
ts = load_timeseries_from_db(
|
||||
role="repeater",
|
||||
metric="telemetry.temperature.1",
|
||||
end_time=datetime.fromtimestamp(base_ts + 1000),
|
||||
lookback=timedelta(hours=1),
|
||||
period="day",
|
||||
)
|
||||
|
||||
assert [p.value for p in ts.points] == pytest.approx([0.0, 10.0])
|
||||
|
||||
@@ -67,49 +67,10 @@ class TestCounterToRateConversion:
|
||||
assert ts.points[0].value == pytest.approx(expected_rate)
|
||||
assert ts.points[1].value == pytest.approx(expected_rate)
|
||||
|
||||
def test_counter_rate_short_interval_under_step_is_skipped(
|
||||
self,
|
||||
initialized_db,
|
||||
configured_env,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Short sampling intervals are skipped to avoid rate spikes."""
|
||||
base_ts = 1704067200
|
||||
|
||||
monkeypatch.setenv("REPEATER_STEP", "900")
|
||||
import meshmon.env
|
||||
|
||||
meshmon.env._config = None
|
||||
|
||||
insert_metrics(base_ts, "repeater", {"nb_recv": 0.0}, initialized_db)
|
||||
insert_metrics(base_ts + 900, "repeater", {"nb_recv": 100.0}, initialized_db)
|
||||
insert_metrics(base_ts + 904, "repeater", {"nb_recv": 110.0}, initialized_db)
|
||||
insert_metrics(base_ts + 1800, "repeater", {"nb_recv": 200.0}, initialized_db)
|
||||
|
||||
ts = load_timeseries_from_db(
|
||||
role="repeater",
|
||||
metric="nb_recv",
|
||||
end_time=datetime.fromtimestamp(base_ts + 1800),
|
||||
lookback=timedelta(hours=2),
|
||||
period="day",
|
||||
)
|
||||
|
||||
expected_rate = (100.0 / 900.0) * 60.0
|
||||
assert len(ts.points) == 2
|
||||
assert ts.points[0].timestamp == datetime.fromtimestamp(base_ts + 900)
|
||||
assert ts.points[1].timestamp == datetime.fromtimestamp(base_ts + 1800)
|
||||
for point in ts.points:
|
||||
assert point.value == pytest.approx(expected_rate)
|
||||
|
||||
def test_applies_scale_factor(self, initialized_db, configured_env, monkeypatch):
|
||||
def test_applies_scale_factor(self, initialized_db, configured_env):
|
||||
"""Counter rate is scaled (typically x60 for per-minute)."""
|
||||
base_ts = 1704067200
|
||||
|
||||
monkeypatch.setenv("REPEATER_STEP", "60")
|
||||
import meshmon.env
|
||||
|
||||
meshmon.env._config = None
|
||||
|
||||
# Insert values 60 seconds apart for easy math
|
||||
insert_metrics(base_ts, "repeater", {"nb_recv": 0.0}, initialized_db)
|
||||
insert_metrics(base_ts + 60, "repeater", {"nb_recv": 60.0}, initialized_db)
|
||||
|
||||
@@ -130,23 +130,6 @@ class TestConfigComplete:
|
||||
assert config.telemetry_retry_attempts == 3
|
||||
assert config.telemetry_retry_backoff_s == 5
|
||||
|
||||
def test_display_unit_system_defaults_to_metric(self, clean_env):
|
||||
"""DISPLAY_UNIT_SYSTEM defaults to metric."""
|
||||
config = Config()
|
||||
assert config.display_unit_system == "metric"
|
||||
|
||||
def test_display_unit_system_accepts_imperial(self, clean_env, monkeypatch):
|
||||
"""DISPLAY_UNIT_SYSTEM=imperial is honored."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial")
|
||||
config = Config()
|
||||
assert config.display_unit_system == "imperial"
|
||||
|
||||
def test_display_unit_system_invalid_falls_back_to_metric(self, clean_env, monkeypatch):
|
||||
"""Invalid DISPLAY_UNIT_SYSTEM falls back to metric."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "kelvin")
|
||||
config = Config()
|
||||
assert config.display_unit_system == "metric"
|
||||
|
||||
def test_all_location_settings(self, clean_env, monkeypatch):
|
||||
"""All location/report settings are loaded."""
|
||||
monkeypatch.setenv("REPORT_LOCATION_NAME", "Mountain Peak Observatory")
|
||||
|
||||
@@ -15,7 +15,6 @@ def clean_env(monkeypatch):
|
||||
"COMPANION_",
|
||||
"REMOTE_",
|
||||
"TELEMETRY_",
|
||||
"DISPLAY_",
|
||||
"REPORT_",
|
||||
"RADIO_",
|
||||
"STATE_DIR",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Tests for chart group building, including telemetry grouping behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import meshmon.html as html
|
||||
|
||||
|
||||
def test_repeater_appends_telemetry_group_when_enabled(configured_env, monkeypatch):
|
||||
"""Repeater chart groups append telemetry section when enabled and available."""
|
||||
monkeypatch.setenv("TELEMETRY_ENABLED", "1")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
monkeypatch.setattr(html, "_load_svg_content", lambda path: "<svg></svg>")
|
||||
|
||||
chart_stats = {
|
||||
"bat": {"day": {"min": 3.5, "avg": 3.7, "max": 3.9, "current": 3.8}},
|
||||
"telemetry.temperature.1": {"day": {"min": 5.0, "avg": 6.0, "max": 7.0, "current": 6.5}},
|
||||
"telemetry.humidity.1": {"day": {"min": 82.0, "avg": 84.0, "max": 86.0, "current": 85.0}},
|
||||
"telemetry.voltage.1": {"day": {"min": 3.9, "avg": 4.0, "max": 4.1, "current": 4.0}},
|
||||
"telemetry.gps.0.latitude": {"day": {"min": 52.1, "avg": 52.2, "max": 52.3, "current": 52.25}},
|
||||
}
|
||||
|
||||
groups = html.build_chart_groups("repeater", "day", chart_stats)
|
||||
|
||||
assert groups[-1]["title"] == "Telemetry"
|
||||
telemetry_metrics = [chart["metric"] for chart in groups[-1]["charts"]]
|
||||
assert "telemetry.temperature.1" in telemetry_metrics
|
||||
assert "telemetry.humidity.1" in telemetry_metrics
|
||||
assert "telemetry.voltage.1" not in telemetry_metrics
|
||||
assert "telemetry.gps.0.latitude" not in telemetry_metrics
|
||||
|
||||
|
||||
def test_repeater_has_no_telemetry_group_when_disabled(configured_env, monkeypatch):
|
||||
"""Repeater chart groups do not include telemetry section when disabled."""
|
||||
monkeypatch.setenv("TELEMETRY_ENABLED", "0")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
monkeypatch.setattr(html, "_load_svg_content", lambda path: "<svg></svg>")
|
||||
|
||||
chart_stats = {
|
||||
"bat": {"day": {"min": 3.5, "avg": 3.7, "max": 3.9, "current": 3.8}},
|
||||
"telemetry.temperature.1": {"day": {"min": 5.0, "avg": 6.0, "max": 7.0, "current": 6.5}},
|
||||
}
|
||||
|
||||
groups = html.build_chart_groups("repeater", "day", chart_stats)
|
||||
|
||||
assert "Telemetry" not in [group["title"] for group in groups]
|
||||
@@ -147,28 +147,3 @@ class TestTemplateRendering:
|
||||
assert "<html" in html
|
||||
assert "<head>" in html
|
||||
assert "<body>" in html
|
||||
|
||||
def test_custom_head_html_rendered_when_set(self):
|
||||
"""Custom head HTML appears in rendered output when provided."""
|
||||
env = get_jinja_env()
|
||||
template = env.get_template("base.html")
|
||||
|
||||
snippet = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>'
|
||||
html = template.render(
|
||||
title="Test",
|
||||
custom_head_html=snippet,
|
||||
)
|
||||
|
||||
assert snippet in html
|
||||
assert html.index(snippet) < html.index("</head>")
|
||||
|
||||
def test_custom_head_html_absent_when_empty(self):
|
||||
"""No extra content in head when custom_head_html is empty."""
|
||||
env = get_jinja_env()
|
||||
template = env.get_template("base.html")
|
||||
|
||||
html_with = template.render(title="Test", custom_head_html="")
|
||||
html_without = template.render(title="Test")
|
||||
|
||||
# Both should produce identical output (no extra content)
|
||||
assert html_with == html_without
|
||||
|
||||
@@ -229,28 +229,5 @@ class TestBuildPageContext:
|
||||
at_root=False,
|
||||
)
|
||||
|
||||
assert root_context["css_path"] == ""
|
||||
assert root_context["css_path"] == "/"
|
||||
assert non_root_context["css_path"] == "../"
|
||||
|
||||
def test_links_use_relative_paths(self, configured_env, sample_row):
|
||||
"""Navigation and asset links are relative for subpath deployments."""
|
||||
root_context = build_page_context(
|
||||
role="repeater",
|
||||
period="day",
|
||||
row=sample_row,
|
||||
at_root=True,
|
||||
)
|
||||
non_root_context = build_page_context(
|
||||
role="companion",
|
||||
period="day",
|
||||
row=sample_row,
|
||||
at_root=False,
|
||||
)
|
||||
|
||||
assert root_context["repeater_link"] == "day.html"
|
||||
assert root_context["companion_link"] == "companion/day.html"
|
||||
assert root_context["reports_link"] == "reports/"
|
||||
|
||||
assert non_root_context["repeater_link"] == "../day.html"
|
||||
assert non_root_context["companion_link"] == "day.html"
|
||||
assert non_root_context["reports_link"] == "../reports/"
|
||||
|
||||
@@ -266,8 +266,7 @@ class TestHtmlOutput:
|
||||
|
||||
content = (out_dir / "day.html").read_text()
|
||||
|
||||
assert 'href="styles.css"' in content
|
||||
assert 'href="/styles.css"' not in content
|
||||
assert "styles.css" in content
|
||||
|
||||
def test_companion_pages_relative_css(self, html_env, metrics_rows):
|
||||
"""Companion pages use relative path to CSS."""
|
||||
@@ -278,5 +277,4 @@ class TestHtmlOutput:
|
||||
content = (out_dir / "companion" / "day.html").read_text()
|
||||
|
||||
# Should reference parent directory CSS
|
||||
assert 'href="../styles.css"' in content
|
||||
assert 'href="/styles.css"' not in content
|
||||
assert "../styles.css" in content or "styles.css" in content
|
||||
|
||||
@@ -236,7 +236,6 @@ class TestConfig:
|
||||
# Telemetry defaults
|
||||
assert config.telemetry_enabled is False
|
||||
assert config.telemetry_timeout_s == 10
|
||||
assert config.display_unit_system == "metric"
|
||||
|
||||
# Display defaults
|
||||
assert config.repeater_display_name == "Repeater Node"
|
||||
@@ -250,7 +249,6 @@ class TestConfig:
|
||||
monkeypatch.setenv("COMPANION_STEP", "120")
|
||||
monkeypatch.setenv("REPEATER_NAME", "TestRepeater")
|
||||
monkeypatch.setenv("TELEMETRY_ENABLED", "true")
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial")
|
||||
monkeypatch.setenv("REPORT_LAT", "51.5074")
|
||||
|
||||
config = Config()
|
||||
@@ -261,15 +259,8 @@ class TestConfig:
|
||||
assert config.companion_step == 120
|
||||
assert config.repeater_name == "TestRepeater"
|
||||
assert config.telemetry_enabled is True
|
||||
assert config.display_unit_system == "imperial"
|
||||
assert config.report_lat == pytest.approx(51.5074)
|
||||
|
||||
def test_invalid_display_unit_system_falls_back_to_metric(self, monkeypatch, clean_env):
|
||||
"""Invalid DISPLAY_UNIT_SYSTEM falls back to metric."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "custom")
|
||||
config = Config()
|
||||
assert config.display_unit_system == "metric"
|
||||
|
||||
def test_paths_are_path_objects(self, monkeypatch, clean_env, tmp_path):
|
||||
"""Path configs are Path objects."""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
@@ -6,7 +6,6 @@ from meshmon.html import (
|
||||
PERIOD_CONFIG,
|
||||
REPEATER_CHART_GROUPS,
|
||||
_build_traffic_table_rows,
|
||||
build_chart_groups,
|
||||
build_companion_metrics,
|
||||
build_node_details,
|
||||
build_radio_config,
|
||||
@@ -458,32 +457,3 @@ class TestChartGroupConstants:
|
||||
for _period, (title, subtitle) in PERIOD_CONFIG.items():
|
||||
assert isinstance(title, str)
|
||||
assert isinstance(subtitle, str)
|
||||
|
||||
|
||||
class TestBuildChartGroups:
|
||||
"""Tests for build_chart_groups."""
|
||||
|
||||
def test_png_paths_use_relative_prefix(self, configured_env):
|
||||
"""PNG fallback paths respect provided asset prefix."""
|
||||
out_dir = configured_env["out_dir"]
|
||||
asset_dir = out_dir / "assets" / "repeater"
|
||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||
(asset_dir / "bat_day_light.png").write_bytes(b"fake")
|
||||
|
||||
groups = build_chart_groups(
|
||||
role="repeater",
|
||||
period="day",
|
||||
chart_stats={},
|
||||
asset_prefix="../",
|
||||
)
|
||||
|
||||
chart = next(
|
||||
chart
|
||||
for group in groups
|
||||
for chart in group["charts"]
|
||||
if chart["metric"] == "bat"
|
||||
)
|
||||
|
||||
assert chart["use_svg"] is False
|
||||
assert chart["src_light"] == "../assets/repeater/bat_day_light.png"
|
||||
assert chart["src_dark"] == "../assets/repeater/bat_day_dark.png"
|
||||
|
||||
@@ -108,29 +108,6 @@ class TestFormatStatValue:
|
||||
"""Unknown metrics format with 2 decimals."""
|
||||
assert _format_stat_value(123.456, "unknown_metric") == "123.46"
|
||||
|
||||
def test_telemetry_metric_units_and_decimals_metric(self, monkeypatch):
|
||||
"""Telemetry metrics use metric units when DISPLAY_UNIT_SYSTEM=metric."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "metric")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
assert _format_stat_value(20.0, "telemetry.temperature.1") == "20.0 °C"
|
||||
assert _format_stat_value(85.0, "telemetry.humidity.1") == "85.0 %"
|
||||
assert _format_stat_value(1008.1, "telemetry.barometer.1") == "1008.1 hPa"
|
||||
assert _format_stat_value(42.0, "telemetry.altitude.1") == "42.0 m"
|
||||
|
||||
def test_telemetry_metric_units_and_decimals_imperial(self, monkeypatch):
|
||||
"""Telemetry metrics format imperial display values with imperial units."""
|
||||
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial")
|
||||
import meshmon.env
|
||||
meshmon.env._config = None
|
||||
|
||||
# Chart stats are already converted in charts.py; formatter should not convert again.
|
||||
assert _format_stat_value(68.0, "telemetry.temperature.1") == "68.0 °F"
|
||||
assert _format_stat_value(29.77, "telemetry.barometer.1") == "29.77 inHg"
|
||||
assert _format_stat_value(137.8, "telemetry.altitude.1") == "137.8 ft"
|
||||
assert _format_stat_value(85.0, "telemetry.humidity.1") == "85.0 %"
|
||||
|
||||
|
||||
class TestLoadSvgContent:
|
||||
"""Test _load_svg_content function."""
|
||||
|
||||
@@ -7,18 +7,12 @@ from meshmon.metrics import (
|
||||
METRIC_CONFIG,
|
||||
REPEATER_CHART_METRICS,
|
||||
MetricConfig,
|
||||
convert_telemetry_value,
|
||||
discover_telemetry_chart_metrics,
|
||||
get_chart_metrics,
|
||||
get_graph_scale,
|
||||
get_metric_config,
|
||||
get_metric_label,
|
||||
get_metric_unit,
|
||||
get_telemetry_metric_decimals,
|
||||
get_telemetry_metric_label,
|
||||
get_telemetry_metric_unit,
|
||||
is_counter_metric,
|
||||
is_telemetry_metric,
|
||||
transform_value,
|
||||
)
|
||||
|
||||
@@ -111,130 +105,6 @@ class TestGetChartMetrics:
|
||||
with pytest.raises(ValueError, match="Unknown role"):
|
||||
get_chart_metrics("")
|
||||
|
||||
def test_repeater_includes_telemetry_when_enabled(self):
|
||||
"""Repeater chart metrics include discovered telemetry when enabled."""
|
||||
available_metrics = [
|
||||
"bat",
|
||||
"telemetry.temperature.1",
|
||||
"telemetry.humidity.1",
|
||||
"telemetry.voltage.1",
|
||||
]
|
||||
|
||||
metrics = get_chart_metrics(
|
||||
"repeater",
|
||||
available_metrics=available_metrics,
|
||||
telemetry_enabled=True,
|
||||
)
|
||||
|
||||
assert "telemetry.temperature.1" in metrics
|
||||
assert "telemetry.humidity.1" in metrics
|
||||
assert "telemetry.voltage.1" not in metrics
|
||||
|
||||
def test_repeater_does_not_include_telemetry_when_disabled(self):
|
||||
"""Repeater chart metrics exclude telemetry when telemetry is disabled."""
|
||||
available_metrics = ["telemetry.temperature.1", "telemetry.humidity.1"]
|
||||
|
||||
metrics = get_chart_metrics(
|
||||
"repeater",
|
||||
available_metrics=available_metrics,
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
assert not any(metric.startswith("telemetry.") for metric in metrics)
|
||||
|
||||
def test_companion_never_includes_telemetry(self):
|
||||
"""Companion chart metrics stay unchanged, even with telemetry enabled."""
|
||||
metrics = get_chart_metrics(
|
||||
"companion",
|
||||
available_metrics=["telemetry.temperature.1"],
|
||||
telemetry_enabled=True,
|
||||
)
|
||||
assert metrics == COMPANION_CHART_METRICS
|
||||
|
||||
|
||||
class TestTelemetryMetricHelpers:
|
||||
"""Tests for telemetry metric parsing, discovery, and display helpers."""
|
||||
|
||||
def test_is_telemetry_metric(self):
|
||||
"""Telemetry metrics are detected by key pattern."""
|
||||
assert is_telemetry_metric("telemetry.temperature.1") is True
|
||||
assert is_telemetry_metric("telemetry.gps.0.latitude") is True
|
||||
assert is_telemetry_metric("bat") is False
|
||||
|
||||
def test_discovery_excludes_voltage(self):
|
||||
"""telemetry.voltage.* metrics are excluded from chart discovery."""
|
||||
discovered = discover_telemetry_chart_metrics(
|
||||
[
|
||||
"telemetry.temperature.1",
|
||||
"telemetry.voltage.1",
|
||||
"telemetry.humidity.1",
|
||||
"telemetry.gps.0.latitude",
|
||||
]
|
||||
)
|
||||
assert "telemetry.temperature.1" in discovered
|
||||
assert "telemetry.humidity.1" in discovered
|
||||
assert "telemetry.voltage.1" not in discovered
|
||||
assert "telemetry.gps.0.latitude" not in discovered
|
||||
|
||||
def test_discovery_is_deterministic(self):
|
||||
"""Discovery order is deterministic and sorted by display intent."""
|
||||
discovered = discover_telemetry_chart_metrics(
|
||||
[
|
||||
"telemetry.temperature.2",
|
||||
"telemetry.humidity.1",
|
||||
"telemetry.temperature.1",
|
||||
]
|
||||
)
|
||||
assert discovered == [
|
||||
"telemetry.humidity.1",
|
||||
"telemetry.temperature.1",
|
||||
"telemetry.temperature.2",
|
||||
]
|
||||
|
||||
def test_telemetry_label_is_human_readable(self):
|
||||
"""Telemetry labels are transformed into readable UI labels."""
|
||||
label = get_telemetry_metric_label("telemetry.temperature.1")
|
||||
assert "Temperature" in label
|
||||
assert "CH1" in label
|
||||
|
||||
def test_telemetry_unit_mapping(self):
|
||||
"""Telemetry units adapt to selected unit system."""
|
||||
assert get_telemetry_metric_unit("telemetry.temperature.1", "metric") == "°C"
|
||||
assert get_telemetry_metric_unit("telemetry.temperature.1", "imperial") == "°F"
|
||||
assert get_telemetry_metric_unit("telemetry.barometer.1", "metric") == "hPa"
|
||||
assert get_telemetry_metric_unit("telemetry.barometer.1", "imperial") == "inHg"
|
||||
assert get_telemetry_metric_unit("telemetry.altitude.1", "metric") == "m"
|
||||
assert get_telemetry_metric_unit("telemetry.altitude.1", "imperial") == "ft"
|
||||
assert get_telemetry_metric_unit("telemetry.humidity.1", "imperial") == "%"
|
||||
|
||||
def test_telemetry_decimals_mapping(self):
|
||||
"""Telemetry decimals adapt to metric type and unit system."""
|
||||
assert get_telemetry_metric_decimals("telemetry.temperature.1", "metric") == 1
|
||||
assert get_telemetry_metric_decimals("telemetry.barometer.1", "imperial") == 2
|
||||
assert get_telemetry_metric_decimals("telemetry.unknown.1", "imperial") == 2
|
||||
|
||||
def test_convert_temperature_c_to_f(self):
|
||||
"""Temperature converts from Celsius to Fahrenheit for imperial display."""
|
||||
assert convert_telemetry_value("telemetry.temperature.1", 0.0, "imperial") == pytest.approx(32.0)
|
||||
assert convert_telemetry_value("telemetry.temperature.1", 20.0, "imperial") == pytest.approx(68.0)
|
||||
|
||||
def test_convert_barometer_hpa_to_inhg(self):
|
||||
"""Barometric pressure converts from hPa to inHg for imperial display."""
|
||||
assert convert_telemetry_value("telemetry.barometer.1", 1013.25, "imperial") == pytest.approx(29.92126, rel=1e-5)
|
||||
|
||||
def test_convert_altitude_m_to_ft(self):
|
||||
"""Altitude converts from meters to feet for imperial display."""
|
||||
assert convert_telemetry_value("telemetry.altitude.1", 100.0, "imperial") == pytest.approx(328.08399, rel=1e-5)
|
||||
|
||||
def test_convert_humidity_unchanged(self):
|
||||
"""Humidity remains unchanged across unit systems."""
|
||||
assert convert_telemetry_value("telemetry.humidity.1", 85.5, "metric") == pytest.approx(85.5)
|
||||
assert convert_telemetry_value("telemetry.humidity.1", 85.5, "imperial") == pytest.approx(85.5)
|
||||
|
||||
def test_convert_unknown_metric_unchanged(self):
|
||||
"""Unknown telemetry metric types remain unchanged."""
|
||||
assert convert_telemetry_value("telemetry.custom.1", 12.34, "imperial") == pytest.approx(12.34)
|
||||
|
||||
|
||||
class TestGetMetricConfig:
|
||||
"""Test get_metric_config function."""
|
||||
@@ -321,12 +191,6 @@ class TestGetMetricLabel:
|
||||
label = get_metric_label("unknown_metric")
|
||||
assert label == "unknown_metric"
|
||||
|
||||
def test_telemetry_metric_returns_human_label(self):
|
||||
"""Telemetry metrics return a human-readable label."""
|
||||
label = get_metric_label("telemetry.temperature.1")
|
||||
assert "Temperature" in label
|
||||
assert "CH1" in label
|
||||
|
||||
|
||||
class TestGetMetricUnit:
|
||||
"""Test get_metric_unit function."""
|
||||
@@ -351,16 +215,6 @@ class TestGetMetricUnit:
|
||||
unit = get_metric_unit("unknown_metric")
|
||||
assert unit == ""
|
||||
|
||||
def test_telemetry_metric_metric_units(self):
|
||||
"""Telemetry metrics use metric units by default."""
|
||||
unit = get_metric_unit("telemetry.temperature.1")
|
||||
assert unit == "°C"
|
||||
|
||||
def test_telemetry_metric_imperial_units(self):
|
||||
"""Telemetry metrics switch units when unit system is imperial."""
|
||||
unit = get_metric_unit("telemetry.barometer.1", unit_system="imperial")
|
||||
assert unit == "inHg"
|
||||
|
||||
|
||||
class TestTransformValue:
|
||||
"""Test transform_value function."""
|
||||
|
||||
Reference in New Issue
Block a user