Compare commits

...

2 Commits

Author SHA1 Message Date
Jorijn Schrijvershof 580ff45800 fix: tests were failing 2026-02-09 13:11:26 +01:00
Jorijn Schrijvershof f8ee0d1076 add telemetry chart discovery and unit display 2026-02-09 12:57:51 +01:00
17 changed files with 724 additions and 15 deletions
+23
View File
@@ -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_TIMEOUT_S`: Timeout for telemetry requests (default: 10)
- `TELEMETRY_RETRY_ATTEMPTS`: Retry attempts for telemetry (default: 2) - `TELEMETRY_RETRY_ATTEMPTS`: Retry attempts for telemetry (default: 2)
- `TELEMETRY_RETRY_BACKOFF_S`: Backoff between telemetry retries (default: 4) - `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 ### Intervals
- `COMPANION_STEP`: Collection interval for companion (default: 60s) - `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_LON`: Longitude in decimal degrees (default: 0.0)
- `REPORT_ELEV`: Elevation (default: 0.0) - `REPORT_ELEV`: Elevation (default: 0.0)
- `REPORT_ELEV_UNIT`: Elevation unit, "m" or "ft" (default: "m") - `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") - `REPEATER_DISPLAY_NAME`: Display name for repeater in UI (default: "Repeater Node")
- `COMPANION_DISPLAY_NAME`: Display name for companion in UI (default: "Companion Node") - `COMPANION_DISPLAY_NAME`: Display name for companion in UI (default: "Companion Node")
- `REPEATER_HARDWARE`: Repeater hardware model for sidebar (default: "LoRa Repeater") - `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 - Channel number distinguishes multiple sensors of the same type
- Compound values (e.g., GPS) stored as: `telemetry.gps.0.latitude`, `telemetry.gps.0.longitude` - Compound values (e.g., GPS) stored as: `telemetry.gps.0.latitude`, `telemetry.gps.0.longitude`
- Telemetry collection does NOT affect circuit breaker state - 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 ## Database Schema
@@ -698,6 +704,7 @@ Color-coded based on data freshness:
- Shows datetime and value when hovering over chart data - Shows datetime and value when hovering over chart data
- Works without JavaScript (charts still display, just no tooltips) - Works without JavaScript (charts still display, just no tooltips)
- Uses `data-points`, `data-x-start`, `data-x-end` attributes embedded in SVG - Uses `data-points`, `data-x-start`, `data-x-end` attributes embedded in SVG
- Telemetry tooltip units/precision follow `DISPLAY_UNIT_SYSTEM`
### Social Sharing ### Social Sharing
Open Graph and Twitter Card meta tags for link previews: 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 - **Inline**: SVGs are embedded directly in HTML for zero additional requests
- **Tooltips**: Data points embedded as JSON in SVG `data-points` attribute - **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) ### Time Aggregation (Binning)
Data points are aggregated into bins to keep chart file sizes reasonable and lines clean: 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 | | `sent_direct` | counter | Packets/min | Direct packets transmitted |
| `recv_direct` | counter | Packets/min | Direct packets received | | `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 ### Companion Metrics Summary
Metrics use firmware field names directly from `get_stats_*`: 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) - `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_METRICS` or `REPEATER_CHART_METRICS` in `src/meshmon/metrics.py`
- `COMPANION_CHART_GROUPS` or `REPEATER_CHART_GROUPS` in `src/meshmon/html.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: 3. **To display in reports**: Add the firmware field name to:
- `COMPANION_REPORT_METRICS` or `REPEATER_REPORT_METRICS` in `src/meshmon/reports.py` - `COMPANION_REPORT_METRICS` or `REPEATER_REPORT_METRICS` in `src/meshmon/reports.py`
+11
View File
@@ -46,6 +46,7 @@ docker compose logs meshcore-stats | head -20
- **Data Collection** - Metrics from local companion and remote repeater nodes - **Data Collection** - Metrics from local companion and remote repeater nodes
- **Interactive Charts** - SVG charts with day/week/month/year views and tooltips - **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 - **Statistics Reports** - Monthly and yearly report generation
- **Light/Dark Theme** - Automatic theme switching based on system preference - **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. 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 #### 3. Create Data Directories
```bash ```bash
+11
View File
@@ -52,6 +52,15 @@ COMPANION_DISPLAY_NAME=My Companion
# REPEATER_PUBKEY_PREFIX=!a1b2c3d4 # REPEATER_PUBKEY_PREFIX=!a1b2c3d4
# COMPANION_PUBKEY_PREFIX=!e5f6g7h8 # 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) # Location Metadata (for reports and sidebar display)
# ============================================================================= # =============================================================================
@@ -126,6 +135,8 @@ RADIO_CODING_RATE=CR8
# Enable telemetry collection from repeater's environmental sensors # Enable telemetry collection from repeater's environmental sensors
# (temperature, humidity, barometric pressure, etc.) # (temperature, humidity, barometric pressure, etc.)
# Requires sensor board attached to repeater (e.g., BME280, BME680) # 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) # Default: 0 (disabled)
# TELEMETRY_ENABLED=1 # TELEMETRY_ENABLED=1
+18 -4
View File
@@ -19,9 +19,10 @@ import matplotlib.dates as mdates
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from . import log 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 .env import get_config
from .metrics import ( from .metrics import (
convert_telemetry_value,
get_chart_metrics, get_chart_metrics,
get_graph_scale, get_graph_scale,
is_counter_metric, is_counter_metric,
@@ -210,6 +211,7 @@ def load_timeseries_from_db(
is_counter = is_counter_metric(metric) is_counter = is_counter_metric(metric)
scale = get_graph_scale(metric) scale = get_graph_scale(metric)
unit_system = get_config().display_unit_system
# Convert to (datetime, value) tuples with transform applied # Convert to (datetime, value) tuples with transform applied
raw_points: list[tuple[datetime, float]] = [] raw_points: list[tuple[datetime, float]] = []
@@ -262,6 +264,13 @@ def load_timeseries_from_db(
# For gauges, just apply scaling # For gauges, just apply scaling
raw_points = [(ts, val * scale) for ts, val in raw_points] 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 # Apply time binning if configured
period_cfg = PERIOD_CONFIG.get(period) period_cfg = PERIOD_CONFIG.get(period)
if period_cfg and period_cfg.bin_seconds and len(raw_points) > 1: 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) Tuple of (list of generated chart paths, stats dict)
Stats dict structure: {metric_name: {period: {min, avg, max, current}}} Stats dict structure: {metric_name: {period: {min, avg, max, current}}}
""" """
if metrics is None:
metrics = get_chart_metrics(role)
cfg = get_config() 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 = cfg.out_dir / "assets" / role
charts_dir.mkdir(parents=True, exist_ok=True) charts_dir.mkdir(parents=True, exist_ok=True)
+16
View File
@@ -126,6 +126,14 @@ def get_path(key: str, default: str) -> Path:
return Path(val).expanduser().resolve() 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: class Config:
"""Configuration loaded from environment variables.""" """Configuration loaded from environment variables."""
@@ -162,6 +170,7 @@ class Config:
# Paths # Paths
state_dir: Path state_dir: Path
out_dir: Path out_dir: Path
html_path: str
# Report location metadata # Report location metadata
report_location_name: str | None report_location_name: str | None
@@ -185,6 +194,9 @@ class Config:
radio_spread_factor: str | None radio_spread_factor: str | None
radio_coding_rate: str | None radio_coding_rate: str | None
# Display formatting
display_unit_system: str
def __init__(self) -> None: def __init__(self) -> None:
# Connection settings # Connection settings
self.mesh_transport = get_str("MESH_TRANSPORT", "serial") or "serial" 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_spread_factor = get_str("RADIO_SPREAD_FACTOR", "SF8")
self.radio_coding_rate = get_str("RADIO_CODING_RATE", "CR8") 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 # Global config instance
_config: Config | None = None _config: Config | None = None
+38 -3
View File
@@ -22,7 +22,13 @@ from .formatters import (
format_uptime, format_uptime,
format_value, 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: if TYPE_CHECKING:
from .reports import MonthlyAggregate, YearlyAggregate from .reports import MonthlyAggregate, YearlyAggregate
@@ -428,6 +434,14 @@ def _format_stat_value(value: float | None, metric: str) -> str:
if value is None: if value is None:
return "-" 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) # Determine format and suffix based on metric (using firmware field names)
# Battery voltage (already transformed to volts in charts.py) # Battery voltage (already transformed to volts in charts.py)
if metric in ("bat", "battery_mv"): 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 asset_prefix: Relative path prefix to reach /assets from page location
""" """
cfg = get_config() cfg = get_config()
groups_config = REPEATER_CHART_GROUPS if role == "repeater" else COMPANION_CHART_GROUPS available_metrics = sorted(chart_stats.keys()) if chart_stats else []
chart_metrics = get_chart_metrics(role) 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: if chart_stats is None:
chart_stats = {} chart_stats = {}
@@ -659,6 +693,7 @@ def build_page_context(
"meta_description": meta_descriptions.get(role, "MeshCore mesh network statistics dashboard."), "meta_description": meta_descriptions.get(role, "MeshCore mesh network statistics dashboard."),
"og_image": None, "og_image": None,
"css_path": css_path, "css_path": css_path,
"display_unit_system": cfg.display_unit_system,
# Node info # Node info
"node_name": node_name, "node_name": node_name,
+158 -6
View File
@@ -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. See docs/firmware-responses.md for the complete field reference.
""" """
import re
from dataclasses import dataclass 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) @dataclass(frozen=True)
class MetricConfig: class MetricConfig:
@@ -205,19 +222,145 @@ REPEATER_CHART_METRICS = [
# Helper functions # 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.<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]:
"""Get list of metrics to chart for a role. """Get list of metrics to chart for a role.
Args: Args:
role: 'companion' or 'repeater' role: 'companion' or 'repeater'
available_metrics: Optional list of available metrics for discovery
telemetry_enabled: Whether telemetry charts should be included
Returns: Returns:
List of metric names in display order List of metric names in display order
""" """
if role == "companion": if role == "companion":
return COMPANION_CHART_METRICS return list(COMPANION_CHART_METRICS)
elif role == "repeater": 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: else:
raise ValueError(f"Unknown role: {role}") 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 Display label or the metric name if not configured
""" """
config = METRIC_CONFIG.get(metric) 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. """Get display unit for a metric.
Args: Args:
metric: Firmware field name metric: Firmware field name
unit_system: Unit system for telemetry metrics ('metric' or 'imperial')
Returns: Returns:
Unit string or empty string if not configured Unit string or empty string if not configured
""" """
config = METRIC_CONFIG.get(metric) 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: def transform_value(metric: str, value: float) -> float:
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-unit-system="{{ display_unit_system | default('metric') }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
+94 -1
View File
@@ -28,6 +28,16 @@
dark: { fill: '#f59e0b', stroke: '#0f1114' } 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. * Metric display configuration keyed by firmware field name.
@@ -65,6 +75,68 @@
// Formatting Utilities // 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. * Format a Unix timestamp as a localized date/time string.
* Uses browser language preference for locale (determines 12/24 hour format). * 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. * Format a numeric value with the appropriate decimals and unit for a metric.
*/ */
function formatMetricValue(value, 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 config = METRIC_CONFIG[metric] || { label: metric, unit: '', decimals: 2 };
var formatted = value.toFixed(config.decimals); var formatted = value.toFixed(config.decimals);
return config.unit ? formatted + ' ' + config.unit : formatted; 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 // Data Point Utilities
// ============================================================================ // ============================================================================
@@ -403,7 +496,7 @@
showTooltip( showTooltip(
event, event,
formatTimestamp(closestPoint.ts, period), formatTimestamp(closestPoint.ts, period),
formatMetricValue(closestPoint.v, metric) getMetricLabel(metric) + ': ' + formatMetricValue(closestPoint.v, metric)
); );
positionIndicator(svg, closestPoint, xStart, xEnd, yMin, yMax, plotArea); positionIndicator(svg, closestPoint, xStart, xEnd, yMin, yMax, plotArea);
+69
View File
@@ -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: "<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)
+40
View File
@@ -185,3 +185,43 @@ class TestLoadTimeseriesFromDb:
timestamps = [p.timestamp for p in ts.points] timestamps = [p.timestamp for p in ts.points]
assert timestamps == sorted(timestamps) 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])
+17
View File
@@ -130,6 +130,23 @@ class TestConfigComplete:
assert config.telemetry_retry_attempts == 3 assert config.telemetry_retry_attempts == 3
assert config.telemetry_retry_backoff_s == 5 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): def test_all_location_settings(self, clean_env, monkeypatch):
"""All location/report settings are loaded.""" """All location/report settings are loaded."""
monkeypatch.setenv("REPORT_LOCATION_NAME", "Mountain Peak Observatory") monkeypatch.setenv("REPORT_LOCATION_NAME", "Mountain Peak Observatory")
+1
View File
@@ -15,6 +15,7 @@ def clean_env(monkeypatch):
"COMPANION_", "COMPANION_",
"REMOTE_", "REMOTE_",
"TELEMETRY_", "TELEMETRY_",
"DISPLAY_",
"REPORT_", "REPORT_",
"RADIO_", "RADIO_",
"STATE_DIR", "STATE_DIR",
+49
View File
@@ -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: "<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]
+9
View File
@@ -236,6 +236,7 @@ class TestConfig:
# Telemetry defaults # Telemetry defaults
assert config.telemetry_enabled is False assert config.telemetry_enabled is False
assert config.telemetry_timeout_s == 10 assert config.telemetry_timeout_s == 10
assert config.display_unit_system == "metric"
# Display defaults # Display defaults
assert config.repeater_display_name == "Repeater Node" assert config.repeater_display_name == "Repeater Node"
@@ -249,6 +250,7 @@ class TestConfig:
monkeypatch.setenv("COMPANION_STEP", "120") monkeypatch.setenv("COMPANION_STEP", "120")
monkeypatch.setenv("REPEATER_NAME", "TestRepeater") monkeypatch.setenv("REPEATER_NAME", "TestRepeater")
monkeypatch.setenv("TELEMETRY_ENABLED", "true") monkeypatch.setenv("TELEMETRY_ENABLED", "true")
monkeypatch.setenv("DISPLAY_UNIT_SYSTEM", "imperial")
monkeypatch.setenv("REPORT_LAT", "51.5074") monkeypatch.setenv("REPORT_LAT", "51.5074")
config = Config() config = Config()
@@ -259,8 +261,15 @@ class TestConfig:
assert config.companion_step == 120 assert config.companion_step == 120
assert config.repeater_name == "TestRepeater" assert config.repeater_name == "TestRepeater"
assert config.telemetry_enabled is True assert config.telemetry_enabled is True
assert config.display_unit_system == "imperial"
assert config.report_lat == pytest.approx(51.5074) 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): def test_paths_are_path_objects(self, monkeypatch, clean_env, tmp_path):
"""Path configs are Path objects.""" """Path configs are Path objects."""
state_dir = tmp_path / "state" state_dir = tmp_path / "state"
+23
View File
@@ -108,6 +108,29 @@ class TestFormatStatValue:
"""Unknown metrics format with 2 decimals.""" """Unknown metrics format with 2 decimals."""
assert _format_stat_value(123.456, "unknown_metric") == "123.46" 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: class TestLoadSvgContent:
"""Test _load_svg_content function.""" """Test _load_svg_content function."""
+146
View File
@@ -7,12 +7,18 @@ from meshmon.metrics import (
METRIC_CONFIG, METRIC_CONFIG,
REPEATER_CHART_METRICS, REPEATER_CHART_METRICS,
MetricConfig, MetricConfig,
convert_telemetry_value,
discover_telemetry_chart_metrics,
get_chart_metrics, get_chart_metrics,
get_graph_scale, get_graph_scale,
get_metric_config, get_metric_config,
get_metric_label, get_metric_label,
get_metric_unit, get_metric_unit,
get_telemetry_metric_decimals,
get_telemetry_metric_label,
get_telemetry_metric_unit,
is_counter_metric, is_counter_metric,
is_telemetry_metric,
transform_value, transform_value,
) )
@@ -105,6 +111,130 @@ class TestGetChartMetrics:
with pytest.raises(ValueError, match="Unknown role"): with pytest.raises(ValueError, match="Unknown role"):
get_chart_metrics("") 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: class TestGetMetricConfig:
"""Test get_metric_config function.""" """Test get_metric_config function."""
@@ -191,6 +321,12 @@ class TestGetMetricLabel:
label = get_metric_label("unknown_metric") label = get_metric_label("unknown_metric")
assert 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: class TestGetMetricUnit:
"""Test get_metric_unit function.""" """Test get_metric_unit function."""
@@ -215,6 +351,16 @@ class TestGetMetricUnit:
unit = get_metric_unit("unknown_metric") unit = get_metric_unit("unknown_metric")
assert unit == "" 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: class TestTransformValue:
"""Test transform_value function.""" """Test transform_value function."""