From 865b695ff966bfab7dab4d211fb2d91f06fe92db Mon Sep 17 00:00:00 2001 From: DevTekVE Date: Thu, 27 Nov 2025 21:56:32 +0100 Subject: [PATCH] feat(params): add support for parameter metadata retrieval - Introduced `getKeyMetadata` method for accessing metadata associated with params. - Enhanced `getParamsAllKeysV1` to include metadata parsing and optional dynamic enum generation. - Extended unit tests to verify metadata parsing, enum mapping, and edge cases. --- common/params.cc | 4 + common/params.h | 2 + common/params_keys.h | 4 +- common/params_pyx.pyx | 6 + sunnypilot/sunnylink/athena/sunnylinkd.py | 38 ++++-- .../sunnylink/tests/test_params_metadata.py | 123 ++++++++++++++++++ 6 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 sunnypilot/sunnylink/tests/test_params_metadata.py diff --git a/common/params.cc b/common/params.cc index 39592cb905..38824ce8d8 100644 --- a/common/params.cc +++ b/common/params.cc @@ -129,6 +129,10 @@ std::optional Params::getKeyDefaultValue(const std::string &key) { return keys[key].default_value; } +std::optional Params::getKeyMetadata(const std::string &key) { + return keys[key].metadata; +} + int Params::put(const char* key, const char* value, size_t value_size) { // Information about safely and atomically writing a file: https://lwn.net/Articles/457667/ // 1) Create temp file diff --git a/common/params.h b/common/params.h index de4f9b435f..d12ba38f03 100644 --- a/common/params.h +++ b/common/params.h @@ -36,6 +36,7 @@ struct ParamKeyAttributes { uint32_t flags; ParamKeyType type; std::optional default_value = std::nullopt; + std::optional metadata = std::nullopt; }; class Params { @@ -51,6 +52,7 @@ public: ParamKeyFlag getKeyFlag(const std::string &key); ParamKeyType getKeyType(const std::string &key); std::optional getKeyDefaultValue(const std::string &key); + std::optional getKeyMetadata(const std::string &key); inline std::string getParamPath(const std::string &key = {}) { return params_path + params_prefix + (key.empty() ? "" : "/" + key); } diff --git a/common/params_keys.h b/common/params_keys.h index 38578d7ddc..631664014d 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -82,7 +82,7 @@ inline static std::unordered_map keys = { {"LiveTorqueParameters", {PERSISTENT | DONT_LOG, BYTES}}, {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, - {"LongitudinalPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"LongitudinalPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD)), R"({"enum": "LongitudinalPersonality", "title": "Driving Personality", "description": "Adjusts the driving style of the openpilot longitudinal control."})"}}, {"NetworkMetered", {PERSISTENT | BACKUP, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, @@ -135,7 +135,7 @@ inline static std::unordered_map keys = { {"ApiCache_DriveStats", {PERSISTENT, JSON}}, {"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}}, {"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}}, - {"BlinkerMinLateralControlSpeed", {PERSISTENT | BACKUP, INT, "20"}}, // MPH or km/h + {"BlinkerMinLateralControlSpeed",{PERSISTENT | BACKUP, INT, "20", R"({"min": 0, "max": 255, "step": 1, "title": "Blinker Min Lat Control Speed", "units": "mph", "description": "Minimum speed for lateral control during blinker usage."})"}}, {"BlinkerPauseLateralControl", {PERSISTENT | BACKUP, INT, "0"}}, {"Brightness", {PERSISTENT | BACKUP, INT, "0"}}, {"CarParamsSP", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES}}, diff --git a/common/params_pyx.pyx b/common/params_pyx.pyx index bffa89b5d3..d5fc5df7bf 100644 --- a/common/params_pyx.pyx +++ b/common/params_pyx.pyx @@ -42,6 +42,7 @@ cdef extern from "common/params.h": bool checkKey(string) nogil ParamKeyType getKeyType(string) nogil optional[string] getKeyDefaultValue(string) nogil + optional[string] getKeyMetadata(string) nogil string getParamPath(string) nogil void clearAll(ParamKeyFlag) vector[string] allKeys(ParamKeyFlag) @@ -191,6 +192,11 @@ cdef class Params: cdef optional[string] default = self.p.getKeyDefaultValue(k) return self._cpp2python(t, default.value(), None, key) if default.has_value() else None + def get_key_metadata(self, key): + cdef string k = self.check_key(key) + cdef optional[string] metadata = self.p.getKeyMetadata(k) + return metadata.value().decode("utf-8") if metadata.has_value() else None + def cpp2python(self, key, value): cdef string k = self.check_key(key) cdef ParamKeyType t = self.p.getKeyType(k) diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index 1e3713c7ef..d6502e28f2 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -13,6 +13,7 @@ import time from jsonrpc import dispatcher from functools import partial +from cereal import log from openpilot.common.params import Params from openpilot.common.realtime import set_core_affinity from openpilot.common.swaglog import cloudlog @@ -180,16 +181,39 @@ def getParamsAllKeys() -> list[str]: @dispatcher.add_method def getParamsAllKeysV1() -> dict[str, str]: - available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + params = Params() + available_keys: list[str] = [k.decode('utf-8') for k in params.all_keys()] params_dict: dict[str, list[dict[str, str | bool | int | None]]] = {"params": []} for key in available_keys: value = get_param_as_byte(key, get_default=True) - params_dict["params"].append({ + + metadata = None + metadata_json = params.get_key_metadata(key) + if metadata_json: + try: + metadata = json.loads(metadata_json) + if "enum" in metadata and log is not None: + enum_name = metadata["enum"] + if hasattr(log, enum_name): + enum_cls = getattr(log, enum_name) + if hasattr(enum_cls, "schema") and hasattr(enum_cls.schema, "enumerants"): + options = [] + for name, val in enum_cls.schema.enumerants.items(): + options.append({"value": val, "label": name}) + # Sort by value + options.sort(key=lambda x: x["value"]) + metadata["options"] = options + except json.JSONDecodeError: + pass + + param_data = { "key": key, "type": int(params.get_type(key).value), "default_value": base64.b64encode(value).decode('utf-8') if value else None, - }) + "_extra": metadata, + } + params_dict["params"].append(param_data) return {"keys": json.dumps(params_dict.get("params", []))} @@ -238,10 +262,7 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local cloudlog.debug("athena.startLocalProxy.starting") ws = create_connection( - remote_ws_uri, - header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, - enable_multithread=True, - sslopt={"cert_reqs": ssl.CERT_NONE} + remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE} ) return start_local_proxy_shim(global_end_event, local_port, ws) @@ -277,8 +298,7 @@ def main(exit_event: threading.Event = None): sslopt={"cert_reqs": ssl.CERT_NONE if "localhost" in ws_uri else ssl.CERT_REQUIRED}, timeout=SUNNYLINK_RECONNECT_TIMEOUT_S, ) - cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, retries=conn_retries, - duration=time.monotonic() - conn_start) + cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, retries=conn_retries, duration=time.monotonic() - conn_start) conn_start = None conn_retries = 0 diff --git a/sunnypilot/sunnylink/tests/test_params_metadata.py b/sunnypilot/sunnylink/tests/test_params_metadata.py new file mode 100644 index 0000000000..d309f22b3a --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_metadata.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +import sys +import os +import json +import unittest +from unittest.mock import MagicMock, patch + +# Add openpilot root to python path +sys.path.append(os.getcwd()) + +# Mock ALL dependencies before importing sunnylinkd +jsonrpc_mock = MagicMock() + + +def add_method(func): + return func + + +jsonrpc_mock.dispatcher.add_method = add_method + +sys.modules["jsonrpc"] = jsonrpc_mock +sys.modules["jsonrpc.dispatcher"] = jsonrpc_mock.dispatcher +sys.modules["openpilot.common.params"] = MagicMock() +sys.modules["openpilot.common.realtime"] = MagicMock() +sys.modules["openpilot.common.swaglog"] = MagicMock() +sys.modules["openpilot.system.hardware.hw"] = MagicMock() +sys.modules["openpilot.system.athena.athenad"] = MagicMock() +sys.modules["websocket"] = MagicMock() +sys.modules["cereal"] = MagicMock() +sys.modules["cereal.messaging"] = MagicMock() +sys.modules["openpilot.sunnypilot.sunnylink.api"] = MagicMock() +sys.modules["openpilot.sunnypilot.sunnylink.utils"] = MagicMock() + +# Now import sunnylinkd +from sunnypilot.sunnylink.athena import sunnylinkd + + +class TestParamsMetadata(unittest.TestCase): + @patch("sunnypilot.sunnylink.athena.sunnylinkd.Params") + @patch("sunnypilot.sunnylink.athena.sunnylinkd.get_param_as_byte") + def test_metadata_parsing(self, mock_get_param, mock_params): + # Setup mock Params + mock_params_instance = mock_params.return_value + mock_params_instance.all_keys.return_value = [b"LongitudinalPersonality", b"BlinkerMinLateralControlSpeed", b"DongleId"] + mock_params_instance.get_type.return_value.value = 1 # INT + + # Setup mock get_key_metadata + def get_key_metadata_side_effect(key): + if key == "LongitudinalPersonality": + return json.dumps( + {"enum": "LongitudinalPersonality", "title": "Driving Personality", "description": "Adjusts the driving style of the openpilot longitudinal control."} + ) + elif key == "BlinkerMinLateralControlSpeed": + return json.dumps( + { + "min": 0, + "max": 255, + "step": 1, + "title": "Blinker Min Lat Control Speed", + "units": "mph", + "description": "Minimum speed for lateral control during blinker usage.", + } + ) + return None + + mock_params_instance.get_key_metadata.side_effect = get_key_metadata_side_effect + + # Setup mock get_param_as_byte + mock_get_param.return_value = b"1" + + # Mock cereal.log + mock_log = MagicMock() + sunnylinkd.log = mock_log + + # Setup LongitudinalPersonality enum mock + mock_enum = MagicMock() + mock_enum.schema.enumerants = {"Aggressive": 0, "Standard": 1, "Relaxed": 2} + mock_log.LongitudinalPersonality = mock_enum + + # Call the function + response = sunnylinkd.getParamsAllKeysV1() + + self.assertIn("keys", response) + params = json.loads(response["keys"]) + + # Check LongitudinalPersonality (Enum) + lp_param = next((p for p in params if p["key"] == "LongitudinalPersonality"), None) + self.assertIsNotNone(lp_param) + + # Debug print + print(f"LongitudinalPersonality param: {lp_param}") + + self.assertIn("_extra", lp_param) + extra = lp_param["_extra"] + self.assertIsNotNone(extra) + self.assertEqual(extra.get("title"), "Driving Personality") + self.assertEqual(extra.get("enum"), "LongitudinalPersonality") + + # Verify options are populated from our mock + self.assertIn("options", extra) + options = extra["options"] + self.assertEqual(len(options), 3) + self.assertEqual(options[0]["label"], "Aggressive") + self.assertEqual(options[0]["value"], 0) + + # Check BlinkerMinLateralControlSpeed (Numeric) + blinker_param = next((p for p in params if p["key"] == "BlinkerMinLateralControlSpeed"), None) + self.assertIsNotNone(blinker_param) + extra = blinker_param["_extra"] + self.assertIsNotNone(extra) + self.assertEqual(extra.get("title"), "Blinker Min Lat Control Speed") + self.assertEqual(extra.get("min"), 0) + self.assertEqual(extra.get("max"), 255) + self.assertEqual(extra.get("units"), "mph") + + # Check a param without metadata + other_param = next((p for p in params if p["key"] == "DongleId"), None) + self.assertIsNotNone(other_param) + self.assertIsNone(other_param.get("_extra")) + + +if __name__ == "__main__": + unittest.main()