diff --git a/scripts/collect_companion.py b/scripts/collect_companion.py index 8118f18..90de65f 100755 --- a/scripts/collect_companion.py +++ b/scripts/collect_companion.py @@ -74,16 +74,6 @@ async def collect_companion() -> int: else: log.error(f"device_query failed: {err}") - # get_bat - ok, evt_type, payload, err = await run_command( - mc, cmd.get_bat(), "get_bat" - ) - if ok: - commands_succeeded += 1 - log.debug(f"get_bat: {payload}") - else: - log.error(f"get_bat failed: {err}") - # get_time ok, evt_type, payload, err = await run_command( mc, cmd.get_time(), "get_time" diff --git a/src/meshmon/charts.py b/src/meshmon/charts.py index 2af44cb..1c3abf1 100644 --- a/src/meshmon/charts.py +++ b/src/meshmon/charts.py @@ -167,6 +167,7 @@ def load_timeseries_from_db( end_time: datetime, lookback: timedelta, period: str, + all_metrics: Optional[dict[str, list[tuple[int, float]]]] = None, ) -> TimeSeries: """Load time series data from SQLite database. @@ -179,6 +180,7 @@ def load_timeseries_from_db( end_time: End of the time range (typically now) lookback: How far back to look period: Period name for binning config ("day", "week", etc.) + all_metrics: Optional pre-fetched metrics dict for this period Returns: TimeSeries with extracted data points @@ -188,7 +190,8 @@ def load_timeseries_from_db( end_ts = int(end_time.timestamp()) # Fetch all metrics for this role/period (returns pivoted dict) - all_metrics = get_metrics_for_period(role, start_ts, end_ts) + if all_metrics is None: + all_metrics = get_metrics_for_period(role, start_ts, end_ts) # Get data for this specific metric metric_data = all_metrics.get(metric, []) @@ -379,10 +382,22 @@ def render_chart_svg( # Plot area fill area_color = _hex_to_rgba(theme.area) - ax.fill_between(timestamps, values, alpha=area_color[3], color=f"#{theme.line}") + area = ax.fill_between( + timestamps, + values, + alpha=area_color[3], + color=f"#{theme.line}", + ) + area.set_gid("chart-area") # Plot line - ax.plot(timestamps, values, color=f"#{theme.line}", linewidth=2) + (line,) = ax.plot( + timestamps, + values, + color=f"#{theme.line}", + linewidth=2, + ) + line.set_gid("chart-line") # Set Y-axis limits and track actual values used if y_min is not None and y_max is not None: @@ -458,7 +473,7 @@ def _inject_data_attributes( Adds: - data-metric, data-period, data-theme, data-x-start, data-x-end, data-y-min, data-y-max to root - - data-points JSON array to the chart path element + - data-points JSON array to the root and chart line path Args: svg: Raw SVG string @@ -495,22 +510,35 @@ def _inject_data_attributes( r']*(?:id|gid)="chart-line"[^>]*)', + add_data_to_id, + svg, + count=1, + ) + + if count == 0: + # Look for the second path element (first is usually the fill area) + path_count = 0 + + def add_data_to_path(match): + nonlocal path_count + path_count += 1 + if path_count == 2: # The line path + return f' str: @@ -59,6 +54,32 @@ def get_metrics_for_role(role: str) -> list[str]: raise ValueError(f"Unknown role: {role}") +REPORT_UNITS_RAW = { + "battery_mv": "mV", + "bat": "mV", + "bat_pct": "%", + "uptime": "s", + "uptime_secs": "s", + "last_rssi": "dBm", + "last_snr": "dB", + "noise_floor": "dBm", + "tx_queue_len": "count", + "contacts": "count", + "recv": "packets", + "sent": "packets", + "nb_recv": "packets", + "nb_sent": "packets", + "airtime": "s", + "rx_airtime": "s", + "flood_dups": "packets", + "direct_dups": "packets", + "sent_flood": "packets", + "recv_flood": "packets", + "sent_direct": "packets", + "recv_direct": "packets", +} + + @dataclass class MetricStats: """Statistics for a single metric over a period. @@ -1116,10 +1137,14 @@ def format_yearly_txt( return format_yearly_txt_companion(agg, node_name, location) -def _metric_stats_to_dict(stats: MetricStats) -> dict[str, Any]: +def _metric_stats_to_dict(stats: MetricStats, metric: str) -> dict[str, Any]: """Convert MetricStats to JSON-serializable dict.""" result: dict[str, Any] = {"count": stats.count} + unit = REPORT_UNITS_RAW.get(metric) + if unit: + result["unit"] = unit + if stats.mean is not None: result["mean"] = round(stats.mean, 4) if stats.min_value is not None: @@ -1144,7 +1169,7 @@ def _daily_to_dict(daily: DailyAggregate) -> dict[str, Any]: "date": daily.date.isoformat(), "snapshot_count": daily.snapshot_count, "metrics": { - ds: _metric_stats_to_dict(stats) + ds: _metric_stats_to_dict(stats, ds) for ds, stats in daily.metrics.items() if stats.has_data }, @@ -1167,7 +1192,7 @@ def monthly_to_json(agg: MonthlyAggregate) -> dict[str, Any]: "role": agg.role, "days_with_data": len(agg.daily), "summary": { - ds: _metric_stats_to_dict(stats) + ds: _metric_stats_to_dict(stats, ds) for ds, stats in agg.summary.items() if stats.has_data }, @@ -1190,7 +1215,7 @@ def yearly_to_json(agg: YearlyAggregate) -> dict[str, Any]: "role": agg.role, "months_with_data": len(agg.monthly), "summary": { - ds: _metric_stats_to_dict(stats) + ds: _metric_stats_to_dict(stats, ds) for ds, stats in agg.summary.items() if stats.has_data }, @@ -1200,7 +1225,7 @@ def yearly_to_json(agg: YearlyAggregate) -> dict[str, Any]: "month": m.month, "days_with_data": len(m.daily), "summary": { - ds: _metric_stats_to_dict(stats) + ds: _metric_stats_to_dict(stats, ds) for ds, stats in m.summary.items() if stats.has_data }, diff --git a/src/meshmon/templates/chart-tooltip.js b/src/meshmon/templates/chart-tooltip.js index 60e589f..51e966c 100644 --- a/src/meshmon/templates/chart-tooltip.js +++ b/src/meshmon/templates/chart-tooltip.js @@ -58,7 +58,8 @@ month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + timeZoneName: 'short' }; // For year view, include year @@ -180,15 +181,23 @@ const yMin = parseFloat(svg.dataset.yMin); const yMax = parseFloat(svg.dataset.yMax); - // Find the path with data-points - const path = svg.querySelector('path[data-points]'); + // Find the primary line path for precise coordinates + const path = + svg.querySelector('path#chart-line') || + svg.querySelector('path[gid="chart-line"]') || + svg.querySelector('#chart-line path') || + svg.querySelector('[gid="chart-line"] path') || + svg.querySelector('path[data-points]'); if (!path) return; - // Parse and cache data points and path coordinates on first access - if (!path._dataPoints) { + const pointsSource = path.dataset.points || svg.dataset.points; + if (!pointsSource) return; + + // Parse and cache data points on first access + if (!svg._dataPoints) { try { - const json = path.dataset.points.replace(/"/g, '"'); - path._dataPoints = JSON.parse(json); + const json = pointsSource.replace(/"/g, '"'); + svg._dataPoints = JSON.parse(json); } catch (e) { console.warn('Failed to parse chart data:', e); return; @@ -220,7 +229,7 @@ const targetTs = xStart + clampedRelX * (xEnd - xStart); // Find closest data point by timestamp - const result = findClosestPoint(path._dataPoints, targetTs); + const result = findClosestPoint(svg._dataPoints, targetTs); if (!result) return; const { point } = result;