diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index 1e3713c7e..a57b6ef55 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" from __future__ import annotations import base64 @@ -32,9 +37,11 @@ LOCAL_PORT_WHITELIST = {8022} SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload" SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc DISALLOW_LOG_UPLOAD = threading.Event() +METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "params_metadata.json") params = Params() + def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: cloudlog.info("sunnylinkd.handle_long_poll started") sm = messaging.SubMaster(['deviceState']) @@ -180,16 +187,30 @@ def getParamsAllKeys() -> list[str]: @dispatcher.add_method def getParamsAllKeysV1() -> dict[str, str]: + try: + with open(METADATA_PATH) as f: + metadata = json.load(f) + except Exception: + cloudlog.exception("sunnylinkd.getParamsAllKeysV1.exception") + metadata = {} + 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": []} + params_dict: dict[str, list[dict[str, str | bool | int | object | dict | None]]] = {"params": []} for key in available_keys: value = get_param_as_byte(key, get_default=True) - params_dict["params"].append({ + + param_entry = { "key": key, "type": int(params.get_type(key).value), "default_value": base64.b64encode(value).decode('utf-8') if value else None, - }) + } + + if key in metadata: + meta_copy = metadata[key].copy() + param_entry["_extra"] = meta_copy + + params_dict["params"].append(param_entry) return {"keys": json.dumps(params_dict.get("params", []))} @@ -238,10 +259,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) diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json new file mode 100644 index 000000000..83ec3c7af --- /dev/null +++ b/sunnypilot/sunnylink/params_metadata.json @@ -0,0 +1,1100 @@ +{ + "AccessToken": { + "title": "AccessTokenIsNice", + "description": "" + }, + "AdbEnabled": { + "title": "Enable ADB", + "description": "" + }, + "AlphaLongitudinalEnabled": { + "title": "Alpha Longitudinal", + "description": "" + }, + "AlwaysOnDM": { + "title": "Always-on Driver Monitor", + "description": "" + }, + "ApiCache_Device": { + "title": "Api Cache Device", + "description": "" + }, + "ApiCache_DriveStats": { + "title": "Api Cache Drive Stats", + "description": "" + }, + "ApiCache_FirehoseStats": { + "title": "Firehose Mode Stats", + "description": "" + }, + "AssistNowToken": { + "title": "Assist Now Token", + "description": "" + }, + "AthenadPid": { + "title": "Athenad Pid", + "description": "" + }, + "AthenadRecentlyViewedRoutes": { + "title": "Athenad Recently Viewed Routes", + "description": "" + }, + "AthenadUploadQueue": { + "title": "Athenad Upload Queue", + "description": "" + }, + "AutoLaneChangeBsmDelay": { + "title": "Auto Lane Change BSM Delay", + "description": "" + }, + "AutoLaneChangeTimer": { + "title": "Auto Lane Change Timer", + "description": "", + "options": [ + { + "value": -1, + "label": "Off" + }, + { + "value": 0, + "label": "Nudge" + }, + { + "value": 1, + "label": "Nudgeless" + }, + { + "value": 2, + "label": "0.5s" + }, + { + "value": 3, + "label": "1s" + }, + { + "value": 4, + "label": "2s" + }, + { + "value": 5, + "label": "3s" + } + ] + }, + "BackupManager_CreateBackup": { + "title": "Create Backup", + "description": "" + }, + "BackupManager_RestoreVersion": { + "title": "Restore Version", + "description": "" + }, + "BlindSpot": { + "title": "Blind Spot Detection", + "description": "" + }, + "BlinkerMinLateralControlSpeed": { + "title": "Blinker Min Lateral Control Speed", + "description": "" + }, + "BlinkerPauseLateralControl": { + "title": "Blinker Pause Lateral Control", + "description": "" + }, + "BootCount": { + "title": "Boot Count", + "description": "" + }, + "Brightness": { + "title": "Screen Brightness", + "description": "" + }, + "CalibrationParams": { + "title": "Calibration Params", + "description": "" + }, + "CameraDebugExpGain": { + "title": "Camera Debug Exp Gain", + "description": "" + }, + "CameraDebugExpTime": { + "title": "Camera Debug Exp Time", + "description": "" + }, + "CarBatteryCapacity": { + "title": "Car Battery Capacity", + "description": "" + }, + "CarParams": { + "title": "Car Params", + "description": "" + }, + "CarParamsCache": { + "title": "Car Params Cache", + "description": "" + }, + "CarParamsPersistent": { + "title": "Car Params Persistent", + "description": "" + }, + "CarParamsPrevRoute": { + "title": "Car Params Prev Route", + "description": "" + }, + "CarParamsSP": { + "title": "Car Params Sp", + "description": "" + }, + "CarParamsSPCache": { + "title": "Car Params Sp Cache", + "description": "" + }, + "CarParamsSPPersistent": { + "title": "Car Params Sp Persistent", + "description": "" + }, + "CarPlatformBundle": { + "title": "Car Platform Bundle", + "description": "" + }, + "ChevronInfo": { + "title": "Chevron Info", + "description": "" + }, + "CompletedTrainingVersion": { + "title": "Completed Training Version", + "description": "" + }, + "ControlsReady": { + "title": "Controls Ready", + "description": "" + }, + "CurrentBootlog": { + "title": "Current Bootlog", + "description": "" + }, + "CurrentRoute": { + "title": "Current Route", + "description": "" + }, + "CustomAccIncrementsEnabled": { + "title": "Custom ACC Increments Enabled", + "description": "" + }, + "CustomAccLongPressIncrement": { + "title": "Custom ACC Long Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomAccShortPressIncrement": { + "title": "Custom ACC Short Press Increment", + "description": "", + "min": 1, + "max": 10, + "step": 1 + }, + "CustomTorqueParams": { + "title": "Custom Torque Params", + "description": "" + }, + "DevUIInfo": { + "title": "Developer UI Info", + "description": "" + }, + "DeviceBootMode": { + "title": "Device Boot Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Standard" + }, + { + "value": 1, + "label": "Always Offroad" + } + ] + }, + "DisableLogging": { + "title": "Disable Logging", + "description": "" + }, + "DisablePowerDown": { + "title": "Disable Power Down", + "description": "" + }, + "DisableUpdates": { + "title": "Disable Updates", + "description": "" + }, + "DisengageOnAccelerator": { + "title": "Disengage On Accelerator", + "description": "" + }, + "DoReboot": { + "title": "Reboot", + "description": "" + }, + "DoShutdown": { + "title": "Power Off", + "description": "" + }, + "DoUninstall": { + "title": "Uninstall sunnypilot", + "description": "" + }, + "DongleId": { + "title": "Device ID", + "description": "" + }, + "DriverTooDistracted": { + "title": "Driver Too Distracted", + "description": "" + }, + "DynamicExperimentalControl": { + "title": "Dynamic Experimental Control", + "description": "" + }, + "EnableCopyparty": { + "title": "Enable Copyparty", + "description": "" + }, + "EnableGithubRunner": { + "title": "Enable GitHub Runner", + "description": "" + }, + "EnableSunnylinkUploader": { + "title": "Enable sunnylink Uploader", + "description": "" + }, + "EnforceTorqueControl": { + "title": "Enforce Torque Control", + "description": "" + }, + "ExperimentalMode": { + "title": "Experimental Mode", + "description": "" + }, + "ExperimentalModeConfirmed": { + "title": "Experimental Mode Confirmed", + "description": "" + }, + "FirmwareQueryDone": { + "title": "Firmware Query Done", + "description": "" + }, + "ForcePowerDown": { + "title": "Force Power Down", + "description": "" + }, + "GitBranch": { + "title": "Git Branch", + "description": "" + }, + "GitCommit": { + "title": "Git Commit", + "description": "" + }, + "GitCommitDate": { + "title": "Git Commit Date", + "description": "" + }, + "GitDiff": { + "title": "Git Diff", + "description": "" + }, + "GitRemote": { + "title": "Git Remote", + "description": "" + }, + "GithubRunnerSufficientVoltage": { + "title": "Github Runner Sufficient Voltage", + "description": "" + }, + "GithubSshKeys": { + "title": "Github Ssh Keys", + "description": "" + }, + "GithubUsername": { + "title": "GitHub Username", + "description": "" + }, + "GreenLightAlert": { + "title": "Green Light Alert", + "description": "" + }, + "GsmApn": { + "title": "GSM APN", + "description": "" + }, + "GsmMetered": { + "title": "Gsm Metered", + "description": "" + }, + "GsmRoaming": { + "title": "GSM Roaming", + "description": "" + }, + "HardwareSerial": { + "title": "Serial Number", + "description": "" + }, + "HasAcceptedTerms": { + "title": "Has Accepted Terms", + "description": "" + }, + "HideVEgoUI": { + "title": "Hide vEgo UI", + "description": "" + }, + "HyundaiLongitudinalTuning": { + "title": "Hyundai Longitudinal Tuning", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Dynamic" + }, + { + "value": 2, + "label": "Predictive" + } + ] + }, + "InstallDate": { + "title": "Install Date", + "description": "" + }, + "IntelligentCruiseButtonManagement": { + "title": "Intelligent Cruise Button Management", + "description": "" + }, + "InteractivityTimeout": { + "title": "Interactivity Timeout", + "description": "" + }, + "IsDevelopmentBranch": { + "title": "Is Development Branch", + "description": "" + }, + "IsDriverViewEnabled": { + "title": "Is Driver View Enabled", + "description": "" + }, + "IsEngaged": { + "title": "Is Engaged", + "description": "" + }, + "IsLdwEnabled": { + "title": "Lane Departure Warnings", + "description": "" + }, + "IsMetric": { + "title": "Use Metric Units", + "description": "" + }, + "IsOffroad": { + "title": "Is Offroad", + "description": "" + }, + "IsOnroad": { + "title": "Is Onroad", + "description": "" + }, + "IsReleaseBranch": { + "title": "Is Release Branch", + "description": "" + }, + "IsReleaseSpBranch": { + "title": "Is Release Sp Branch", + "description": "" + }, + "IsRhdDetected": { + "title": "Is Rhd Detected", + "description": "" + }, + "IsTakingSnapshot": { + "title": "Is Taking Snapshot", + "description": "" + }, + "IsTestedBranch": { + "title": "Is Tested Branch", + "description": "" + }, + "JoystickDebugMode": { + "title": "Joystick Debug Mode", + "description": "" + }, + "LagdToggle": { + "title": "LaGD Toggle", + "description": "" + }, + "LagdToggleDelay": { + "title": "LaGD Toggle Delay", + "description": "" + }, + "LagdValueCache": { + "title": "LaGD Value Cache", + "description": "" + }, + "LaneTurnDesire": { + "title": "Lane Turn Desire", + "description": "" + }, + "LaneTurnValue": { + "title": "Lane Turn Value", + "description": "", + "min": 0, + "max": 20, + "step": 1 + }, + "LanguageSetting": { + "title": "Language", + "description": "" + }, + "LastAthenaPingTime": { + "title": "Last Athena Ping Time", + "description": "" + }, + "LastGPSPosition": { + "title": "Last Gps Position", + "description": "" + }, + "LastGPSPositionLLK": { + "title": "Last GPS Position LLK", + "description": "" + }, + "LastManagerExitReason": { + "title": "Last Manager Exit Reason", + "description": "" + }, + "LastOffroadStatusPacket": { + "title": "Last Offroad Status Packet", + "description": "" + }, + "LastPowerDropDetected": { + "title": "Last Power Drop Detected", + "description": "" + }, + "LastSunnylinkPingTime": { + "title": "Last sunnylink Ping Time", + "description": "" + }, + "LastUpdateException": { + "title": "Last Update Exception", + "description": "" + }, + "LastUpdateRouteCount": { + "title": "Last Update Route Count", + "description": "" + }, + "LastUpdateTime": { + "title": "Last Update Time", + "description": "" + }, + "LastUpdateUptimeOnroad": { + "title": "Last Update Uptime Onroad", + "description": "" + }, + "LeadDepartAlert": { + "title": "Lead Depart Alert", + "description": "" + }, + "LiveDelay": { + "title": "Live Delay", + "description": "" + }, + "LiveParameters": { + "title": "Live Parameters", + "description": "" + }, + "LiveParametersV2": { + "title": "Live Parameters V2", + "description": "" + }, + "LiveTorqueParameters": { + "title": "Live Torque Parameters", + "description": "" + }, + "LiveTorqueParamsRelaxedToggle": { + "title": "Live Torque Params Relaxed Toggle", + "description": "" + }, + "LiveTorqueParamsToggle": { + "title": "Live Torque Params Toggle", + "description": "" + }, + "LocationFilterInitialState": { + "title": "Location Filter Initial State", + "description": "" + }, + "LongitudinalManeuverMode": { + "title": "Longitudinal Maneuver Mode", + "description": "" + }, + "LongitudinalPersonality": { + "title": "Driving Personality", + "description": "", + "options": [ + { + "value": 0, + "label": "Aggressive" + }, + { + "value": 1, + "label": "Standard" + }, + { + "value": 2, + "label": "Relaxed" + } + ] + }, + "Mads": { + "title": "MADS Enabled", + "description": "" + }, + "MadsMainCruiseAllowed": { + "title": "MADS Main Cruise Allowed", + "description": "" + }, + "MadsSteeringMode": { + "title": "MADS Steering Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Remain Active" + }, + { + "value": 1, + "label": "Pause" + }, + { + "value": 2, + "label": "Disengage" + } + ] + }, + "MadsUnifiedEngagementMode": { + "title": "MADS Unified Engagement Mode", + "description": "" + }, + "MapAdvisorySpeedLimit": { + "title": "Map Advisory Speed Limit", + "description": "" + }, + "MapSpeedLimit": { + "title": "Map Speed Limit", + "description": "" + }, + "MapTargetVelocities": { + "title": "Map Target Velocities", + "description": "" + }, + "MapdVersion": { + "title": "Mapd Version", + "description": "" + }, + "MaxTimeOffroad": { + "title": "Max Time Offroad", + "description": "" + }, + "ModelManager_ActiveBundle": { + "title": "Model Manager Active Bundle", + "description": "" + }, + "ModelManager_ClearCache": { + "title": "Model Manager Clear Cache", + "description": "" + }, + "ModelManager_DownloadIndex": { + "title": "Model Manager Download Index", + "description": "" + }, + "ModelManager_Favs": { + "title": "Model Manager Favorites", + "description": "" + }, + "ModelManager_LastSyncTime": { + "title": "Model Manager Last Sync Time", + "description": "" + }, + "ModelManager_ModelsCache": { + "title": "Model Manager Models Cache", + "description": "" + }, + "ModelRunnerTypeCache": { + "title": "Model Runner Type Cache", + "description": "" + }, + "NetworkMetered": { + "title": "Network Usage", + "description": "", + "options": [ + { + "value": 0, + "label": "Default" + }, + { + "value": 1, + "label": "Metered" + }, + { + "value": 2, + "label": "Unmetered" + } + ] + }, + "NeuralNetworkLateralControl": { + "title": "Neural Network Lateral Control", + "description": "" + }, + "NextMapSpeedLimit": { + "title": "Next Map Speed Limit", + "description": "" + }, + "OSMDownloadBounds": { + "title": "OSM Download Bounds", + "description": "" + }, + "OSMDownloadLocations": { + "title": "OSM Download Locations", + "description": "" + }, + "OSMDownloadProgress": { + "title": "OSM Download Progress", + "description": "" + }, + "ObdMultiplexingChanged": { + "title": "Obd Multiplexing Changed", + "description": "" + }, + "ObdMultiplexingEnabled": { + "title": "Obd Multiplexing Enabled", + "description": "" + }, + "OffroadMode": { + "title": "Offroad Mode", + "description": "" + }, + "Offroad_CarUnrecognized": { + "title": "Offroad Car Unrecognized", + "description": "" + }, + "Offroad_ConnectivityNeeded": { + "title": "Offroad Connectivity Needed", + "description": "" + }, + "Offroad_ConnectivityNeededPrompt": { + "title": "Offroad Connectivity Needed Prompt", + "description": "" + }, + "Offroad_DriverMonitoringUncertain": { + "title": "Offroad Driver Monitoring Uncertain", + "description": "" + }, + "Offroad_ExcessiveActuation": { + "title": "Offroad Excessive Actuation", + "description": "" + }, + "Offroad_IsTakingSnapshot": { + "title": "Offroad Is Taking Snapshot", + "description": "" + }, + "Offroad_NeosUpdate": { + "title": "Offroad Neos Update", + "description": "" + }, + "Offroad_NoFirmware": { + "title": "Offroad No Firmware", + "description": "" + }, + "Offroad_OSMUpdateRequired": { + "title": "Offroad OSM Update Required", + "description": "" + }, + "Offroad_Recalibration": { + "title": "Offroad Recalibration", + "description": "" + }, + "Offroad_TemperatureTooHigh": { + "title": "Offroad Temperature Too High", + "description": "" + }, + "Offroad_TiciSupport": { + "title": "Offroad Tici Support", + "description": "" + }, + "Offroad_UnregisteredHardware": { + "title": "Offroad Unregistered Hardware", + "description": "" + }, + "Offroad_UpdateFailed": { + "title": "Offroad Update Failed", + "description": "" + }, + "OnroadCycleRequested": { + "title": "Onroad Cycle Requested", + "description": "" + }, + "OnroadScreenOffBrightness": { + "title": "Onroad Screen Off Brightness", + "description": "", + "min": 0, + "max": 100, + "step": 5 + }, + "OnroadScreenOffControl": { + "title": "Onroad Screen Off Control", + "description": "" + }, + "OnroadScreenOffTimer": { + "title": "Onroad Screen Off Timer", + "description": "", + "min": 0, + "max": 60, + "step": 1 + }, + "OnroadUploads": { + "title": "Onroad Uploads", + "description": "" + }, + "OpenpilotEnabledToggle": { + "title": "Enable sunnypilot", + "description": "" + }, + "OsmDbUpdatesCheck": { + "title": "OSM DB Updates Check", + "description": "" + }, + "OsmDownloadedDate": { + "title": "OSM Downloaded Date", + "description": "" + }, + "OsmLocal": { + "title": "OSM Local", + "description": "" + }, + "OsmLocationName": { + "title": "OSM Location Name", + "description": "" + }, + "OsmLocationTitle": { + "title": "OSM Location Title", + "description": "" + }, + "OsmLocationUrl": { + "title": "OSM Location URL", + "description": "" + }, + "OsmStateName": { + "title": "OSM State Name", + "description": "" + }, + "OsmStateTitle": { + "title": "OSM State Title", + "description": "" + }, + "OsmWayTest": { + "title": "OSM Way Test", + "description": "" + }, + "PandaHeartbeatLost": { + "title": "Panda Heartbeat Lost", + "description": "" + }, + "PandaSignatures": { + "title": "Panda Signatures", + "description": "" + }, + "PandaSomResetTriggered": { + "title": "Panda Som Reset Triggered", + "description": "" + }, + "PrimeType": { + "title": "Prime Type", + "description": "" + }, + "QuickBootToggle": { + "title": "Quick Boot", + "description": "" + }, + "QuietMode": { + "title": "Quiet Mode", + "description": "" + }, + "RainbowMode": { + "title": "Rainbow Mode", + "description": "" + }, + "RecordAudio": { + "title": "Record & Upload Mic Audio", + "description": "" + }, + "RecordAudioFeedback": { + "title": "Record Audio Feedback", + "description": "" + }, + "RecordFront": { + "title": "Record & Upload Driver Camera", + "description": "" + }, + "RecordFrontLock": { + "title": "Record Front Lock", + "description": "" + }, + "RoadName": { + "title": "Road Name", + "description": "" + }, + "RoadNameToggle": { + "title": "Road Name Toggle", + "description": "" + }, + "RouteCount": { + "title": "Route Count", + "description": "" + }, + "SecOCKey": { + "title": "Sec Oc Key", + "description": "" + }, + "ShowAdvancedControls": { + "title": "Show Advanced Controls", + "description": "" + }, + "ShowDebugInfo": { + "title": "UI Debug Mode", + "description": "" + }, + "ShowTurnSignals": { + "title": "Show Turn Signals", + "description": "" + }, + "SmartCruiseControlMap": { + "title": "Smart Cruise Control Map", + "description": "" + }, + "SmartCruiseControlVision": { + "title": "Smart Cruise Control Vision", + "description": "" + }, + "SnoozeUpdate": { + "title": "Snooze Update", + "description": "" + }, + "SpeedLimitMode": { + "title": "Speed Limit Mode", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Information" + }, + { + "value": 2, + "label": "Warning" + }, + { + "value": 3, + "label": "Assist" + } + ] + }, + "SpeedLimitOffsetType": { + "title": "Speed Limit Offset Type", + "description": "", + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "Fixed" + }, + { + "value": 2, + "label": "Percentage" + } + ] + }, + "SpeedLimitPolicy": { + "title": "Speed Limit Policy", + "description": "", + "options": [ + { + "value": 0, + "label": "Car State Only" + }, + { + "value": 1, + "label": "Map Data Only" + }, + { + "value": 2, + "label": "Car State Priority" + }, + { + "value": 3, + "label": "Map Data Priority" + }, + { + "value": 4, + "label": "Combined" + } + ] + }, + "SpeedLimitValueOffset": { + "title": "Speed Limit Value Offset", + "description": "", + "min": -30, + "max": 30, + "step": 1 + }, + "SshEnabled": { + "title": "Enable SSH", + "description": "" + }, + "StandstillTimer": { + "title": "Standstill Timer", + "description": "" + }, + "SubaruStopAndGo": { + "title": "Subaru Stop and Go", + "description": "" + }, + "SubaruStopAndGoManualParkingBrake": { + "title": "Subaru Stop and Go Manual Parking Brake", + "description": "" + }, + "SunnylinkCache_Roles": { + "title": "sunnylink Cache Roles", + "description": "" + }, + "SunnylinkCache_Users": { + "title": "sunnylink Cache Users", + "description": "" + }, + "SunnylinkDongleId": { + "title": "sunnylink Dongle ID", + "description": "" + }, + "SunnylinkEnabled": { + "title": "sunnylink Enabled", + "description": "" + }, + "SunnylinkTempFault": { + "title": "sunnylink Temp Fault", + "description": "" + }, + "SunnylinkdPid": { + "title": "Sunnylinkd Pid", + "description": "" + }, + "TermsVersion": { + "title": "Terms Version", + "description": "" + }, + "TeslaCoopSteering": { + "title": "Tesla Coop Steering", + "description": "" + }, + "TorqueParamsOverrideEnabled": { + "title": "Torque Params Override Enabled", + "description": "" + }, + "TorqueParamsOverrideFriction": { + "title": "Torque Params Override Friction", + "description": "", + "min": 0.0, + "max": 1.0, + "step": 0.01 + }, + "TorqueParamsOverrideLatAccelFactor": { + "title": "Torque Params Override Lat Accel Factor", + "description": "", + "min": 0.1, + "max": 5.0, + "step": 0.1 + }, + "TrainingVersion": { + "title": "Training Version", + "description": "" + }, + "TrueVEgoUI": { + "title": "True vEgo UI", + "description": "" + }, + "UbloxAvailable": { + "title": "Ublox Available", + "description": "" + }, + "UpdateAvailable": { + "title": "Update Available", + "description": "" + }, + "UpdateFailedCount": { + "title": "Update Failed Count", + "description": "" + }, + "UpdaterAvailableBranches": { + "title": "Updater Available Branches", + "description": "" + }, + "UpdaterCurrentDescription": { + "title": "Updater Current Description", + "description": "" + }, + "UpdaterCurrentReleaseNotes": { + "title": "Updater Current Release Notes", + "description": "" + }, + "UpdaterFetchAvailable": { + "title": "Updater Fetch Available", + "description": "" + }, + "UpdaterLastFetchTime": { + "title": "Updater Last Fetch Time", + "description": "" + }, + "UpdaterNewDescription": { + "title": "Updater New Description", + "description": "" + }, + "UpdaterNewReleaseNotes": { + "title": "Updater New Release Notes", + "description": "" + }, + "UpdaterState": { + "title": "Updater State", + "description": "" + }, + "UpdaterTargetBranch": { + "title": "Updater Target Branch", + "description": "" + }, + "UptimeOffroad": { + "title": "Uptime Offroad", + "description": "" + }, + "UptimeOnroad": { + "title": "Uptime Onroad", + "description": "" + }, + "Version": { + "title": "openpilot Version", + "description": "" + } +} diff --git a/sunnypilot/sunnylink/tests/test_params_metadata.py b/sunnypilot/sunnylink/tests/test_params_metadata.py new file mode 100644 index 000000000..f4f1fbc4b --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_metadata.py @@ -0,0 +1,86 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json + +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import getParamsAllKeysV1, METADATA_PATH + + +def test_get_params_all_keys_v1(): + """ + Test the getParamsAllKeysV1 API endpoint. + + Why: + This endpoint is used by the UI (and potentially external tools) to fetch the list of + available parameters along with their metadata (titles, descriptions, options, constraints). + We need to ensure it returns the correct structure and that the metadata from + params_metadata.json is correctly merged into the response. + + Expected: + - The response should contain a "keys" field which is a JSON string of a list of parameters. + - Each parameter object should have "key", "type", "default_value", and optionally "_extra". + - The "_extra" field should contain the rich metadata (title, options, min/max, etc.) matching + the source of truth (params_metadata.json). + """ + response = getParamsAllKeysV1() + assert "keys" in response + + keys_json = response["keys"] + params_list = json.loads(keys_json) + + assert isinstance(params_list, list) + assert len(params_list) > 0 + + # Check structure of first item + first_param = params_list[0] + assert "key" in first_param + assert "type" in first_param + assert "default_value" in first_param + + if "_extra" in first_param: + assert isinstance(first_param["_extra"], dict) + assert "default" not in first_param["_extra"] + assert "type" not in first_param["_extra"] + + # Load the source of truth + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Verify that the API response matches the metadata file for a few sample keys + # This ensures the plumbing is working without being brittle to content changes + + # 1. Check a key that should have metadata + keys_with_metadata = [k for k in params_list if k["key"] in metadata] + assert len(keys_with_metadata) > 0, "No parameters found that match metadata keys" + + for param in keys_with_metadata[:5]: # Check first 5 matches + key = param["key"] + expected_meta = metadata[key] + + assert "_extra" in param, f"Parameter {key} should have _extra field" + actual_meta = param["_extra"] + + # Verify all fields in JSON are present in the API response + for meta_key, meta_val in expected_meta.items(): + assert meta_key in actual_meta, f"Missing {meta_key} in API response for {key}" + assert actual_meta[meta_key] == meta_val, f"Mismatch for {key}.{meta_key}: expected {meta_val}, got {actual_meta[meta_key]}" + + # 2. Check that we are correctly serving options if they exist + params_with_options = [k for k in keys_with_metadata if "options" in k.get("_extra", {})] + if params_with_options: + param = params_with_options[0] + key = param["key"] + assert isinstance(param["_extra"]["options"], list), f"Options for {key} should be a list" + assert param["_extra"]["options"] == metadata[key]["options"] + + # 3. Check that we are correctly serving numeric constraints if they exist + params_with_constraints = [k for k in keys_with_metadata if "min" in k.get("_extra", {})] + if params_with_constraints: + param = params_with_constraints[0] + key = param["key"] + assert param["_extra"]["min"] == metadata[key]["min"] + assert param["_extra"]["max"] == metadata[key]["max"] + assert param["_extra"]["step"] == metadata[key]["step"] diff --git a/sunnypilot/sunnylink/tests/test_params_sync.py b/sunnypilot/sunnylink/tests/test_params_sync.py new file mode 100644 index 000000000..26bdca42d --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_params_sync.py @@ -0,0 +1,202 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os +import pytest + +from openpilot.common.params import Params +from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import METADATA_PATH + + +def test_metadata_json_exists(): + """ + Test that the params_metadata.json file exists at the expected path. + + Why: + The metadata file is the source of truth for parameter descriptions, options, and constraints. + If it's missing, the UI will not be able to display rich information for parameters. + + Expected: + The file should exist at sunnypilot/sunnylink/params_metadata.json. + """ + assert os.path.exists(METADATA_PATH), f"Metadata file not found at {METADATA_PATH}" + + +def test_metadata_json_valid(): + """ + Test that the params_metadata.json file contains valid JSON. + + Why: + Invalid JSON will cause the metadata loading to fail, potentially crashing the UI or + resulting in missing metadata. + + Expected: + The file content should be parseable as a JSON object (dictionary). + """ + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + pytest.fail("Metadata file is not valid JSON") + + assert isinstance(data, dict), "Metadata root must be a dictionary" + + +def test_all_params_have_metadata(): + """ + Test that every parameter in the codebase has a corresponding entry in params_metadata.json. + + Why: + We want to ensure 100% coverage of parameter metadata. Any parameter added to the codebase + should also be documented in the metadata file. + + Expected: + There should be no parameters in Params() that are missing from the metadata file. + If this fails, run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py'. + """ + params = Params() + all_keys = [k.decode('utf-8') for k in params.all_keys()] + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + missing_keys = [key for key in all_keys if key not in metadata] + + if missing_keys: + pytest.fail( + f"The following parameters are missing from metadata: {missing_keys}. " + + "Please run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py' to update." + ) + + +def test_metadata_keys_exist_in_params(): + """ + Test that all keys in params_metadata.json actually exist in the codebase. + + Why: + We want to avoid stale metadata for parameters that have been removed or renamed. + This keeps the metadata file clean and relevant. + + Expected: + There should be no keys in the metadata file that are not present in Params(). + This prints a warning rather than failing, as it's less critical than missing metadata. + """ + params = Params() + all_keys = {k.decode('utf-8') for k in params.all_keys()} + + with open(METADATA_PATH) as f: + metadata = json.load(f) + + extra_keys = [key for key in metadata.keys() if key not in all_keys] + + if extra_keys: + print(f"Warning: The following keys in metadata do not exist in Params: {extra_keys}") + + +def test_no_default_titles(): + """ + Test that no parameter has a title that is identical to its key. + + Why: + The default behavior of the update script is to set the title equal to the key. + We want to force developers to provide human-readable, descriptive titles for all parameters. + + Expected: + No parameter metadata should have 'title' == 'key'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + default_title_keys = [key for key, meta in metadata.items() if meta.get("title") == key] + + if default_title_keys: + pytest.fail( + f"The following parameters have default titles (title == key): {default_title_keys}. " + + "Please update 'params_metadata.json' with descriptive titles." + ) + + +def test_options_structure(): + """ + Test that the 'options' field in metadata follows the correct structure. + + Why: + The UI expects 'options' to be a list of objects with 'value' and 'label' keys. + Incorrect structure will break the UI rendering for dropdowns/toggles. + + Expected: + If 'options' is present, it must be a list of dicts, and each dict must have 'value' and 'label'. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "options" in meta: + options = meta["options"] + assert isinstance(options, list), f"Options for {key} must be a list" + for option in options: + assert isinstance(option, dict), f"Option in {key} must be a dictionary" + assert "value" in option, f"Option in {key} must have a 'value' key" + assert "label" in option, f"Option in {key} must have a 'label' key" + + +def test_numeric_constraints(): + """ + Test that numeric parameters have valid 'min', 'max', and 'step' constraints. + + Why: + The UI uses these constraints to validate user input and render sliders/steppers. + Missing or invalid constraints can lead to UI bugs or invalid parameter values. + + Expected: + If any of min/max/step is present, ALL of them must be present. + They must be numbers (int/float), and min must be less than max. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + for key, meta in metadata.items(): + if "min" in meta or "max" in meta or "step" in meta: + assert "min" in meta, f"Numeric param {key} must have 'min'" + assert "max" in meta, f"Numeric param {key} must have 'max'" + assert "step" in meta, f"Numeric param {key} must have 'step'" + + assert isinstance(meta["min"], (int, float)), f"Min for {key} must be number" + assert isinstance(meta["max"], (int, float)), f"Max for {key} must be number" + assert isinstance(meta["step"], (int, float)), f"Step for {key} must be number" + assert meta["min"] < meta["max"], f"Min must be less than max for {key}" + + +def test_known_params_metadata(): + """ + Test specific known parameters to ensure they have the expected rich metadata. + + Why: + This acts as a spot check to ensure that our rich metadata population logic is working correctly + and that critical parameters (like LongitudinalPersonality) have their options and constraints preserved. + + Expected: + 'LongitudinalPersonality' should have 3 options (Aggressive, Standard, Relaxed). + 'CustomAccLongPressIncrement' should have min=1, max=10, step=1. + """ + with open(METADATA_PATH) as f: + metadata = json.load(f) + + # Check an enum-like param + lp = metadata.get("LongitudinalPersonality") + assert lp is not None + assert "options" in lp + assert len(lp["options"]) == 3 + assert lp["options"][0]["label"] == "Aggressive" + assert lp["options"][0]["value"] == 0 + + # Check a numeric param + acc_long = metadata.get("CustomAccLongPressIncrement") + assert acc_long is not None + assert acc_long["min"] == 1 + assert acc_long["max"] == 10 + assert acc_long["step"] == 1 diff --git a/sunnypilot/sunnylink/tools/update_params_metadata.py b/sunnypilot/sunnylink/tools/update_params_metadata.py new file mode 100755 index 000000000..ac2ef556e --- /dev/null +++ b/sunnypilot/sunnylink/tools/update_params_metadata.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import os + +from openpilot.common.params import Params + +METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json") + + +def main(): + params = Params() + all_keys = params.all_keys() + + if os.path.exists(METADATA_PATH): + with open(METADATA_PATH) as f: + try: + data = json.load(f) + except json.JSONDecodeError: + data = {} + else: + data = {} + + # Add new keys + for key in all_keys: + key_str = key.decode("utf-8") + if key_str not in data: + print(f"Adding new key: {key_str}") + data[key_str] = { + "title": key_str, + "description": "", + } + + # Remove deleted keys + # keys_to_remove = [k for k in data.keys() if k.encode("utf-8") not in all_keys] + # for k in keys_to_remove: + # print(f"Removing deleted key: {k}") + # del data[k] + + # Sort keys + sorted_data = dict(sorted(data.items())) + + with open(METADATA_PATH, "w") as f: + json.dump(sorted_data, f, indent=2) + f.write("\n") + + print(f"Updated {METADATA_PATH}") + + +if __name__ == "__main__": + main()