mirror of
https://github.com/jorijn/meshcore-stats.git
synced 2026-03-28 17:42:55 +01:00
Compare commits
2 Commits
d2dff7d950
...
html
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580ff45800 | ||
|
|
f8ee0d1076 |
23
AGENTS.md
23
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.<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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.<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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-unit-system="{{ display_unit_system | default('metric') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
tests/charts/test_render_all_charts.py
Normal file
69
tests/charts/test_render_all_charts.py
Normal 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)
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -15,6 +15,7 @@ def clean_env(monkeypatch):
|
||||
"COMPANION_",
|
||||
"REMOTE_",
|
||||
"TELEMETRY_",
|
||||
"DISPLAY_",
|
||||
"REPORT_",
|
||||
"RADIO_",
|
||||
"STATE_DIR",
|
||||
|
||||
49
tests/html/test_chart_groups.py
Normal file
49
tests/html/test_chart_groups.py
Normal 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]
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user