From 137bbe3c663004ddad549c47c7502822a79775b6 Mon Sep 17 00:00:00 2001 From: Jorijn Schrijvershof Date: Mon, 9 Feb 2026 13:12:44 +0100 Subject: [PATCH] feat: add telemetry chart discovery and unit display (#109) * add telemetry chart discovery and unit display * fix: tests were failing --- AGENTS.md | 23 ++++ README.md | 11 ++ meshcore.conf.example | 11 ++ src/meshmon/charts.py | 22 +++- src/meshmon/env.py | 16 +++ src/meshmon/html.py | 41 ++++++- src/meshmon/metrics.py | 164 ++++++++++++++++++++++++- src/meshmon/templates/base.html | 2 +- src/meshmon/templates/chart-tooltip.js | 95 +++++++++++++- tests/charts/test_render_all_charts.py | 69 +++++++++++ tests/charts/test_timeseries.py | 40 ++++++ tests/config/test_env.py | 17 +++ tests/conftest.py | 1 + tests/html/test_chart_groups.py | 49 ++++++++ tests/unit/test_env_parsing.py | 9 ++ tests/unit/test_html_formatters.py | 23 ++++ tests/unit/test_metrics.py | 146 ++++++++++++++++++++++ 17 files changed, 724 insertions(+), 15 deletions(-) create mode 100644 tests/charts/test_render_all_charts.py create mode 100644 tests/html/test_chart_groups.py diff --git a/AGENTS.md b/AGENTS.md index ea3151b..ef13228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -485,6 +485,8 @@ 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) @@ -497,6 +499,7 @@ 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") @@ -542,6 +545,9 @@ 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 @@ -698,6 +704,7 @@ 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: @@ -732,6 +739,18 @@ 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..[.]` 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: @@ -773,6 +792,9 @@ 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_*`: @@ -861,6 +883,7 @@ 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` diff --git a/README.md b/README.md index f8e78c9..ffb2184 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ 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 @@ -90,6 +91,16 @@ 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 diff --git a/meshcore.conf.example b/meshcore.conf.example index 7abe7f3..008b78f 100644 --- a/meshcore.conf.example +++ b/meshcore.conf.example @@ -52,6 +52,15 @@ 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) # ============================================================================= @@ -126,6 +135,8 @@ 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 diff --git a/src/meshmon/charts.py b/src/meshmon/charts.py index 9be4cf2..c83b1bd 100644 --- a/src/meshmon/charts.py +++ b/src/meshmon/charts.py @@ -19,9 +19,10 @@ import matplotlib.dates as mdates import matplotlib.pyplot as plt from . import log -from .db import get_metrics_for_period +from .db import get_available_metrics, get_metrics_for_period from .env import get_config from .metrics import ( + convert_telemetry_value, get_chart_metrics, get_graph_scale, is_counter_metric, @@ -210,6 +211,7 @@ 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]] = [] @@ -262,6 +264,13 @@ def load_timeseries_from_db( # 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: @@ -591,10 +600,15 @@ def render_all_charts( Tuple of (list of generated chart paths, stats dict) Stats dict structure: {metric_name: {period: {min, avg, max, current}}} """ - if metrics is None: - metrics = get_chart_metrics(role) - 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, + ) + charts_dir = cfg.out_dir / "assets" / role charts_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/meshmon/env.py b/src/meshmon/env.py index 1972732..c82fe17 100644 --- a/src/meshmon/env.py +++ b/src/meshmon/env.py @@ -126,6 +126,14 @@ 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.""" @@ -162,6 +170,7 @@ class Config: # Paths state_dir: Path out_dir: Path + html_path: str # Report location metadata report_location_name: str | None @@ -185,6 +194,9 @@ 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" @@ -256,6 +268,10 @@ 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 "" # Global config instance _config: Config | None = None diff --git a/src/meshmon/html.py b/src/meshmon/html.py index 4087d62..85b96f7 100644 --- a/src/meshmon/html.py +++ b/src/meshmon/html.py @@ -22,7 +22,13 @@ from .formatters import ( format_uptime, format_value, ) -from .metrics import get_chart_metrics, get_metric_label +from .metrics import ( + get_chart_metrics, + get_metric_label, + get_metric_unit, + get_telemetry_metric_decimals, + is_telemetry_metric, +) if TYPE_CHECKING: from .reports import MonthlyAggregate, YearlyAggregate @@ -428,6 +434,14 @@ 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,8 +508,28 @@ def build_chart_groups( asset_prefix: Relative path prefix to reach /assets from page location """ cfg = get_config() - groups_config = REPEATER_CHART_GROUPS if role == "repeater" else COMPANION_CHART_GROUPS - chart_metrics = get_chart_metrics(role) + 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, + } + ) if chart_stats is None: chart_stats = {} @@ -659,6 +693,7 @@ 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, diff --git a/src/meshmon/metrics.py b/src/meshmon/metrics.py index 32b14ed..6eab5bd 100644 --- a/src/meshmon/metrics.py +++ b/src/meshmon/metrics.py @@ -11,8 +11,25 @@ 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: @@ -205,19 +222,145 @@ REPEATER_CHART_METRICS = [ # Helper functions # ============================================================================= -def get_chart_metrics(role: str) -> list[str]: +def parse_telemetry_metric(metric: str) -> TelemetryMetricParts | None: + """Parse telemetry metric key into its parts. + + Expected format: telemetry..[.] + """ + 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]: """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 COMPANION_CHART_METRICS + return list(COMPANION_CHART_METRICS) elif role == "repeater": - return REPEATER_CHART_METRICS + 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 else: raise ValueError(f"Unknown role: {role}") @@ -273,20 +416,29 @@ def get_metric_label(metric: str) -> str: Display label or the metric name if not configured """ config = METRIC_CONFIG.get(metric) - return config.label if config else metric + if config: + return config.label + if is_telemetry_metric(metric): + return get_telemetry_metric_label(metric) + return metric -def get_metric_unit(metric: str) -> str: +def get_metric_unit(metric: str, unit_system: str = "metric") -> 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) - return config.unit if config else "" + if config: + return config.unit + if is_telemetry_metric(metric): + return get_telemetry_metric_unit(metric, unit_system) + return "" def transform_value(metric: str, value: float) -> float: diff --git a/src/meshmon/templates/base.html b/src/meshmon/templates/base.html index 2aa77da..de46f8d 100644 --- a/src/meshmon/templates/base.html +++ b/src/meshmon/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/src/meshmon/templates/chart-tooltip.js b/src/meshmon/templates/chart-tooltip.js index e909b66..563e50f 100644 --- a/src/meshmon/templates/chart-tooltip.js +++ b/src/meshmon/templates/chart-tooltip.js @@ -28,6 +28,16 @@ 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. @@ -65,6 +75,68 @@ // 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). @@ -93,11 +165,32 @@ * 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 // ============================================================================ @@ -403,7 +496,7 @@ showTooltip( event, formatTimestamp(closestPoint.ts, period), - formatMetricValue(closestPoint.v, metric) + getMetricLabel(metric) + ': ' + formatMetricValue(closestPoint.v, metric) ); positionIndicator(svg, closestPoint, xStart, xEnd, yMin, yMax, plotArea); diff --git a/tests/charts/test_render_all_charts.py b/tests/charts/test_render_all_charts.py new file mode 100644 index 0000000..619aafb --- /dev/null +++ b/tests/charts/test_render_all_charts.py @@ -0,0 +1,69 @@ +"""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: "") + + _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: "") + + _generated, stats = charts.render_all_charts("repeater") + + assert not any(metric.startswith("telemetry.") for metric in stats) diff --git a/tests/charts/test_timeseries.py b/tests/charts/test_timeseries.py index e32a708..2bd9902 100644 --- a/tests/charts/test_timeseries.py +++ b/tests/charts/test_timeseries.py @@ -185,3 +185,43 @@ 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]) diff --git a/tests/config/test_env.py b/tests/config/test_env.py index f49915e..1a0a35c 100644 --- a/tests/config/test_env.py +++ b/tests/config/test_env.py @@ -130,6 +130,23 @@ 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") diff --git a/tests/conftest.py b/tests/conftest.py index ce28aba..683eb4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ def clean_env(monkeypatch): "COMPANION_", "REMOTE_", "TELEMETRY_", + "DISPLAY_", "REPORT_", "RADIO_", "STATE_DIR", diff --git a/tests/html/test_chart_groups.py b/tests/html/test_chart_groups.py new file mode 100644 index 0000000..79e9eb3 --- /dev/null +++ b/tests/html/test_chart_groups.py @@ -0,0 +1,49 @@ +"""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: "") + + 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: "") + + 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] diff --git a/tests/unit/test_env_parsing.py b/tests/unit/test_env_parsing.py index e15d729..fa1ff5b 100644 --- a/tests/unit/test_env_parsing.py +++ b/tests/unit/test_env_parsing.py @@ -236,6 +236,7 @@ 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" @@ -249,6 +250,7 @@ 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() @@ -259,8 +261,15 @@ 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" diff --git a/tests/unit/test_html_formatters.py b/tests/unit/test_html_formatters.py index ec39943..83b15ab 100644 --- a/tests/unit/test_html_formatters.py +++ b/tests/unit/test_html_formatters.py @@ -108,6 +108,29 @@ 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.""" diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index f6823a1..a268f73 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -7,12 +7,18 @@ 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, ) @@ -105,6 +111,130 @@ 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.""" @@ -191,6 +321,12 @@ 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.""" @@ -215,6 +351,16 @@ 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."""