#!/usr/bin/env python3 import base64 import json import os import threading import traceback import zmq import time import uuid from pathlib import Path from collections import defaultdict from datetime import datetime, UTC from openpilot.common.params import Params from cereal.messaging import SubMaster from openpilot.system.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.system.hardware import HARDWARE from openpilot.common.utils import atomic_write from openpilot.system.version import get_build_metadata from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, STATS_FLUSH_TIME_S from openpilot.system.statsd import METRIC_TYPE, StatLogSP from openpilot.common.realtime import Ratekeeper STATSLOGSP = StatLogSP(intercept=False) def sp_stats(end_event): """Collect sunnypilot-specific statistics and send as raw metrics.""" rk = Ratekeeper(.1, print_delay_threshold=None) statlogsp = STATSLOGSP params = Params() def flatten_dict(d, parent_key='', sep='.'): items = {} if isinstance(d, dict): for k, v in d.items(): new_key = f"{parent_key}{sep}{k}" if parent_key else k items.update(flatten_dict(v, new_key, sep=sep)) elif isinstance(d, (list, tuple)): for i, v in enumerate(d): new_key = f"{parent_key}[{i}]" items.update(flatten_dict(v, new_key, sep=sep)) else: items[parent_key] = d return items # Collect sunnypilot parameters stats_dict = {} param_keys = [ 'SunnylinkEnabled', 'AutoLaneChangeBsmDelay', 'AutoLaneChangeTimer', 'CarPlatformBundle', 'CurrentRoute', 'DevUIInfo', 'EnableCopyparty', 'IntelligentCruiseButtonManagement', 'QuietMode', 'RainbowMode', 'ShowAdvancedControls', 'Mads', 'MadsMainCruiseAllowed', 'MadsSteeringMode', 'MadsUnifiedEngagementMode', 'ModelManager_ActiveBundle', 'ModelManager_Favs', 'EnableSunnylinkUploader', 'SunnylinkEnabled', 'InstallDate', 'UptimeOffroad', 'UptimeOnroad', ] while not end_event.is_set(): try: for key in param_keys: try: value = params.get(key) except Exception as e: stats_dict[key] = e continue if value is None: continue if isinstance(value, (dict, list, tuple)): stats_dict.update(flatten_dict(value, key)) else: stats_dict[key] = value if stats_dict: statlogsp.raw('sunnypilot.device_params', stats_dict) except Exception as e: cloudlog.error(f"Exception {e}") finally: rk.keep_time() def stats_main(end_event): comma_dongle_id = Params().get("DongleId") sunnylink_dongle_id = Params().get("SunnylinkDongleId") def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str: res = f"{measurement}" for k, v in tags.items(): res += f",{k}={str(v)}" res += " " if isinstance(value, float): value = {'value': value} for k, v in value.items(): res += f"{k}={str(v)}," res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" return res def get_influxdb_line_raw(measurement: str, value: dict, timestamp: datetime, tags: dict) -> str: res = f"{measurement}" try: custom_tags = "" for k, v in tags.items(): custom_tags += f",{k}={str(v)}" res += custom_tags fields = "" for k, v in value.items(): # Skip complex types - only keep simple scalar values if isinstance(v, (dict, list, bytes, bytearray)): continue fields += f"{k}={json.dumps(v)}," res += f" {fields}" except Exception as e: cloudlog.error(f"Unable to get influxdb line for: {value}") res += f",invalid=1 reason={e}," res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n" return res # open statistics socket ctx = zmq.Context.instance() sock = ctx.socket(zmq.PULL) sock.bind(f"{STATS_SOCKET}_sp") STATS_DIR = Paths.stats_sp_root() # initialize stats directory Path(STATS_DIR).mkdir(parents=True, exist_ok=True) build_metadata = get_build_metadata() # initialize tags tags = { 'started': False, 'version': build_metadata.openpilot.version, 'branch': build_metadata.channel, 'dirty': build_metadata.openpilot.is_dirty, 'origin': build_metadata.openpilot.git_normalized_origin, 'deviceType': HARDWARE.get_device_type(), } # subscribe to deviceState for started state sm = SubMaster(['deviceState']) idx = 0 boot_uid = str(uuid.uuid4())[:8] last_flush_time = time.monotonic() gauges = {} samples: dict[str, list[float]] = defaultdict(list) raws: dict = defaultdict() try: while not end_event.is_set(): started_prev = sm['deviceState'].started sm.update() # Update metrics while True: try: metric = sock.recv_string(zmq.NOBLOCK) try: metric_type = metric.split('|')[1] metric_name = metric.split(':')[0] metric_value_raw = metric.split('|')[0].split(':')[1] if metric_type == METRIC_TYPE.GAUGE: metric_value = float(metric_value_raw) gauges[metric_name] = metric_value elif metric_type == METRIC_TYPE.SAMPLE: metric_value = float(metric_value_raw) samples[metric_name].append(metric_value) elif metric_type == METRIC_TYPE.RAW: raws[metric_name] = metric_value_raw else: cloudlog.event("unknown metric type", metric_type=metric_type) except Exception: print(traceback.format_exc()) cloudlog.event("malformed metric", metric=metric) except zmq.error.Again: break # flush when started state changes or after FLUSH_TIME_S if (time.monotonic() > last_flush_time + STATS_FLUSH_TIME_S) or (sm['deviceState'].started != started_prev): result = "" current_time = datetime.now(UTC) tags['started'] = sm['deviceState'].started for key, value in raws.items(): decoded_value = json.loads(base64.b64decode(value).decode('utf-8')) result += get_influxdb_line_raw(key, decoded_value, current_time, tags) for key, value in gauges.items(): result += get_influxdb_line(f"gauge.{key}", value, current_time, tags) for key, values in samples.items(): values.sort() sample_count = len(values) sample_sum = sum(values) stats = { 'count': sample_count, 'min': values[0], 'max': values[-1], 'mean': sample_sum / sample_count, } for percentile in [0.05, 0.5, 0.95]: value = values[int(round(percentile * (sample_count - 1)))] stats[f"p{int(percentile * 100)}"] = value result += get_influxdb_line(f"sample.{key}", stats, current_time, tags) # clear intermediate data gauges.clear() samples.clear() last_flush_time = time.monotonic() # check that we aren't filling up the drive if len(os.listdir(STATS_DIR)) < STATS_DIR_FILE_LIMIT: if len(result) > 0: stats_path = os.path.join(STATS_DIR, f"{boot_uid}_{idx}") with atomic_write(stats_path) as f: f.write(result) idx += 1 else: cloudlog.error("stats dir full") finally: sock.close() ctx.term() def main(): rk = Ratekeeper(1, print_delay_threshold=None) end_event = threading.Event() threads = [ threading.Thread(target=stats_main, args=(end_event,)), threading.Thread(target=sp_stats, args=(end_event,)), ] for t in threads: t.start() try: while all(t.is_alive() for t in threads): rk.keep_time() finally: end_event.set() for t in threads: t.join() if __name__ == "__main__": main()