sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
commit
de77c89cf6
3995 files changed
+1260688
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import jwt
|
||||
from typing import cast
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from openpilot.common.api.base import BaseApi
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
|
||||
API_HOST = os.getenv('SUNNYLINK_API_HOST', 'https://stg.api.sunnypilot.ai')
|
||||
UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice"
|
||||
MAX_RETRIES = 6
|
||||
CRASH_LOG_DIR = Paths.crash_log_root()
|
||||
|
||||
|
||||
class SunnylinkApi(BaseApi):
|
||||
def __init__(self, dongle_id):
|
||||
super().__init__(dongle_id, API_HOST)
|
||||
self.user_agent = "sunnypilot-"
|
||||
self.spinner = None
|
||||
self.params = Params()
|
||||
|
||||
def api_get(self, endpoint, method='GET', timeout=10, access_token=None, session=None, json=None, **kwargs):
|
||||
if not self.params.get_bool("SunnylinkEnabled"):
|
||||
return None
|
||||
|
||||
return super().api_get(endpoint, method, timeout, access_token, session, json, **kwargs)
|
||||
|
||||
def get_token(self, payload_extra=None, expiry_hours=1):
|
||||
# Add your additional data here
|
||||
additional_data = {}
|
||||
return super()._get_token(payload_extra, expiry_hours, **additional_data)
|
||||
|
||||
def _status_update(self, message):
|
||||
print(message)
|
||||
if self.spinner:
|
||||
self.spinner.update(message)
|
||||
time.sleep(0.5)
|
||||
|
||||
def _resolve_dongle_ids(self):
|
||||
sunnylink_dongle_id = self.params.get("SunnylinkDongleId")
|
||||
comma_dongle_id = self.dongle_id or self.params.get("DongleId")
|
||||
return sunnylink_dongle_id, comma_dongle_id
|
||||
|
||||
def _resolve_imeis(self):
|
||||
imei = None
|
||||
imei_try = 0
|
||||
while imei is None and imei_try < MAX_RETRIES:
|
||||
try:
|
||||
imei = HARDWARE.get_imei()
|
||||
except Exception:
|
||||
self._status_update(f"Error getting imei, trying again... [{imei_try + 1}/{MAX_RETRIES}]")
|
||||
time.sleep(1)
|
||||
imei_try += 1
|
||||
return imei, ""
|
||||
|
||||
def _resolve_serial(self):
|
||||
return (self.params.get("HardwareSerial")
|
||||
or HARDWARE.get_serial())
|
||||
|
||||
def register_device(self, spinner=None, timeout=60, verbose=False):
|
||||
self.spinner = spinner
|
||||
|
||||
sunnylink_dongle_id, comma_dongle_id = self._resolve_dongle_ids()
|
||||
|
||||
if comma_dongle_id is None:
|
||||
self._status_update("Comma dongle ID not found, deferring sunnylink's registration to comma's registration process.")
|
||||
return None
|
||||
|
||||
imei1, imei2 = self._resolve_imeis()
|
||||
serial = self._resolve_serial()
|
||||
|
||||
if sunnylink_dongle_id not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID):
|
||||
return sunnylink_dongle_id
|
||||
|
||||
jwt_algo, private_key, public_key = BaseApi.get_key_pair()
|
||||
|
||||
start_time = time.monotonic()
|
||||
successful_registration = False
|
||||
if not public_key:
|
||||
sunnylink_dongle_id = UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
self._status_update("Public key not found, setting dongle ID to unregistered.")
|
||||
else:
|
||||
Params().put("LastSunnylinkPingTime", 0, block=True) # Reset the last ping time to 0 if we are trying to register
|
||||
|
||||
backoff = 1
|
||||
while True:
|
||||
register_token = jwt.encode({'register': True, 'exp': datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)},
|
||||
cast(str, private_key), algorithm=jwt_algo)
|
||||
try:
|
||||
if verbose or time.monotonic() - start_time < timeout / 2:
|
||||
self._status_update("Registering device to sunnylink...")
|
||||
elif time.monotonic() - start_time >= timeout / 2:
|
||||
self._status_update("Still registering device to sunnylink...")
|
||||
|
||||
resp = self.api_get("v2/pilotauth/", method='POST', timeout=15, imei=imei1, imei2=imei2, serial=serial,
|
||||
comma_dongle_id=comma_dongle_id, public_key=public_key, register_token=register_token)
|
||||
|
||||
if resp is None:
|
||||
raise Exception("Unable to register device, request was None")
|
||||
|
||||
if resp.status_code in (409, 412):
|
||||
timeout = time.monotonic() - start_time # Don't retry if the public key is already in use
|
||||
key_in_use = "Public key is already in use, is your key unique? Contact your vendor for a new key."
|
||||
unsafe_key = "Public key is known to not be unique and it's unsafe. Contact your vendor for a new key."
|
||||
error_message = key_in_use if resp.status_code == 409 else unsafe_key
|
||||
raise Exception(error_message)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Failed to register with sunnylink. Status code: {resp.status_code}\nData\n:{resp.text}")
|
||||
|
||||
dongleauth = json.loads(resp.text)
|
||||
sunnylink_dongle_id = dongleauth["device_id"]
|
||||
if sunnylink_dongle_id:
|
||||
self._status_update("Device registered successfully.")
|
||||
successful_registration = True
|
||||
break
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
self._status_update(f"Waiting {backoff}s before retry, Exception occurred during registration: [{str(e)}]")
|
||||
|
||||
if not os.path.exists(CRASH_LOG_DIR):
|
||||
os.makedirs(CRASH_LOG_DIR)
|
||||
|
||||
with open(f'{CRASH_LOG_DIR}/error.txt', 'a') as f:
|
||||
f.write(f"[{datetime.now()}] sunnylink: {str(e)}\n")
|
||||
|
||||
backoff = min(backoff * 2 * (0.5 + random.random()), 60)
|
||||
time.sleep(backoff)
|
||||
|
||||
if time.monotonic() - start_time > timeout:
|
||||
self._status_update(f"Giving up on sunnylink's registration after {timeout}s. Will retry on next boot.")
|
||||
time.sleep(3)
|
||||
break
|
||||
|
||||
self.params.put("SunnylinkDongleId", sunnylink_dongle_id or UNREGISTERED_SUNNYLINK_DONGLE_ID, block=True)
|
||||
|
||||
# Set the last ping time to the current time since we were just talking to the API
|
||||
last_ping = int((time.monotonic() if successful_registration else start_time) * 1e9)
|
||||
Params().put("LastSunnylinkPingTime", last_ping, block=True)
|
||||
|
||||
# Disable sunnylink if registration was not successful
|
||||
if not successful_registration:
|
||||
Params().put_bool("SunnylinkEnabled", False, block=True)
|
||||
|
||||
self.spinner = None
|
||||
return sunnylink_dongle_id
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.system.athena.manage_athenad import manage_athenad
|
||||
|
||||
if __name__ == '__main__':
|
||||
manage_athenad("SunnylinkDongleId", "SunnylinkdPid", 'sunnylinkd', 'openpilot.sunnypilot.sunnylink.athena.sunnylinkd')
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
#!/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
|
||||
import errno
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
|
||||
from functools import partial
|
||||
from openpilot.system.athena.rpc import dispatcher
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.realtime import set_core_affinity
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.system.athena.athenad import ws_send, jsonrpc_handler, \
|
||||
recv_queue, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage, log_handler, start_local_proxy_shim, upload_handler, stat_handler
|
||||
from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException,
|
||||
create_connection, WebSocketConnectionClosedException)
|
||||
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL
|
||||
from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
|
||||
|
||||
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
|
||||
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
|
||||
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()
|
||||
|
||||
params = Params()
|
||||
|
||||
# Parameters that should never be remotely modified
|
||||
BLOCKED_PARAMS = {
|
||||
"AdbEnabled",
|
||||
"CompletedSunnylinkConsentVersion",
|
||||
"CompletedTrainingVersion",
|
||||
"GithubUsername", # Could grant SSH access
|
||||
"GithubSshKeys", # Direct SSH key injection
|
||||
"HasAcceptedTerms",
|
||||
"HasAcceptedTermsSP",
|
||||
"OnroadCycleRequested", # Prevent remote cycle trigger
|
||||
"ParamsVersion", # Device-managed version counter
|
||||
}
|
||||
|
||||
|
||||
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
|
||||
cloudlog.info("sunnylinkd.handle_long_poll started")
|
||||
sm = messaging.SubMaster(['deviceState'])
|
||||
end_event = threading.Event()
|
||||
comma_prime_cellular_end_event = threading.Event()
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
|
||||
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
|
||||
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
|
||||
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
|
||||
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
|
||||
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
|
||||
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
|
||||
] + [
|
||||
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
|
||||
for x in range(HANDLER_THREADS)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
try:
|
||||
while not end_event.wait(0.1):
|
||||
if not sunnylink_ready(params):
|
||||
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
|
||||
break
|
||||
|
||||
sm.update(0)
|
||||
if exit_event is not None and exit_event.is_set():
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
|
||||
prime_type = params.get("PrimeType") or 0
|
||||
metered = sm['deviceState'].networkMetered
|
||||
|
||||
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
|
||||
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif metered and int(prime_type) > 2:
|
||||
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
|
||||
cloudlog.debug(
|
||||
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.clear()
|
||||
finally:
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
for thread in threads:
|
||||
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
|
||||
thread.join()
|
||||
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
|
||||
|
||||
|
||||
def ws_recv(ws: WebSocket, end_event: threading.Event) -> None:
|
||||
last_ping = int(time.monotonic() * 1e9)
|
||||
while not end_event.is_set():
|
||||
try:
|
||||
opcode, data = ws.recv_data(control_frame=True)
|
||||
if opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
|
||||
if opcode == ABNF.OPCODE_TEXT:
|
||||
data = data.decode("utf-8")
|
||||
recv_queue.put_nowait(data)
|
||||
cloudlog.debug(f"sunnylinkd.ws_recv.recv {data}")
|
||||
elif opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG):
|
||||
cloudlog.debug("sunnylinkd.ws_recv.pong")
|
||||
last_ping = int(time.monotonic() * 1e9)
|
||||
Params().put("LastSunnylinkPingTime", last_ping, block=True)
|
||||
except WebSocketTimeoutException:
|
||||
ns_since_last_ping = int(time.monotonic() * 1e9) - last_ping
|
||||
if ns_since_last_ping > SUNNYLINK_RECONNECT_TIMEOUT_S * 1e9:
|
||||
cloudlog.warning("sunnylinkd.ws_recv.timeout")
|
||||
end_event.set()
|
||||
except Exception as e:
|
||||
if isinstance(e, WebSocketConnectionClosedException):
|
||||
cloudlog.warning(f"sunnylinkd.ws_recv.{type(e).__name__}")
|
||||
else:
|
||||
cloudlog.exception("sunnylinkd.ws_recv.exception")
|
||||
end_event.set()
|
||||
|
||||
|
||||
def ws_ping(ws: WebSocket, end_event: threading.Event) -> None:
|
||||
ws.ping() # Send the first ping
|
||||
while not end_event.wait(SUNNYLINK_RECONNECT_TIMEOUT_S * 0.7): # Sleep about 70% before a timeout
|
||||
try:
|
||||
ws.ping()
|
||||
cloudlog.debug("sunnylinkd.ws_recv.ws_ping: Pinging")
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.ws_ping.exception")
|
||||
end_event.set()
|
||||
cloudlog.debug("sunnylinkd.ws_ping.end_event is set, exiting ws_ping thread")
|
||||
|
||||
|
||||
def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event: threading.Event) -> None:
|
||||
while not end_event.wait(0.1):
|
||||
if not comma_prime_cellular_end_event.is_set():
|
||||
log_handler(comma_prime_cellular_end_event, SUNNYLINK_LOG_ATTR_NAME)
|
||||
comma_prime_cellular_end_event.set()
|
||||
|
||||
|
||||
@dispatcher.add_method
|
||||
def toggleLogUpload(enabled: bool):
|
||||
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
|
||||
|
||||
|
||||
@dispatcher.add_method
|
||||
def getParamsAllKeys() -> list[str]:
|
||||
keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
return keys
|
||||
|
||||
|
||||
@dispatcher.add_method
|
||||
def getParamsMetadata() -> str:
|
||||
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
|
||||
|
||||
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
|
||||
and returns. Single RPC for the frontend to get the complete settings UI and
|
||||
runtime capabilities.
|
||||
"""
|
||||
try:
|
||||
schema = generate_schema()
|
||||
schema["capabilities"] = generate_capabilities()
|
||||
schema["capability_labels"] = CAPABILITY_LABELS
|
||||
schema["default_model"] = DEFAULT_MODEL
|
||||
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
|
||||
raise
|
||||
|
||||
|
||||
@dispatcher.add_method
|
||||
def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
|
||||
params = Params()
|
||||
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
|
||||
try:
|
||||
zero_values: dict[int, bytes] = {
|
||||
ParamKeyType.STRING.value: b"",
|
||||
ParamKeyType.BOOL.value: b"0",
|
||||
ParamKeyType.INT.value: b"0",
|
||||
ParamKeyType.FLOAT.value: b"0.0",
|
||||
ParamKeyType.TIME.value: b"",
|
||||
ParamKeyType.JSON.value: b"{}",
|
||||
ParamKeyType.BYTES.value: b"",
|
||||
}
|
||||
|
||||
param_keys_validated = [key for key in params_keys if key in available_keys]
|
||||
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
|
||||
for key in param_keys_validated:
|
||||
value = get_param_as_byte(key)
|
||||
if value is None:
|
||||
value = get_param_as_byte(key, get_default=True)
|
||||
if value is None:
|
||||
param_type = params.get_type(key)
|
||||
value = zero_values.get(param_type.value, b"")
|
||||
|
||||
params_dict["params"].append({
|
||||
"key": key,
|
||||
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
|
||||
"type": int(params.get_type(key).value),
|
||||
"is_compressed": compression
|
||||
})
|
||||
|
||||
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
|
||||
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception("sunnylinkd.getParams.exception", e)
|
||||
raise
|
||||
|
||||
|
||||
@dispatcher.add_method
|
||||
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
|
||||
for key, value in params_to_update.items():
|
||||
# disallow modifications to blocked parameters
|
||||
if key in BLOCKED_PARAMS:
|
||||
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
|
||||
continue
|
||||
|
||||
try:
|
||||
save_param_from_base64_encoded_string(key, value, compression)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
|
||||
|
||||
# Increment version counter for frontend change detection
|
||||
try:
|
||||
current = int(params.get("ParamsVersion") or "0")
|
||||
params.put("ParamsVersion", str(current + 1), block=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
|
||||
sunnylink_dongle_id = params.get("SunnylinkDongleId")
|
||||
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
return start_local_proxy_shim(global_end_event, local_port, ws)
|
||||
|
||||
|
||||
def main(exit_event: threading.Event | None = None):
|
||||
try:
|
||||
set_core_affinity([0, 1, 2, 3])
|
||||
except Exception:
|
||||
cloudlog.exception("failed to set core affinity")
|
||||
|
||||
while sunnylink_need_register(params):
|
||||
cloudlog.info("Waiting for sunnylink registration to complete")
|
||||
time.sleep(10)
|
||||
|
||||
sunnylink_dongle_id = params.get("SunnylinkDongleId")
|
||||
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
|
||||
UploadQueueCache.initialize(upload_queue)
|
||||
|
||||
update_car_list_param()
|
||||
|
||||
ws_uri = f"{SUNNYLINK_ATHENA_HOST}"
|
||||
conn_start = None
|
||||
conn_retries = 0
|
||||
while (exit_event is None or not exit_event.is_set()) and sunnylink_ready(params):
|
||||
try:
|
||||
if conn_start is None:
|
||||
conn_start = time.monotonic()
|
||||
|
||||
cloudlog.event("sunnylinkd.main.connecting_ws", ws_uri=ws_uri, retries=conn_retries)
|
||||
ws = create_connection(
|
||||
ws_uri,
|
||||
header={"Authorization": f"Bearer {sunnylink_api.get_token()}"},
|
||||
enable_multithread=True,
|
||||
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)
|
||||
conn_start = None
|
||||
|
||||
conn_retries = 0
|
||||
cur_upload_items.clear()
|
||||
|
||||
handle_long_poll(ws, exit_event)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
break
|
||||
except Exception as e:
|
||||
conn_retries += 1
|
||||
params.remove("LastSunnylinkPingTime")
|
||||
|
||||
if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)):
|
||||
cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}")
|
||||
elif isinstance(e, OSError):
|
||||
name = errno.errorcode.get(e.errno or -1, "UNKNOWN")
|
||||
msg = f"sunnylinkd.main.OSError.{name} ({e.errno})"
|
||||
is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH)
|
||||
cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg)
|
||||
else:
|
||||
cloudlog.exception("sunnylinkd.main.exception")
|
||||
|
||||
time.sleep(backoff(conn_retries))
|
||||
|
||||
if not sunnylink_ready(params):
|
||||
cloudlog.debug("Reached end of sunnylinkd.main while sunnylink is not ready. Waiting 60s before retrying")
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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 openpilot.sunnypilot.sunnylink.athena import sunnylinkd
|
||||
|
||||
|
||||
class TestSunnylinkdMethods:
|
||||
def setup_method(self):
|
||||
self.saved_params = []
|
||||
|
||||
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
|
||||
|
||||
def mock_save_param(key, value, compression=False):
|
||||
self.saved_params.append((key, value, compression))
|
||||
|
||||
sunnylinkd.save_param_from_base64_encoded_string = mock_save_param
|
||||
|
||||
def teardown_method(self):
|
||||
sunnylinkd.save_param_from_base64_encoded_string = self.original_save
|
||||
|
||||
def test_saveParams_blocked(self):
|
||||
blocked_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"GithubSshKeys": "ssh-rsa attacker_key",
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(blocked_params)
|
||||
|
||||
assert len(self.saved_params) == 0
|
||||
|
||||
def test_saveParams_allowed(self):
|
||||
allowed_params = {
|
||||
"SpeedLimitOffset": "5",
|
||||
"MyCustomParam": "123"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(allowed_params)
|
||||
|
||||
# verify content
|
||||
assert len(self.saved_params) == 2
|
||||
keys_saved = [p[0] for p in self.saved_params]
|
||||
assert "SpeedLimitOffset" in keys_saved
|
||||
assert "MyCustomParam" in keys_saved
|
||||
|
||||
def test_saveParams_mixed(self):
|
||||
mixed_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"SpeedLimitOffset": "10"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(mixed_params)
|
||||
|
||||
# should save allowed one
|
||||
assert len(self.saved_params) == 1
|
||||
assert self.saved_params[0][0] == "SpeedLimitOffset"
|
||||
assert self.saved_params[0][1] == "10"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
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 Crypto.Cipher import AES
|
||||
|
||||
|
||||
class AESCipher:
|
||||
def __init__(self, key: bytes, iv: bytes):
|
||||
if len(key) not in (16, 32):
|
||||
raise ValueError("Key must be 16 bytes (AES-128) or 32 bytes (AES-256).")
|
||||
if len(iv) != 16:
|
||||
raise ValueError("IV must be 16 bytes.")
|
||||
|
||||
self.key = key
|
||||
self.iv = iv
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
block_size = 16
|
||||
padding_length = block_size - (len(data) % block_size)
|
||||
padding = bytes([padding_length]) * padding_length
|
||||
padded_data = data + padding
|
||||
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
|
||||
return cipher.encrypt(padded_data)
|
||||
|
||||
def decrypt(self, encrypted_data: bytes) -> bytes:
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
|
||||
decrypted_data = cipher.decrypt(encrypted_data)
|
||||
padding_length = decrypted_data[-1]
|
||||
return decrypted_data[:-padding_length]
|
||||
Whitespace-only changes.
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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 base64
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from openpilot.common.git import get_branch
|
||||
from openpilot.common.params import Params, ParamKeyFlag
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.version import get_version
|
||||
|
||||
from openpilot.cereal import messaging, custom
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder
|
||||
from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string
|
||||
|
||||
|
||||
class OperationType(Enum):
|
||||
BACKUP = "backup"
|
||||
RESTORE = "restore"
|
||||
|
||||
|
||||
class BackupManagerSP:
|
||||
"""Manages device configuration backups to/from sunnylink"""
|
||||
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.device_id = self.params.get("SunnylinkDongleId")
|
||||
self.api = SunnylinkApi(self.device_id)
|
||||
self.pm = messaging.PubMaster(["backupManagerSP"])
|
||||
|
||||
# Status tracking
|
||||
self.backup_status = custom.BackupManagerSP.Status.idle
|
||||
self.restore_status = custom.BackupManagerSP.Status.idle
|
||||
|
||||
# Unified progress & operation type (only one operation runs at a time)
|
||||
self.progress = 0.0
|
||||
self.operation: OperationType | None = None
|
||||
|
||||
self.last_error = ""
|
||||
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
|
||||
|
||||
def _report_status(self) -> None:
|
||||
"""Reports current backup manager state through the messaging system."""
|
||||
msg = messaging.new_message('backupManagerSP', valid=True)
|
||||
backup_state = msg.backupManagerSP
|
||||
|
||||
backup_state.backupStatus = self.backup_status
|
||||
backup_state.restoreStatus = self.restore_status
|
||||
# Both progress fields use the unified progress value
|
||||
backup_state.backupProgress = self.progress
|
||||
backup_state.restoreProgress = self.progress
|
||||
backup_state.lastError = self.last_error
|
||||
|
||||
# Optionally, add a field for operation type if supported:
|
||||
# backup_state.operationType = self.operation.value if self.operation else "none"
|
||||
|
||||
self.pm.send('backupManagerSP', msg)
|
||||
|
||||
def _update_progress(self, progress: float, op_type: OperationType) -> None:
|
||||
"""Updates the unified progress and operation type, then reports status."""
|
||||
self.progress = progress
|
||||
self.operation = op_type
|
||||
self._report_status()
|
||||
|
||||
def _collect_config_data(self) -> dict[str, Any]:
|
||||
"""Collects configuration data to be backed up."""
|
||||
config_data = {}
|
||||
params_to_backup = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)]
|
||||
for param in params_to_backup:
|
||||
value = get_param_as_byte(param)
|
||||
if value is not None:
|
||||
config_data[param] = base64.b64encode(value).decode('utf-8')
|
||||
return config_data
|
||||
|
||||
def _get_metadata_value(self, metadata_list, key, default_value=None):
|
||||
return next((entry.get("value") for entry in metadata_list if entry.get("key") == key), default_value)
|
||||
|
||||
async def create_backup(self) -> bool:
|
||||
"""Creates and uploads a new backup to sunnylink."""
|
||||
try:
|
||||
self.backup_status = custom.BackupManagerSP.Status.inProgress
|
||||
self._update_progress(0.0, OperationType.BACKUP)
|
||||
|
||||
# Collect configuration data
|
||||
config_data = self._collect_config_data()
|
||||
self._update_progress(25.0, OperationType.BACKUP)
|
||||
|
||||
# Serialize and encrypt config data
|
||||
config_json = json.dumps(config_data)
|
||||
encrypted_config = encrypt_compressed_data(config_json, use_aes_256=True)
|
||||
self._update_progress(50.0, OperationType.BACKUP)
|
||||
|
||||
backup_info = custom.BackupManagerSP.BackupInfo()
|
||||
backup_info.deviceId = self.device_id
|
||||
backup_info.config = encrypted_config
|
||||
backup_info.isEncrypted = True
|
||||
backup_info.createdAt = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
||||
backup_info.updatedAt = backup_info.createdAt
|
||||
backup_info.sunnypilotVersion = self._get_current_version()
|
||||
backup_info.backupMetadata = [
|
||||
custom.BackupManagerSP.MetadataEntry(key="creator", value="BackupManagerSP"),
|
||||
custom.BackupManagerSP.MetadataEntry(key="all_values_encoded", value="True"),
|
||||
custom.BackupManagerSP.MetadataEntry(key="AES", value="256")
|
||||
]
|
||||
|
||||
payload = json.loads(json.dumps(backup_info.to_dict(), cls=SnakeCaseEncoder))
|
||||
self._update_progress(75.0, OperationType.BACKUP)
|
||||
|
||||
cloudlog.debug(f"Uploading backup with payload: {json.dumps(payload)}")
|
||||
# Upload to sunnylink
|
||||
result = self.api.api_get(
|
||||
f"backup/{self.device_id}",
|
||||
method='PUT',
|
||||
access_token=self.api.get_token(),
|
||||
json=payload,
|
||||
session=self._session
|
||||
)
|
||||
|
||||
if result:
|
||||
self.backup_status = custom.BackupManagerSP.Status.completed
|
||||
self._update_progress(100.0, OperationType.BACKUP)
|
||||
cloudlog.info("Backup successfully created and uploaded")
|
||||
else:
|
||||
self.backup_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = "Failed to upload backup"
|
||||
cloudlog.error(result)
|
||||
self._report_status()
|
||||
|
||||
return bool(self.backup_status == custom.BackupManagerSP.Status.completed)
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error creating backup: {str(e)}")
|
||||
self.backup_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
return False
|
||||
|
||||
async def restore_backup(self, version: int | None = None) -> bool:
|
||||
"""Restores a backup from sunnylink."""
|
||||
try:
|
||||
self.restore_status = custom.BackupManagerSP.Status.inProgress
|
||||
self._update_progress(0.0, OperationType.RESTORE)
|
||||
|
||||
# Get backup data from API for the specified version
|
||||
endpoint = f"backup/{self.device_id}" + f"/{version or ''}" + "?api-version=1"
|
||||
backup_data = self.api.api_get(endpoint, access_token=self.api.get_token(), session=self._session)
|
||||
if not backup_data:
|
||||
raise Exception(f"No backup found for device {self.device_id}")
|
||||
|
||||
self._update_progress(25.0, OperationType.RESTORE)
|
||||
|
||||
data = backup_data.json()
|
||||
backup_metadata = data.get("backup_metadata", [])
|
||||
encrypted_config = data.get("config", "")
|
||||
if not encrypted_config:
|
||||
raise Exception("Empty backup configuration")
|
||||
self._update_progress(50.0, OperationType.RESTORE)
|
||||
|
||||
# Decrypt config and load data
|
||||
use_aes_256 = self._get_metadata_value(backup_metadata, "AES", "128") == "256"
|
||||
config_json = decrypt_compressed_data(encrypted_config, use_aes_256)
|
||||
if not config_json:
|
||||
raise Exception("Failed to decrypt backup configuration")
|
||||
|
||||
config_data = json.loads(config_json)
|
||||
self._update_progress(75.0, OperationType.RESTORE)
|
||||
|
||||
# Apply configuration
|
||||
self._apply_config(config_data)
|
||||
|
||||
self.restore_status = custom.BackupManagerSP.Status.completed
|
||||
self._update_progress(100.0, OperationType.RESTORE)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error restoring backup: {str(e)}")
|
||||
self.restore_status = custom.BackupManagerSP.Status.failed
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
return False
|
||||
|
||||
def _apply_config(self, config_data: dict[str, str]) -> None:
|
||||
"""Applies configuration data from a backup, but only for parameters marked as backupable."""
|
||||
backupable_params = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)]
|
||||
backupable_set_lower = {p.lower() for p in backupable_params}
|
||||
|
||||
restored_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for param, encoded_value in config_data.items():
|
||||
if param.lower() in backupable_set_lower:
|
||||
# Find real param name (with correct casing)
|
||||
real_param = next(p for p in backupable_params if p.lower() == param.lower())
|
||||
try:
|
||||
save_param_from_base64_encoded_string(real_param, encoded_value)
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to restore param {param}: {str(e)}")
|
||||
else:
|
||||
skipped_count += 1
|
||||
cloudlog.info(f"Skipped restoring param {param}: not marked for backup in current version")
|
||||
|
||||
cloudlog.info(f"Restore complete: {restored_count} params restored, {skipped_count} params skipped")
|
||||
|
||||
def _get_current_version(self) -> custom.BackupManagerSP.Version:
|
||||
"""Gets current sunnypilot version information."""
|
||||
version_obj = custom.BackupManagerSP.Version()
|
||||
version_str = get_version()
|
||||
|
||||
version_parts = version_str.split('-') # For when version is like "1.2.3-456"
|
||||
version_nums = version_parts[0].split('.')
|
||||
|
||||
# Extract build number from hyphen format or as 4th version component
|
||||
build = 0
|
||||
if len(version_parts) > 1 and version_parts[1].isdigit():
|
||||
build = int(version_parts[1])
|
||||
elif len(version_nums) > 3 and version_nums[3].isdigit():
|
||||
build = int(version_nums[3])
|
||||
|
||||
# Set version components with safer defaults
|
||||
version_obj.major = int(version_nums[0]) if len(version_nums) > 0 and version_nums[0].isdigit() else 0
|
||||
version_obj.minor = int(version_nums[1]) if len(version_nums) > 1 and version_nums[1].isdigit() else 0
|
||||
version_obj.patch = int(version_nums[2]) if len(version_nums) > 2 and version_nums[2].isdigit() else 0
|
||||
version_obj.build = build
|
||||
version_obj.branch = get_branch()
|
||||
|
||||
return version_obj
|
||||
|
||||
async def main_thread(self) -> None:
|
||||
"""Main thread for backup management."""
|
||||
rk = Ratekeeper(1, print_delay_threshold=None)
|
||||
reset_progress = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
if reset_progress:
|
||||
self.progress = 100.0
|
||||
self.operation = None
|
||||
self.restore_status = custom.BackupManagerSP.Status.idle
|
||||
self.backup_status = custom.BackupManagerSP.Status.idle
|
||||
|
||||
# Check for backup command
|
||||
if self.params.get_bool("BackupManager_CreateBackup"):
|
||||
try:
|
||||
if await self.create_backup():
|
||||
reset_progress = True
|
||||
finally:
|
||||
self.params.remove("BackupManager_CreateBackup")
|
||||
|
||||
# Check for restore command
|
||||
restore_version = self.params.get("BackupManager_RestoreVersion")
|
||||
if restore_version:
|
||||
try:
|
||||
version = int(restore_version) if restore_version.isdigit() else None
|
||||
await self.restore_backup(version)
|
||||
reset_progress = True
|
||||
finally:
|
||||
self.params.remove("BackupManager_RestoreVersion")
|
||||
|
||||
self._report_status()
|
||||
rk.keep_time()
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error in backup manager main thread: {str(e)}")
|
||||
self.last_error = str(e)
|
||||
self._report_status()
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
def main():
|
||||
import asyncio
|
||||
asyncio.run(BackupManagerSP().main_thread())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
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 base64
|
||||
import hashlib
|
||||
import os
|
||||
import zlib
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, ec
|
||||
|
||||
from openpilot.common.api.base import KEYS
|
||||
from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
|
||||
|
||||
class KeyDerivation:
|
||||
@staticmethod
|
||||
def _load_key(file_path: str) -> bytes:
|
||||
with open(file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def derive_aes_key_iv(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
|
||||
key_pem: bytes = KeyDerivation._load_key(key_path)
|
||||
key_plain = key_pem.decode(errors="ignore")
|
||||
|
||||
if "private" in key_plain.lower():
|
||||
private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend())
|
||||
if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
|
||||
public_key = private_key.public_key()
|
||||
else:
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
elif "public" in key_plain.lower():
|
||||
public_key = serialization.load_pem_public_key(key_pem, backend=default_backend())
|
||||
if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
else:
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
|
||||
if isinstance(public_key, rsa.RSAPublicKey):
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
|
||||
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
else:
|
||||
raise ValueError("Unsupported key type.")
|
||||
|
||||
if use_aes_256:
|
||||
# AES-256-CBC
|
||||
key = hashlib.sha256(der_data).digest()
|
||||
iv = hashlib.md5(der_data).digest()
|
||||
else:
|
||||
# AES-128-CBC
|
||||
key = hashlib.md5(der_data).digest()
|
||||
iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility
|
||||
|
||||
return key, iv
|
||||
|
||||
|
||||
def uncompress_dat(data):
|
||||
"""
|
||||
Decompress data using zlib.
|
||||
|
||||
Args:
|
||||
data (bytes): Compressed data
|
||||
|
||||
Returns:
|
||||
bytes: Decompressed data
|
||||
"""
|
||||
data_stripped_4 = data[4:]
|
||||
return zlib.decompress(data_stripped_4)
|
||||
|
||||
|
||||
def compress_dat(data):
|
||||
"""
|
||||
Compress data using zlib.
|
||||
|
||||
Args:
|
||||
data (bytes): Data to compress
|
||||
|
||||
Returns:
|
||||
bytes: Compressed data
|
||||
"""
|
||||
compressed_data = zlib.compress(data, level=9)
|
||||
return b"ZLIB" + compressed_data
|
||||
|
||||
|
||||
def get_key_path(use_aes_256=False) -> str:
|
||||
key_path = ""
|
||||
for key in KEYS:
|
||||
if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'):
|
||||
key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub'))
|
||||
break
|
||||
|
||||
if not key_path:
|
||||
raise FileNotFoundError("No valid key pair found in persist storage.")
|
||||
|
||||
return key_path
|
||||
|
||||
|
||||
def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
|
||||
"""
|
||||
Decrypt and decompress data from base64 string.
|
||||
|
||||
Args:
|
||||
encrypted_base64 (str): Base64 encoded encrypted data
|
||||
key_path (str, optional): Path to RSA public key
|
||||
|
||||
Returns:
|
||||
str: Decrypted and decompressed string
|
||||
"""
|
||||
try:
|
||||
# Decode base64
|
||||
encrypted_data = base64.b64decode(encrypted_base64)
|
||||
|
||||
# Decrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
decrypted_data = cipher.decrypt(encrypted_data)
|
||||
|
||||
# Decompress
|
||||
decompressed_data = uncompress_dat(decrypted_data)
|
||||
|
||||
# Decode UTF-8
|
||||
result = decompressed_data.decode('utf-8')
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Decryption and decompression failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def encrypt_compressed_data(text, use_aes_256=True):
|
||||
"""
|
||||
Compress and encrypt string data to base64.
|
||||
|
||||
Args:
|
||||
text (str): Text to compress and encrypt
|
||||
key_path (str, optional): Path to RSA public key
|
||||
|
||||
Returns:
|
||||
str: Base64 encoded encrypted data
|
||||
"""
|
||||
try:
|
||||
# Encode to UTF-8
|
||||
text_bytes = text.encode('utf-8')
|
||||
|
||||
# Compress
|
||||
compressed_data = compress_dat(text_bytes)
|
||||
|
||||
# Encrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
encrypted_data = cipher.encrypt(compressed_data)
|
||||
|
||||
# Encode to base64
|
||||
result = base64.b64encode(encrypted_data).decode('utf-8')
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Compression and encryption failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def camel_to_snake(name):
|
||||
"""Convert camelCase to snake_case."""
|
||||
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
|
||||
|
||||
|
||||
def transform_dict(obj):
|
||||
"""Recursively transform dictionary keys from camelCase to snake_case."""
|
||||
if isinstance(obj, dict):
|
||||
return {camel_to_snake(k): transform_dict(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [transform_dict(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class SnakeCaseEncoder(json.JSONEncoder):
|
||||
def encode(self, obj):
|
||||
transformed_obj = transform_dict(obj)
|
||||
return super().encode(transformed_obj)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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.cereal import custom, messaging
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
|
||||
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
|
||||
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
|
||||
|
||||
# Wire-protocol version for the capabilities payload. Bump on breaking changes
|
||||
# only; additive fields are backward-compatible and do not require a bump.
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
# All capability fields that rules may reference.
|
||||
# Non-boolean fields must have defaults in CAPABILITY_DEFAULTS.
|
||||
CAPABILITY_FIELDS = (
|
||||
"protocol_version",
|
||||
"has_longitudinal_control",
|
||||
"has_icbm",
|
||||
"icbm_available",
|
||||
"torque_allowed",
|
||||
"brand",
|
||||
"pcm_cruise",
|
||||
"alpha_long_available",
|
||||
"steer_control_type",
|
||||
"enable_bsm",
|
||||
"is_release",
|
||||
"is_sp_release",
|
||||
"is_development",
|
||||
"tesla_has_vehicle_bus",
|
||||
"has_stop_and_go",
|
||||
"stock_longitudinal",
|
||||
"device_type",
|
||||
"subaru_has_sng",
|
||||
"hyundai_alpha_long_available",
|
||||
)
|
||||
|
||||
CAPABILITY_LABELS: dict[str, str] = {
|
||||
"protocol_version": "Capabilities protocol version",
|
||||
"has_longitudinal_control": "sunnypilot longitudinal control",
|
||||
"has_icbm": "ICBM enabled",
|
||||
"icbm_available": "ICBM available",
|
||||
"torque_allowed": "torque steering (not available for angle steering vehicles)",
|
||||
"brand": "Vehicle brand",
|
||||
"pcm_cruise": "PCM cruise",
|
||||
"alpha_long_available": "Alpha Longitudinal available",
|
||||
"steer_control_type": "Steer control type",
|
||||
"enable_bsm": "BSM available",
|
||||
"is_release": "Release branch",
|
||||
"is_sp_release": "SP release branch",
|
||||
"is_development": "Development branch",
|
||||
"tesla_has_vehicle_bus": "Tesla vehicle bus",
|
||||
"has_stop_and_go": "Stop and Go",
|
||||
"stock_longitudinal": "stock longitudinal",
|
||||
"device_type": "Device type",
|
||||
"subaru_has_sng": "Subaru Stop-and-Go available",
|
||||
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
|
||||
}
|
||||
|
||||
# Explicit defaults for non-boolean capability fields
|
||||
CAPABILITY_DEFAULTS: dict[str, bool | str | int] = {
|
||||
"brand": "",
|
||||
"steer_control_type": "",
|
||||
"device_type": "",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _bundle_field(bundle: dict | None, key: str) -> str:
|
||||
return bundle.get(key, "") if isinstance(bundle, dict) else ""
|
||||
|
||||
|
||||
def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
|
||||
"""Set brand-specific capabilities from bundle platform or CarParams fallback.
|
||||
|
||||
Bundle (manual car selection) is a pre-fingerprint approximation.
|
||||
CarParams (auto-fingerprint) is the authoritative post-fingerprint source.
|
||||
Mirrors the per-brand update_settings() logic in device UI layouts.
|
||||
"""
|
||||
brand = caps["brand"]
|
||||
|
||||
if brand == "hyundai":
|
||||
if bundle_platform:
|
||||
try:
|
||||
unsupported = set().union(*UNSUPPORTED_LONGITUDINAL_CAR.values())
|
||||
caps["hyundai_alpha_long_available"] = HYUNDAI_CAR[bundle_platform] not in unsupported
|
||||
except KeyError:
|
||||
cloudlog.exception(f"capabilities: unknown hyundai platform {bundle_platform!r}")
|
||||
elif CP is not None:
|
||||
caps["hyundai_alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
|
||||
|
||||
elif brand == "subaru":
|
||||
if bundle_platform:
|
||||
try:
|
||||
flags = SUBARU_CAR[bundle_platform].config.flags
|
||||
caps["subaru_has_sng"] = not bool(flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
caps["has_stop_and_go"] = caps["subaru_has_sng"]
|
||||
except KeyError:
|
||||
cloudlog.exception(f"capabilities: unknown subaru platform {bundle_platform!r}")
|
||||
elif CP is not None:
|
||||
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
caps["has_stop_and_go"] = caps["subaru_has_sng"]
|
||||
|
||||
|
||||
def generate_capabilities(params: Params | None = None) -> dict:
|
||||
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
|
||||
|
||||
When CarPlatformBundle is present, brand and platform come from the bundle
|
||||
(mirrors Raylib). CarParams* deserialization is the fallback before the bundle
|
||||
is written (early after first pairing).
|
||||
"""
|
||||
params = params or Params()
|
||||
|
||||
caps: dict = {field: CAPABILITY_DEFAULTS.get(field, False) for field in CAPABILITY_FIELDS}
|
||||
|
||||
# Wire-protocol version is always set explicitly.
|
||||
caps["protocol_version"] = PROTOCOL_VERSION
|
||||
|
||||
# Hardware + boolean params (no CarParams dependency)
|
||||
caps["device_type"] = HARDWARE.get_device_type()
|
||||
caps["is_release"] = False # params.get_bool("IsReleaseBranch")
|
||||
caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch")
|
||||
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
|
||||
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
|
||||
|
||||
bundle = params.get("CarPlatformBundle")
|
||||
bundle_brand = _bundle_field(bundle, "brand")
|
||||
bundle_platform = _bundle_field(bundle, "platform")
|
||||
|
||||
# Bundle-first brand resolution; CP is fallback only.
|
||||
if bundle_brand:
|
||||
caps["brand"] = bundle_brand
|
||||
|
||||
# CarParams-derived capabilities
|
||||
CP = None
|
||||
CP_bytes = params.get("CarParamsPersistent")
|
||||
if CP_bytes is not None:
|
||||
try:
|
||||
CP = messaging.log_from_bytes(CP_bytes, car.CarParams)
|
||||
caps["alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
|
||||
if CP.alphaLongitudinalAvailable:
|
||||
caps["has_longitudinal_control"] = params.get_bool("AlphaLongitudinalEnabled")
|
||||
else:
|
||||
caps["has_longitudinal_control"] = bool(CP.openpilotLongitudinalControl)
|
||||
# CP.steerControlType is the physical control mode (angle / torque).
|
||||
# CP.lateralTuning.which() returns the tuning class (pid / torque / indi)
|
||||
# which is a separate concept and is not interchangeable.
|
||||
caps["steer_control_type"] = str(CP.steerControlType)
|
||||
caps["torque_allowed"] = CP.steerControlType != car.CarParams.SteerControlType.angle
|
||||
if not caps["brand"] and CP.brand:
|
||||
caps["brand"] = str(CP.brand)
|
||||
caps["pcm_cruise"] = bool(CP.pcmCruise)
|
||||
caps["enable_bsm"] = bool(CP.enableBsm)
|
||||
# Generic SnG fallback. Brand-specific opaque flags below override.
|
||||
caps["has_stop_and_go"] = bool(CP.openpilotLongitudinalControl)
|
||||
except Exception:
|
||||
CP = None
|
||||
cloudlog.exception("capabilities: failed to deserialize CarParamsPersistent")
|
||||
|
||||
# CarParamsSP-derived capabilities
|
||||
CP_SP_bytes = params.get("CarParamsSPPersistent")
|
||||
if CP_SP_bytes is not None:
|
||||
try:
|
||||
CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
|
||||
caps["icbm_available"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable)
|
||||
caps["has_icbm"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable) and params.get_bool("IntelligentCruiseButtonManagement")
|
||||
caps["tesla_has_vehicle_bus"] = bool(CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS)
|
||||
except Exception:
|
||||
cloudlog.exception("capabilities: failed to deserialize CarParamsSPPersistent")
|
||||
|
||||
_resolve_brand_capabilities(caps, bundle_platform, CP)
|
||||
|
||||
return caps
|
||||
|
||||
|
||||
def generate_capabilities_json(params: Params | None = None) -> str:
|
||||
"""Generate SettingsCapabilities as a JSON string."""
|
||||
return json.dumps(generate_capabilities(params), separators=(",", ":"))
|
||||
@@ -0,0 +1,585 @@
|
||||
# sunnylink Settings UI Guide
|
||||
|
||||
> One YAML file per page. Edit, run the compiler, commit. The sunnylink frontend updates automatically.
|
||||
|
||||
## What you edit (and what's generated)
|
||||
|
||||
| File | What | When to edit |
|
||||
|------|------|-------------|
|
||||
| `settings_ui_src/pages/<page>.yaml` | One YAML per page (panel). Contains panel metadata + sections + items + sub_panels inline. | Adding/changing/removing a setting. |
|
||||
| `settings_ui_src/pages/vehicle.yaml` | Per-brand settings page (`kind: vehicle`). Each brand is a section. | Adding/changing a vehicle-specific setting. |
|
||||
| `settings_ui_src/_macros.yaml` | Named rule fragments referenced via `{$ref: "#/macros/<name>"}`. | Adding a reusable rule (e.g. a new platform gate). |
|
||||
| **`settings_ui.json`** | **Generated from src tree by `compile_settings_ui.py`. Do not edit by hand.** | Never. Compiler emits it; frontend reads it. |
|
||||
|
||||
Pages today: `steering, cruise, display, visuals, toggles, device, software, developer, models, vehicle` (10).
|
||||
|
||||
Run `python sunnypilot/sunnylink/tools/compile_settings_ui.py` after edits. Add `--check` in CI to fail on out-of-sync `settings_ui.json`.
|
||||
|
||||
Display metadata (titles, descriptions, options, min/max/step/unit) is inline on each item. There is no separate metadata file.
|
||||
|
||||
## Page file shape
|
||||
|
||||
A page YAML contains the whole panel: metadata at the top, then `sections`. Each section has its own `items` and (optionally) `sub_panels`. Sub-panels are nested inside the section they belong to. Items appear in the order written in the file.
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=../_schemas/page.schema.json
|
||||
id: steering
|
||||
label: Steering
|
||||
icon: steering_wheel
|
||||
order: 1
|
||||
remote_configurable: true
|
||||
description: Lateral control, lane changes, and steering behavior
|
||||
|
||||
sections:
|
||||
- id: mads
|
||||
title: Modular Assistive Driving System (MADS)
|
||||
items:
|
||||
- key: Mads
|
||||
widget: toggle
|
||||
title: Enable Modular Assistive Driving System (MADS)
|
||||
description: |
|
||||
Enable the beloved MADS feature. Disable toggle to revert back
|
||||
to stock sunnypilot engagement/disengagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
|
||||
sub_panels:
|
||||
- id: mads_settings
|
||||
label: MADS Settings
|
||||
trigger_key: Mads
|
||||
trigger_condition: {type: param, key: Mads, equals: true}
|
||||
items:
|
||||
- key: MadsMainCruiseAllowed
|
||||
widget: toggle
|
||||
title: Toggle with Main Cruise
|
||||
description: |
|
||||
Note: For vehicles without LFA/LKAS button, disabling this will
|
||||
prevent lateral control engagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The vehicle page has the same shape but declares `kind: vehicle`; each section's `id` becomes a brand key under `vehicle_settings` in the compiled JSON.
|
||||
|
||||
## Macros (named rule fragments)
|
||||
|
||||
`_macros.yaml` declares reusable rule lists. Reference them from any rules array via `{$ref: "#/macros/<name>"}`.
|
||||
|
||||
```yaml
|
||||
macros:
|
||||
offroad: [{type: offroad_only}]
|
||||
longitudinal: [{type: capability, field: has_longitudinal_control, equals: true}]
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
```
|
||||
|
||||
In an item:
|
||||
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The compiler splices a list-context `$ref` into its parent list. Macros may reference other macros up to depth 3; cycles are an error.
|
||||
|
||||
## Compiler workflow
|
||||
|
||||
```
|
||||
1. common/params_keys.h — add/remove the C++ param key
|
||||
2. settings_ui_src/pages/<page>.yaml — add/edit/remove the item in the right section
|
||||
3. python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
4. python sunnypilot/sunnylink/tools/validate_settings_ui.py (or: --check on the compiler)
|
||||
5. uv run python -m pytest sunnypilot/sunnylink/tests/ # run regression + compiler tests
|
||||
6. commit
|
||||
```
|
||||
|
||||
CI runs `compile_settings_ui.py --check` to fail on hand-edited `settings_ui.json`.
|
||||
|
||||
## Compiled output reference (schema contract)
|
||||
|
||||
The tables below describe the **compiled** `settings_ui.json` schema — what the frontend consumes at runtime. JSON snippets show the wire shape; in the src tree you author YAML that compiles to the same shape. Use these as a contract reference for valid fields, their meanings, and rule types.
|
||||
|
||||
## Quick reference: widget types
|
||||
|
||||
| Widget | Use for | Fields needed |
|
||||
|--------|---------|---------------|
|
||||
| `toggle` | On/off boolean | `title` |
|
||||
| `multiple_button` | 2-4 discrete options | `title` + `options` array |
|
||||
| `option` | Numeric range or dropdown | `title` + `min/max/step` or `options` |
|
||||
| `info` | Read-only display | `title` |
|
||||
|
||||
## Quick reference: item fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `key` | Yes | Param key name (must exist in `params_keys.h`) |
|
||||
| `widget` | Yes | `toggle`, `option`, `multiple_button`, `button`, `info` |
|
||||
| `title` | Yes | Display name shown to the user |
|
||||
| `description` | No | Inline explanatory text below the title. May be empty when only `details` is used. |
|
||||
| `details` | No | Extended help text shown in a modal when the user taps an "i" button on the row. Independent of `description`: either, both, or neither may be present. |
|
||||
| `options` | For selectors | Array of `{"value": 0, "label": "Off"}` objects (see per-option enablement below) |
|
||||
| `min`, `max`, `step` | For sliders | Numeric range constraints |
|
||||
| `unit` | No | Unit label. Static: `"seconds"`. Dynamic: `{"metric": "km/h", "imperial": "mph"}` (resolved by IsMetric) |
|
||||
| `visibility` | No | Rules for show/hide. Settings are never hidden, always dimmed with UNAVAILABLE badge when rules fail |
|
||||
| `enablement` | No | Rules for enabled/disabled (all must pass). Dimmed with badge when rules fail |
|
||||
| `blocked` | No | `true` for device-only settings that cannot be modified remotely. Frontend shows as read-only |
|
||||
| `title_param_suffix` | No | Dynamic title suffix. Example: `{"param": "IsMetric", "values": {"0": "mph", "1": "km/h"}}` |
|
||||
| `sub_items` | No | Nested child items |
|
||||
| `needs_onroad_cycle` | No | `true` if changing this param triggers a system restart. Frontend shows a "Restart" badge. See [REFERENCE.md - Remote Onroad Cycle](REFERENCE.md#remote-onroad-cycle) |
|
||||
|
||||
## Quick reference: rule types
|
||||
|
||||
| Rule | Example | Use for |
|
||||
|------|---------|---------|
|
||||
| `offroad_only` | `{"type": "offroad_only"}` | Grey out while driving |
|
||||
| `not_engaged` | `{"type": "not_engaged"}` | Grey out only while engaged (started + selfdrive/MADS active) |
|
||||
| `capability` | `{"type": "capability", "field": "has_longitudinal_control", "equals": true}` | Car-dependent visibility |
|
||||
| `param` | `{"type": "param", "key": "Mads", "equals": true}` | Show/enable based on another setting |
|
||||
| `param_compare` | `{"type": "param_compare", "key": "SpeedLimitMode", "op": ">", "value": 0}` | Numeric comparison |
|
||||
| `not` | `{"type": "not", "condition": {...}}` | Negate a rule |
|
||||
| `any` | `{"type": "any", "conditions": [...]}` | OR logic |
|
||||
| `all` | `{"type": "all", "conditions": [...]}` | AND logic (for nesting inside `any`/`not`) |
|
||||
| `$ref` | `{"$ref": "#/macros/offroad"}` | Reference a named rule fragment in `_macros.yaml` |
|
||||
|
||||
**Visibility design**: Settings are always visible. When visibility rules fail, the setting is dimmed with an UNAVAILABLE badge, so users know it exists but is not applicable.
|
||||
|
||||
**Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why.
|
||||
|
||||
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `enable_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`
|
||||
|
||||
---
|
||||
|
||||
## How to
|
||||
|
||||
### Pick a writability rule (offroad / not_engaged / param-based)
|
||||
|
||||
| Use this | When | Why |
|
||||
|---|---|---|
|
||||
| `offroad_only` | Param can only be safely changed when the car is parked. Most user-facing toggles. | Strictest. Frontend shows "device is driving" badge and disables the row. |
|
||||
| `not_engaged` | Param can be changed while the car is started but only when sunnypilot/MADS is **not** actively driving. | Less strict than offroad. Matches Raylib `engaged = started AND (selfdriveState.enabled OR mads.enabled)`. Use for items the device must apply mid-drive (e.g. test maneuvers, longitudinal stock-vs-OP toggle). |
|
||||
| `param`-based | Behavior depends on another setting's value (parent toggle, mode selector, etc.). | Composes with `not`/`any`/`all` for arbitrary logic. |
|
||||
| `capability`-based | Behavior depends on the connected car or device (brand, longitudinal, hardware). | Resolved on the device from `CarParams` / hardware. See [`capabilities.py`](../capabilities.py) for the full field list. |
|
||||
| (no rule) | Param is always writable, no gating. | Rare. Prefer at least `offroad_only` unless the param is genuinely safe to flip mid-drive. |
|
||||
|
||||
Default for new toggles: `enablement: [{$ref: "#/macros/offroad"}]`. Drop down to `not_engaged` only if you've confirmed mid-drive write is safe in the controls/UI code path.
|
||||
|
||||
### Use `details` for safety notes / extended help
|
||||
|
||||
Inline `description` shows under the title. For longer caveats, safety notes, or "learn more" content, use `details` — the frontend renders an info button that opens a modal. Either field may be present alone or both together.
|
||||
|
||||
```yaml
|
||||
- key: AutoLaneChangeTimer
|
||||
widget: option
|
||||
title: Auto Lane Change by Blinker
|
||||
description: |-
|
||||
Set a timer to delay the auto lane change operation when the blinker is used.
|
||||
No nudge on the steering wheel is required to auto lane change if a timer is set.
|
||||
Default is Nudge.
|
||||
details: |-
|
||||
Please use caution when using this feature. Only use the blinker when traffic
|
||||
and road conditions permit.
|
||||
options: [...]
|
||||
```
|
||||
|
||||
For an item that is intentionally minimal inline (no inline body, only the modal):
|
||||
|
||||
```yaml
|
||||
- key: SomeAdvancedToggle
|
||||
widget: toggle
|
||||
title: Some Advanced Feature
|
||||
details: |-
|
||||
Long-form rationale, caveats, links, etc. — kept entirely behind the info button.
|
||||
```
|
||||
|
||||
### Add a toggle
|
||||
|
||||
1. Register in `common/params_keys.h`:
|
||||
```cpp
|
||||
{"MyToggle", {PERSISTENT | BACKUP, BOOL}},
|
||||
```
|
||||
|
||||
2. Open `settings_ui_src/pages/<page>.yaml`. Add the item to the right section:
|
||||
```yaml
|
||||
- key: MyToggle
|
||||
widget: toggle
|
||||
title: My Feature
|
||||
description: What this feature does.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
If changing the param requires an onroad cycle to take effect, add `needs_onroad_cycle: true`.
|
||||
|
||||
3. Compile + validate + test:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
### Add a multi-button option
|
||||
|
||||
```yaml
|
||||
- key: MySelector
|
||||
widget: multiple_button
|
||||
title: Mode
|
||||
options:
|
||||
- {value: 0, label: Off}
|
||||
- {value: 1, label: On}
|
||||
- {value: 2, label: Auto}
|
||||
```
|
||||
|
||||
### Add a slider or range
|
||||
|
||||
```yaml
|
||||
- key: MyRange
|
||||
widget: option
|
||||
title: Follow Distance
|
||||
description: Time gap to lead vehicle.
|
||||
min: 0.5
|
||||
max: 3.0
|
||||
step: 0.1
|
||||
unit: seconds
|
||||
```
|
||||
|
||||
### Add a slider with metric/imperial units
|
||||
|
||||
```yaml
|
||||
- key: MinSpeed
|
||||
widget: option
|
||||
title: Minimum Speed
|
||||
min: 0
|
||||
max: 100
|
||||
step: 5
|
||||
unit: {metric: km/h, imperial: mph}
|
||||
```
|
||||
|
||||
Frontend resolves the unit string based on the device's `IsMetric` param. Static units (e.g. `seconds`, `m/s²`) stay plain strings.
|
||||
|
||||
### Add a dynamic title suffix
|
||||
|
||||
```yaml
|
||||
- key: FollowDistance
|
||||
widget: option
|
||||
title: Follow Distance
|
||||
title_param_suffix:
|
||||
param: IsMetric
|
||||
values: {'0': mph, '1': km/h}
|
||||
min: 0.5
|
||||
max: 3.0
|
||||
step: 0.1
|
||||
```
|
||||
|
||||
Renders as "Follow Distance: mph" / "Follow Distance: km/h".
|
||||
|
||||
### Add a device-only read-only setting
|
||||
|
||||
```yaml
|
||||
- key: OnroadCyclePendingRemote
|
||||
widget: info
|
||||
title: Pending Remote Cycle
|
||||
blocked: true
|
||||
```
|
||||
|
||||
Frontend treats `blocked: true` items as read-only.
|
||||
|
||||
### Add a dropdown option
|
||||
|
||||
```yaml
|
||||
- key: MyDropdown
|
||||
widget: option
|
||||
title: Recording Quality
|
||||
options:
|
||||
- {value: 0, label: Low (720p)}
|
||||
- {value: 1, label: Medium (1080p)}
|
||||
- {value: 2, label: High (4K)}
|
||||
```
|
||||
|
||||
### Per-option enablement rules
|
||||
|
||||
```yaml
|
||||
- key: MadsSteeringMode
|
||||
widget: multiple_button
|
||||
title: Steering Mode on Brake Pedal
|
||||
options:
|
||||
- value: 0
|
||||
label: Remain Active
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 1
|
||||
label: Pause
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 2
|
||||
label: Disengage
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
When an option's enablement fails, that option is grayed out but still visible.
|
||||
|
||||
### Show only when another setting is on
|
||||
|
||||
```yaml
|
||||
- key: ChildSetting
|
||||
widget: toggle
|
||||
title: Child Feature
|
||||
visibility:
|
||||
- {type: param, key: ParentToggle, equals: true}
|
||||
```
|
||||
|
||||
(With the "dim instead of hide" design, this setting is dimmed, not hidden, when the rule fails.)
|
||||
|
||||
### Show only for specific brands
|
||||
|
||||
```yaml
|
||||
- key: LongFeature
|
||||
widget: toggle
|
||||
title: Longitudinal Feature
|
||||
visibility:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
```
|
||||
|
||||
### Combine multiple conditions
|
||||
|
||||
The `enablement` array is implicit-AND: every entry must pass. Use `any` for OR, `all` for nested AND, `not` for negation. Wrap repeated combinations in a macro so future you doesn't re-derive the logic.
|
||||
|
||||
**AND across two params** (writable only when both Mads is on AND ICBM is enabled):
|
||||
```yaml
|
||||
enablement:
|
||||
- {type: param, key: Mads, equals: true}
|
||||
- {type: param, key: IntelligentCruiseButtonManagement, equals: true}
|
||||
```
|
||||
|
||||
**OR across two params** (writable when either is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: param, key: ExperimentalMode, equals: true}
|
||||
- {type: param, key: DynamicExperimentalControl, equals: true}
|
||||
```
|
||||
|
||||
**Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
**Three-way: offroad AND torque-allowed AND not-NNLC** (real example: `EnforceTorqueControl`):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
- {type: param, key: NeuralNetworkLateralControl, equals: false}
|
||||
```
|
||||
|
||||
**Negation across multiple platforms** (everything except Rivian + Tesla-no-bus):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"} # macro encapsulates the not(any(rivian, all(tesla, not(bus)))) logic
|
||||
```
|
||||
|
||||
If the same multi-condition block appears in 2+ items, **promote it to a macro** in `_macros.yaml`. Re-run `python sunnypilot/sunnylink/tools/apply_macros.py` to substitute existing inlined matches automatically.
|
||||
|
||||
### Mutual exclusion
|
||||
|
||||
```yaml
|
||||
- key: FeatureAlpha
|
||||
widget: toggle
|
||||
title: Feature Alpha
|
||||
enablement:
|
||||
- {type: param, key: FeatureBeta, equals: false}
|
||||
|
||||
- key: FeatureBeta
|
||||
widget: toggle
|
||||
title: Feature Beta
|
||||
enablement:
|
||||
- {type: param, key: FeatureAlpha, equals: false}
|
||||
```
|
||||
|
||||
### Add a section
|
||||
|
||||
In the page YAML, add an entry to the `sections` list:
|
||||
```yaml
|
||||
sections:
|
||||
- id: my_section
|
||||
title: My Section
|
||||
description: Optional subtitle
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
Sections support `visibility`, `enablement`, and `attestation_required`. When section-level rules fail, all items within are dimmed.
|
||||
|
||||
### Add a sub-panel
|
||||
|
||||
Sub-panels nest inside the section they belong to:
|
||||
```yaml
|
||||
sections:
|
||||
- id: parent_section
|
||||
title: Parent
|
||||
items: [...]
|
||||
sub_panels:
|
||||
- id: my_sub
|
||||
label: Advanced Settings
|
||||
trigger_key: ParentParam
|
||||
trigger_condition: {type: param, key: ParentParam, equals: true}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
### Add vehicle-brand settings
|
||||
|
||||
Edit `pages/vehicle.yaml`. Each section is a brand:
|
||||
```yaml
|
||||
id: vehicle
|
||||
kind: vehicle
|
||||
sections:
|
||||
- id: rivian
|
||||
title: Rivian Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: RivianFeature
|
||||
widget: toggle
|
||||
title: Rivian One Pedal
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
`kind: vehicle` tells the compiler to emit this page as `vehicle_settings.<brand>` in the wire JSON.
|
||||
|
||||
### Add a feature with toggles, sub-panel, and macro
|
||||
|
||||
Example: "Smart Wipers" with a master toggle, intensity selector, and sub-panel for advanced tuning, gated to torque-steering Hyundais on offroad.
|
||||
|
||||
1. **Param keys** — register all 4 in `common/params_keys.h`.
|
||||
|
||||
2. **Decide on a macro** — if "torque Hyundai" gating is reused, add to `_macros.yaml`:
|
||||
```yaml
|
||||
torque_hyundai:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: brand, equals: hyundai}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
```
|
||||
|
||||
3. **Edit the relevant page** — `pages/visuals.yaml` (or wherever the feature lives). Add a new section + sub_panel:
|
||||
```yaml
|
||||
sections:
|
||||
- id: smart_wipers
|
||||
title: Smart Wipers
|
||||
description: Camera-driven wiper control (Hyundai/Kia, torque only)
|
||||
items:
|
||||
- key: SmartWipersEnabled
|
||||
widget: toggle
|
||||
title: Enable Smart Wipers
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
- key: SmartWipersIntensity
|
||||
widget: multiple_button
|
||||
title: Sensitivity
|
||||
options:
|
||||
- {value: 0, label: Low}
|
||||
- {value: 1, label: Medium}
|
||||
- {value: 2, label: High}
|
||||
visibility:
|
||||
- {type: param, key: SmartWipersEnabled, equals: true}
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
sub_panels:
|
||||
- id: smart_wipers_tuning
|
||||
label: Smart Wipers Tuning
|
||||
trigger_key: SmartWipersEnabled
|
||||
trigger_condition: {type: param, key: SmartWipersEnabled, equals: true}
|
||||
items:
|
||||
- key: SmartWipersHysteresis
|
||||
widget: option
|
||||
title: Hysteresis (frames)
|
||||
min: 1
|
||||
max: 30
|
||||
step: 1
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
4. **Compile / validate / test**:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
`apply_macros.py` is automatic for newly-added items only if you wrote the rule list inline; for greenfield items, you'd write `$ref` directly.
|
||||
|
||||
### Change a toggle's behavior
|
||||
|
||||
1. Find the item in `pages/<page>.yaml`.
|
||||
2. Edit `visibility`/`enablement`/`options[].enablement` directly. Use macros where possible.
|
||||
3. **Add a regression test** in `sunnypilot/sunnylink/tests/test_settings_changes.py` that asserts the new gate exists. Use existing tests (e.g. `TestMadsBrandGates`, `TestNotEngagedReplacement`) as templates: lookup item by key, assert `_references_capability_field(rules, "...")` or `_flatten_rule_types(rules)` contains/excludes a type. This freezes the new behavior so a future edit won't silently revert it.
|
||||
4. Compile + run the full suite. Per-bug test should pass; structural tests should remain green.
|
||||
|
||||
### Change a widget type or options
|
||||
|
||||
Editing `widget:` from `toggle` to `multiple_button` is a frontend behavior change. Whenever you change widget shape:
|
||||
- The param's underlying type (bool / int / string) must match what the new widget writes. `toggle` writes bool; `multiple_button`/`option` write int/string. Update `params_keys.h` if the type changes.
|
||||
- Add an `options:` list when switching to `multiple_button` or `option`.
|
||||
- Old values stored on devices may not be valid for the new widget. Consider a migration in `sunnypilot/system/updated/` if users have stale values.
|
||||
|
||||
### Deprecate or remove a setting
|
||||
|
||||
1. Remove the item from `pages/<page>.yaml`.
|
||||
2. Remove the param key from `common/params_keys.h` **only after** confirming nothing in `selfdrive/`, `sunnypilot/`, or any controls code reads it.
|
||||
3. If the param has been on user devices, drop it via a migration (see `sunnypilot/system/updated/`) so stale values don't linger.
|
||||
4. Compile + validate + test. The validator's "no duplicate keys" + structural checks will fail if anything still references the removed key.
|
||||
|
||||
### Move a setting to another page
|
||||
|
||||
Cut the item block from one page YAML, paste into the target page's section. Compile + validate. The "no duplicate keys" check catches forgotten copies.
|
||||
|
||||
### Change display text
|
||||
|
||||
Edit `title:` or `description:` in the page YAML and recompile to regenerate `settings_ui.json`.
|
||||
|
||||
### Reorder sections, sub-panels, and items
|
||||
|
||||
Reorder them within their parent list in the YAML. The compiler preserves authored order — no `order:` field required at the section/sub_panel/item level (panel-level `order:` controls which page comes first in the side nav).
|
||||
|
||||
---
|
||||
|
||||
### Capability labels and tooltips
|
||||
|
||||
The schema response includes `capability_labels`, which map capability field names to descriptions. The frontend uses these to show contextual tooltips when a capability rule prevents a setting from being used.
|
||||
|
||||
The device defines these labels in `capabilities.py:CAPABILITY_LABELS`. Examples:
|
||||
|
||||
- `has_longitudinal_control` → "sunnypilot longitudinal control"
|
||||
- `torque_allowed` → "torque steering (not available for angle steering vehicles)"
|
||||
- `brand` → "Vehicle brand"
|
||||
|
||||
### Centralized param enforcement
|
||||
|
||||
The device-side UI enforces capability constraints in `selfdrive/ui/sunnypilot/ui_state.py:_enforce_constraints()`, which removes incompatible params based on car capabilities. This is the single source of truth for such constraints.
|
||||
|
||||
Settings layouts should not duplicate these params.remove() calls. Instead, rely on schema rules and centralized enforcement to prevent duplicate logic and ensure consistency.
|
||||
|
||||
Example constraints in `_enforce_constraints()`:
|
||||
- Angle steering cars: remove `EnforceTorqueControl` and `NeuralNetworkLateralControl`
|
||||
- No CarParams: remove all car-dependent params
|
||||
- No longitudinal: remove `ExperimentalMode`
|
||||
- No ICBM: remove `IntelligentCruiseButtonManagement`
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from openpilot.sunnypilot.sunnylink.utils import register_sunnylink
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
|
||||
def main():
|
||||
"""The main method is expected to be called by the manager when the device boots up."""
|
||||
try:
|
||||
rk = Ratekeeper(.5)
|
||||
sm = messaging.SubMaster(['deviceState'], poll='deviceState')
|
||||
while True:
|
||||
sm.update(1000)
|
||||
if sm['deviceState'].networkType != NetworkType.none:
|
||||
break
|
||||
|
||||
cloudlog.info(f"Waiting to become online... {time.monotonic()}")
|
||||
rk.keep_time()
|
||||
|
||||
register_sunnylink()
|
||||
except Exception:
|
||||
cloudlog.exception("Sunnylink registration failed")
|
||||
Params().put_bool("SunnylinkTempFault", True, block=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,516 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/settings_ui.schema.json",
|
||||
"title": "sunnypilot Settings UI Schema",
|
||||
"description": "Defines the structure of the sunnypilot settings UI panels, items, rules, and vehicle-specific settings.",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "panels", "vehicle_settings"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for editor support."
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"description": "Version of the settings UI schema format.",
|
||||
"examples": ["1.0"]
|
||||
},
|
||||
"panels": {
|
||||
"type": "array",
|
||||
"description": "Top-level settings panels displayed in the UI.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Panel"
|
||||
}
|
||||
},
|
||||
"vehicle_settings": {
|
||||
"type": "object",
|
||||
"description": "Brand-keyed vehicle-specific settings. Each key is a car brand (e.g. 'hyundai', 'toyota').",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/VehicleBrandSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Panel": {
|
||||
"type": "object",
|
||||
"description": "A top-level settings panel (tab) in the UI.",
|
||||
"required": ["id", "label", "icon", "order"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this panel."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Display label shown in the UI."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Icon identifier for this panel."
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort order for panel display.",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the panel label."
|
||||
},
|
||||
"remote_configurable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this panel's settings can be changed remotely via sunnylink.",
|
||||
"default": false
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"description": "Grouped sections within this panel.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/PanelSection"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items directly in this panel (no section grouping).",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"description": "Nested sub-panels triggered by a setting.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SubPanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PanelSection": {
|
||||
"type": "object",
|
||||
"description": "A grouped section within a panel.",
|
||||
"required": ["id", "title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this section."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title for this section."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the section title."
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort order within the parent panel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"visibility": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this section is visible. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether items in this section are enabled. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"attestation_required": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the UI must show an attestation modal before any write to items in this section.",
|
||||
"default": false
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items within this section.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"description": "Nested sub-panels within this section.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SubPanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VehicleBrandSettings": {
|
||||
"type": "object",
|
||||
"description": "Brand-specific settings group inside vehicle_settings.",
|
||||
"required": ["items"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title for this brand's settings group."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the brand title."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items for this brand.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SchemaItem": {
|
||||
"type": "object",
|
||||
"description": "A single settings item (toggle, option selector, button group, etc.).",
|
||||
"required": ["key", "widget"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key this item reads/writes."
|
||||
},
|
||||
"widget": {
|
||||
"type": "string",
|
||||
"description": "The UI widget type to render.",
|
||||
"enum": ["toggle", "option", "multiple_button", "button", "info"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Override display title (defaults to metadata lookup by key)."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Override description text. Rendered inline below the title. May be empty when only `details` is used."
|
||||
},
|
||||
"details": {
|
||||
"type": "string",
|
||||
"description": "Extended help text shown in a popover/modal when the user taps an info ('i') button on the row. Independent of `description`: either, both, or neither may be present."
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "Available options for 'option' or 'multiple_button' widgets.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaOption"
|
||||
}
|
||||
},
|
||||
"min": {
|
||||
"type": "number",
|
||||
"description": "Minimum value for numeric option widgets."
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"description": "Maximum value for numeric option widgets."
|
||||
},
|
||||
"step": {
|
||||
"type": "number",
|
||||
"description": "Step increment for numeric option widgets."
|
||||
},
|
||||
"unit": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Static unit label (e.g. 'seconds', 'm/s²')."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Dynamic unit that changes based on IsMetric param.",
|
||||
"required": ["metric", "imperial"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"description": "Unit label when IsMetric is true (e.g. 'km/h')."
|
||||
},
|
||||
"imperial": {
|
||||
"type": "string",
|
||||
"description": "Unit label when IsMetric is false (e.g. 'mph')."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Unit label for numeric values. Use a string for static units or an object with metric/imperial variants for units that depend on the IsMetric param."
|
||||
},
|
||||
"value_map": {
|
||||
"type": "object",
|
||||
"description": "Maps stored values to display labels.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this item is visible. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this item is enabled/interactive. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"sub_items": {
|
||||
"type": "array",
|
||||
"description": "Child items nested under this item (e.g. options revealed by a toggle).",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action identifier for button widgets."
|
||||
},
|
||||
"title_param_suffix": {
|
||||
"type": "object",
|
||||
"description": "Renders an extra suffix in the item title chosen by the value of another param.",
|
||||
"required": ["param", "values"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"param": {
|
||||
"type": "string",
|
||||
"description": "Param key whose value selects the suffix label."
|
||||
},
|
||||
"values": {
|
||||
"type": "object",
|
||||
"description": "Map from stringified param value to suffix label.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"needs_onroad_cycle": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the device must cycle onroad/offroad for the new value to take effect.",
|
||||
"default": false
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "When true, this item is treated as DEVICE_ONLY and the dashboard must not write it remotely.",
|
||||
"default": false
|
||||
},
|
||||
"requires_attestation": {
|
||||
"type": "boolean",
|
||||
"description": "When true, writes to this item require an explicit per-write confirmation modal.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubPanel": {
|
||||
"type": "object",
|
||||
"description": "A nested panel that opens when triggered by a parent item.",
|
||||
"required": ["id", "label", "trigger_key"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this sub-panel."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Display label for the sub-panel header."
|
||||
},
|
||||
"trigger_key": {
|
||||
"type": "string",
|
||||
"description": "The param key that triggers opening this sub-panel."
|
||||
},
|
||||
"trigger_condition": {
|
||||
"$ref": "#/$defs/Rule",
|
||||
"description": "Optional rule that must evaluate to true for the sub-panel trigger to be active."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items within this sub-panel.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SchemaOption": {
|
||||
"type": "object",
|
||||
"description": "A selectable option for option/multiple_button widgets.",
|
||||
"required": ["value", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {
|
||||
"oneOf": [
|
||||
{ "type": "number" },
|
||||
{ "type": "string" }
|
||||
],
|
||||
"description": "The stored value when this option is selected."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "The display label for this option."
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this option is selectable. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rule": {
|
||||
"description": "A visibility or enablement rule. Discriminated union on the 'type' field.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/RuleOffroadOnly" },
|
||||
{ "$ref": "#/$defs/RuleNotEngaged" },
|
||||
{ "$ref": "#/$defs/RuleCapability" },
|
||||
{ "$ref": "#/$defs/RuleParam" },
|
||||
{ "$ref": "#/$defs/RuleParamCompare" },
|
||||
{ "$ref": "#/$defs/RuleNot" },
|
||||
{ "$ref": "#/$defs/RuleAny" },
|
||||
{ "$ref": "#/$defs/RuleAll" }
|
||||
]
|
||||
},
|
||||
"RuleOffroadOnly": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes only when the device is offroad.",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "offroad_only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleNotEngaged": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes when the vehicle is not engaged (matches Raylib `engaged = started AND (selfdriveState.enabled OR selfdriveStateSP.mads.enabled)`).",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "not_engaged"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleCapability": {
|
||||
"type": "object",
|
||||
"description": "Rule that checks a vehicle capability field against an expected value.",
|
||||
"required": ["type", "field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "capability"
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "The capability field name to check."
|
||||
},
|
||||
"equals": {
|
||||
"description": "The expected value to match against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleParam": {
|
||||
"type": "object",
|
||||
"description": "Rule that checks a param value against an expected value.",
|
||||
"required": ["type", "key", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "param"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key to read."
|
||||
},
|
||||
"equals": {
|
||||
"description": "The expected value to match against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleParamCompare": {
|
||||
"type": "object",
|
||||
"description": "Rule that compares a numeric param value using a comparison operator.",
|
||||
"required": ["type", "key", "op", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "param_compare"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key to read."
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"description": "Comparison operator.",
|
||||
"enum": [">", "<", ">=", "<="]
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"description": "The numeric value to compare against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleNot": {
|
||||
"type": "object",
|
||||
"description": "Rule that negates a single child condition.",
|
||||
"required": ["type", "condition"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "not"
|
||||
},
|
||||
"condition": {
|
||||
"$ref": "#/$defs/Rule",
|
||||
"description": "The rule to negate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleAny": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes if ANY of the child conditions pass (logical OR).",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "any"
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Child rules; at least one must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleAll": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes only if ALL child conditions pass (logical AND).",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "all"
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Child rules; all must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Named rule fragments. Reference from items/sections via {$ref: "#/macros/<name>"}.
|
||||
# Macros may $ref other macros (max depth 3 — see compile_settings_ui.py). No template logic.
|
||||
#
|
||||
# Adding a macro: define here once, then reference everywhere. The compiler
|
||||
# resolves $refs into the canonical settings_ui.json output the frontend reads.
|
||||
|
||||
macros:
|
||||
# Most-used: only writable when the device is offroad.
|
||||
offroad:
|
||||
- {type: offroad_only}
|
||||
|
||||
# Writable while not engaged (started, but selfdrive/MADS not active).
|
||||
not_engaged:
|
||||
- {type: not_engaged}
|
||||
|
||||
# sunnypilot longitudinal control is active.
|
||||
longitudinal:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
|
||||
# Longitudinal + ICBM both available.
|
||||
longitudinal_and_icbm:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
- {type: capability, field: has_icbm, equals: true}
|
||||
|
||||
# Item only meaningful when "Show Advanced Controls" is enabled by the user.
|
||||
advanced_only:
|
||||
- {type: param, key: ShowAdvancedControls, equals: true}
|
||||
|
||||
# Hide on MICI hardware (no analog HUD support yet).
|
||||
hide_on_mici:
|
||||
- type: not
|
||||
condition: {type: capability, field: device_type, equals: mici}
|
||||
|
||||
# Mirrors selfdrive/ui/sunnypilot/layouts/.../mads_settings.py:_mads_limited_settings()
|
||||
# Rivian + Tesla-without-vehicle-bus get the limited MADS UI (3-toggle subset).
|
||||
# On those platforms these toggles are disabled — full MADS settings are
|
||||
# only writable on platforms NOT in the limited-set.
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Inverse of mads_full_platforms: present only on the limited platforms.
|
||||
# Useful for "show this only on rivian/tesla-no-bus" toggles.
|
||||
mads_limited_platforms:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Hide on sunnypilot release branches (is_release is hardcoded False everywhere; is_sp_release is the active gate).
|
||||
release_branches_hide:
|
||||
- type: not
|
||||
condition: {type: capability, field: is_sp_release, equals: true}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/macros.schema.json",
|
||||
"title": "Settings UI macros (named rule fragments)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["macros"],
|
||||
"properties": {
|
||||
"macros": {
|
||||
"type": "object",
|
||||
"description": "Named rule fragments. Each value is either a list of rules (typical) or a single rule object. Reference from items/layout via {$ref: '#/macros/<name>'}.",
|
||||
"patternProperties": {
|
||||
"^[A-Za-z_][A-Za-z0-9_]*$": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
{"$ref": "rule.schema.json"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/page.schema.json",
|
||||
"title": "Settings UI page (panel) YAML",
|
||||
"description": "Validates pages/<id>.yaml. Each page describes one settings panel (or the vehicle namespace via kind: vehicle).",
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"icon": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"remote_configurable": {"type": "boolean"},
|
||||
"description": {"type": "string"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["panel", "vehicle"],
|
||||
"description": "panel (default) or vehicle (compiles to settings_ui.json#vehicle_settings)."
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Section"}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Item"}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/SubPanel"}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Section": {
|
||||
"type": "object",
|
||||
"required": ["id", "title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"attestation_required": {"type": "boolean"},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}},
|
||||
"sub_panels": {"type": "array", "items": {"$ref": "#/$defs/SubPanel"}}
|
||||
}
|
||||
},
|
||||
"SubPanel": {
|
||||
"type": "object",
|
||||
"required": ["id", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"trigger_key": {"type": ["string", "null"]},
|
||||
"trigger_condition": {"$ref": "rule.schema.json"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
},
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"required": ["key", "widget"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"},
|
||||
"widget": {"type": "string", "enum": ["toggle", "option", "multiple_button", "button", "info"]},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string", "description": "Inline body text under the title. May be omitted when only details is used."},
|
||||
"details": {"type": "string", "description": "Extended help shown in a modal when the user taps the info (i) button. Independent of description; either, both, or neither may be present."},
|
||||
"title_param_suffix": {"type": "object"},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"},
|
||||
"step": {"type": "number"},
|
||||
"unit": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["metric", "imperial"],
|
||||
"properties": {
|
||||
"metric": {"type": "string"},
|
||||
"imperial": {"type": "string"}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"needs_onroad_cycle": {"type": "boolean"},
|
||||
"requires_attestation": {"type": "boolean"},
|
||||
"blocked": {"type": "boolean"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["value", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {},
|
||||
"label": {"type": "string"},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"sub_items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/rule.schema.json",
|
||||
"title": "Rule",
|
||||
"description": "Visibility/enablement rule. Discriminated union on 'type'. Macro reference is also accepted via {$ref: '#/macros/<name>'}.",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/MacroRef"},
|
||||
{"$ref": "#/$defs/RuleOffroadOnly"},
|
||||
{"$ref": "#/$defs/RuleNotEngaged"},
|
||||
{"$ref": "#/$defs/RuleCapability"},
|
||||
{"$ref": "#/$defs/RuleParam"},
|
||||
{"$ref": "#/$defs/RuleParamCompare"},
|
||||
{"$ref": "#/$defs/RuleNot"},
|
||||
{"$ref": "#/$defs/RuleAny"},
|
||||
{"$ref": "#/$defs/RuleAll"}
|
||||
],
|
||||
"$defs": {
|
||||
"MacroRef": {
|
||||
"type": "object",
|
||||
"required": ["$ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"type": "string",
|
||||
"pattern": "^#/macros/[A-Za-z_][A-Za-z0-9_]*$",
|
||||
"description": "Reference to a macro defined in _macros.yaml under #/macros/<name>."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleOffroadOnly": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "offroad_only"}}
|
||||
},
|
||||
"RuleNotEngaged": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "not_engaged"}}
|
||||
},
|
||||
"RuleCapability": {
|
||||
"type": "object",
|
||||
"required": ["type", "field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "capability"},
|
||||
"field": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParam": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param"},
|
||||
"key": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParamCompare": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "op", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param_compare"},
|
||||
"key": {"type": "string"},
|
||||
"op": {"type": "string", "enum": [">", "<", ">=", "<="]},
|
||||
"value": {"type": "number"}
|
||||
}
|
||||
},
|
||||
"RuleNot": {
|
||||
"type": "object",
|
||||
"required": ["type", "condition"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "not"},
|
||||
"condition": {"$ref": "#"}
|
||||
}
|
||||
},
|
||||
"RuleAny": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "any"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
},
|
||||
"RuleAll": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "all"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
# Page: cruise
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: cruise
|
||||
label: Cruise
|
||||
icon: cruise_control
|
||||
order: 2
|
||||
remote_configurable: true
|
||||
description: Longitudinal control, speed limits, and cruise behavior
|
||||
sections:
|
||||
- id: core_cruise_features
|
||||
title: ''
|
||||
description: ''
|
||||
items:
|
||||
- key: ExperimentalMode
|
||||
widget: toggle
|
||||
title: Experimental Mode
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: DynamicExperimentalControl
|
||||
widget: toggle
|
||||
title: Dynamic Experimental Control
|
||||
description: Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: DisengageOnAccelerator
|
||||
widget: toggle
|
||||
title: Disengage Cruise on Accelerator Pedal
|
||||
description: When enabled, pressing the accelerator pedal will disengage longitudinal control.
|
||||
- key: LongitudinalPersonality
|
||||
widget: multiple_button
|
||||
title: Driving Personality
|
||||
description: Standard is recommended. In aggressive mode, sunnypilot will follow lead cars closer and be more aggressive
|
||||
with the gas and brake. In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can
|
||||
cycle through these personalities with your steering wheel distance button.
|
||||
options:
|
||||
- value: 0
|
||||
label: Aggressive
|
||||
- value: 1
|
||||
label: Standard
|
||||
- value: 2
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
visibility:
|
||||
- type: capability
|
||||
field: icbm_available
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- id: custom_acc_increments
|
||||
title: Custom ACC Speed Intervals
|
||||
description: ''
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: any
|
||||
conditions:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: pcm_cruise
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
items:
|
||||
- key: CustomAccIncrementsEnabled
|
||||
widget: toggle
|
||||
title: Enable Custom ACC Speed Intervals
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: any
|
||||
conditions:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: pcm_cruise
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
sub_panels:
|
||||
- id: custom_acc_intervals
|
||||
label: Custom ACC Speed Intervals Settings
|
||||
trigger_key: CustomAccIncrementsEnabled
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
items:
|
||||
- key: CustomAccShortPressIncrement
|
||||
widget: option
|
||||
title: Short Press Increment
|
||||
min: 1
|
||||
max: 10
|
||||
step: 1
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
- key: CustomAccLongPressIncrement
|
||||
widget: option
|
||||
title: Long Press Increment
|
||||
min: 1
|
||||
max: 10
|
||||
step: 1
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
- id: speed_limits
|
||||
title: Speed Limits
|
||||
description: Speed limit detection and offset behavior
|
||||
items: []
|
||||
sub_panels:
|
||||
- id: speed_limit_settings
|
||||
label: Speed Limit Settings
|
||||
trigger_key: SpeedLimitMode
|
||||
items:
|
||||
- key: SpeedLimitMode
|
||||
widget: multiple_button
|
||||
title: Speed Limit Assist Mode
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Information
|
||||
- value: 2
|
||||
label: Warning
|
||||
- value: 3
|
||||
label: Assist
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: brand
|
||||
equals: rivian
|
||||
- type: not
|
||||
condition:
|
||||
type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: brand
|
||||
equals: tesla
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
- key: SpeedLimitPolicy
|
||||
widget: multiple_button
|
||||
title: Speed Limit Source
|
||||
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
|
||||
- key: SpeedLimitOffsetType
|
||||
widget: multiple_button
|
||||
title: Speed Limit Offset Type
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Fixed
|
||||
- value: 2
|
||||
label: Percentage
|
||||
- key: SpeedLimitValueOffset
|
||||
widget: option
|
||||
title: Speed Limit Offset Value
|
||||
min: -30
|
||||
max: 30
|
||||
step: 1
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
visibility:
|
||||
- type: param_compare
|
||||
key: SpeedLimitOffsetType
|
||||
op: '>'
|
||||
value: 0
|
||||
- id: smart_cruise
|
||||
title: Smart Cruise Control
|
||||
description: ''
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
items:
|
||||
- key: SmartCruiseControlVision
|
||||
widget: toggle
|
||||
title: Vision
|
||||
description: Use vision path predictions to estimate the appropriate speed to drive through turns ahead.
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
- key: SmartCruiseControlMap
|
||||
widget: toggle
|
||||
title: Map
|
||||
description: Use map data to estimate the appropriate speed to drive through turns ahead.
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
@@ -0,0 +1,137 @@
|
||||
# Page: developer
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: developer
|
||||
label: Developer
|
||||
icon: developer
|
||||
order: 9
|
||||
remote_configurable: true
|
||||
description: Debug tools, remote access, and advanced services
|
||||
sections:
|
||||
- id: connectivity
|
||||
title: Connectivity
|
||||
description: Remote access and debugging interfaces
|
||||
items:
|
||||
- key: AdbEnabled
|
||||
widget: toggle
|
||||
blocked: true
|
||||
title: Enable ADB
|
||||
description: ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. See https://docs.comma.ai/how-to/connect-to-comma
|
||||
for more info.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: SshEnabled
|
||||
widget: toggle
|
||||
blocked: true
|
||||
title: Enable SSH
|
||||
- key: JoystickDebugMode
|
||||
widget: toggle
|
||||
title: Joystick Debug Mode
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: AlphaLongitudinalEnabled
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: sunnypilot Longitudinal Control (Alpha)
|
||||
description: 'WARNING: sunnypilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking
|
||||
(AEB). On this car, sunnypilot defaults to the car''s built-in ACC instead of sunnypilot''s longitudinal control. Enable
|
||||
this to switch to sunnypilot longitudinal control. Enabling Experimental mode is recommended when enabling sunnypilot
|
||||
longitudinal control alpha. Changing this setting will restart sunnypilot if the car is powered on.'
|
||||
visibility:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: alpha_long_available
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ShowDebugInfo
|
||||
widget: toggle
|
||||
title: UI Debug Mode
|
||||
- id: test_maneuvers
|
||||
title: Test Maneuvers
|
||||
description: 'DANGER: enabling these maneuvers replaces normal driving behavior with deterministic test sequences. Each
|
||||
toggle requires explicit confirmation per write. Use only in a closed environment.'
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
- type: param
|
||||
key: ShowAdvancedControls
|
||||
equals: true
|
||||
attestation_required: true
|
||||
items:
|
||||
- key: LateralManeuverMode
|
||||
widget: toggle
|
||||
title: '[TEST] Lateral Maneuver Mode'
|
||||
description: Replaces normal lateral control with a deterministic test sequence. NOT for road use.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- key: LongitudinalManeuverMode
|
||||
widget: toggle
|
||||
title: '[TEST] Longitudinal Maneuver Mode'
|
||||
description: Replaces normal longitudinal control with a deterministic test sequence. NOT for road use.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: advanced_services
|
||||
title: Advanced Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ShowAdvancedControls
|
||||
widget: toggle
|
||||
title: Show Advanced Controls
|
||||
description: Toggle visibility of advanced sunnypilot controls. This only changes the visibility of the toggles; it does
|
||||
not change the actual enabled/disabled state.
|
||||
- key: EnableGithubRunner
|
||||
widget: toggle
|
||||
title: GitHub Runner Service
|
||||
description: Enables or disables the GitHub runner service.
|
||||
visibility:
|
||||
- $ref: '#/macros/release_branches_hide'
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: EnableCopyparty
|
||||
widget: toggle
|
||||
title: copyparty Service
|
||||
description: copyparty is a very capable file server, you can use it to download your routes, view your logs and even
|
||||
make some edits on some files from your browser. Requires you to connect to your comma locally via its IP address.
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: QuickBootToggle
|
||||
widget: toggle
|
||||
title: Quickboot Mode
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
enablement:
|
||||
- type: param
|
||||
key: DisableUpdates
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,67 @@
|
||||
# Page: device
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: device
|
||||
label: Device
|
||||
icon: device
|
||||
order: 6
|
||||
remote_configurable: true
|
||||
description: Device behavior, units, and recording settings
|
||||
sections:
|
||||
- id: general
|
||||
title: General
|
||||
description: Power, boot, and unit preferences
|
||||
items:
|
||||
- key: OffroadMode
|
||||
widget: toggle
|
||||
title: Force Offroad Mode
|
||||
- key: DeviceBootMode
|
||||
widget: option
|
||||
title: Wake Up Behavior
|
||||
description: 'Controls state of the device after boot/sleep. Default: Device will boot/wake-up normally and will be ready
|
||||
to engage. Offroad: Device will be in Always Offroad mode after boot/wake-up.'
|
||||
options:
|
||||
- value: 0
|
||||
label: Standard
|
||||
- value: 1
|
||||
label: Always Offroad
|
||||
- key: QuietMode
|
||||
widget: toggle
|
||||
title: Quiet Mode
|
||||
- key: OnroadUploads
|
||||
widget: toggle
|
||||
title: Onroad Uploads
|
||||
- key: MaxTimeOffroad
|
||||
widget: option
|
||||
title: Max Time Offroad
|
||||
description: Device will automatically shutdown after set time once the engine is turned off. 30h is the default.
|
||||
options:
|
||||
- value: 0
|
||||
label: Always On
|
||||
- value: 5
|
||||
label: 5m
|
||||
- value: 10
|
||||
label: 10m
|
||||
- value: 15
|
||||
label: 15m
|
||||
- value: 30
|
||||
label: 30m
|
||||
- value: 60
|
||||
label: 1h
|
||||
- value: 120
|
||||
label: 2h
|
||||
- value: 180
|
||||
label: 3h
|
||||
- value: 300
|
||||
label: 5h
|
||||
- value: 600
|
||||
label: 10h
|
||||
- value: 1440
|
||||
label: 24h
|
||||
- value: 1800
|
||||
label: 30h (Default)
|
||||
- id: language
|
||||
title: Language
|
||||
items:
|
||||
- key: LanguageSetting
|
||||
widget: info
|
||||
title: Language
|
||||
@@ -0,0 +1,130 @@
|
||||
# Page: display
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: display
|
||||
label: Display
|
||||
icon: display
|
||||
order: 3
|
||||
remote_configurable: true
|
||||
description: Screen brightness, timeout, and interactivity settings
|
||||
sections:
|
||||
- id: brightness_timeout
|
||||
title: Brightness & Timeout
|
||||
description: Screen dimming and sleep behavior while driving
|
||||
items:
|
||||
- key: OnroadScreenOffBrightness
|
||||
widget: multiple_button
|
||||
title: Onroad Brightness
|
||||
options:
|
||||
- value: 0
|
||||
label: Auto (Default)
|
||||
- value: 1
|
||||
label: Auto (Dark)
|
||||
- value: 2
|
||||
label: Screen Off
|
||||
- value: 3
|
||||
label: 5 %
|
||||
- value: 4
|
||||
label: 10 %
|
||||
- value: 5
|
||||
label: 15 %
|
||||
- value: 6
|
||||
label: 20 %
|
||||
- value: 7
|
||||
label: 25 %
|
||||
- value: 8
|
||||
label: 30 %
|
||||
- value: 9
|
||||
label: 35 %
|
||||
- value: 10
|
||||
label: 40 %
|
||||
- value: 11
|
||||
label: 45 %
|
||||
- value: 12
|
||||
label: 50 %
|
||||
- value: 13
|
||||
label: 55 %
|
||||
- value: 14
|
||||
label: 60 %
|
||||
- value: 15
|
||||
label: 65 %
|
||||
- value: 16
|
||||
label: 70 %
|
||||
- value: 17
|
||||
label: 75 %
|
||||
- value: 18
|
||||
label: 80 %
|
||||
- value: 19
|
||||
label: 85 %
|
||||
- value: 20
|
||||
label: 90 %
|
||||
- value: 21
|
||||
label: 95 %
|
||||
- value: 22
|
||||
label: 100 %
|
||||
- key: OnroadScreenOffTimer
|
||||
widget: multiple_button
|
||||
title: Onroad Brightness Delay
|
||||
options:
|
||||
- value: 0
|
||||
label: Always On
|
||||
- value: 3
|
||||
label: 3s
|
||||
- value: 5
|
||||
label: 5s
|
||||
- value: 10
|
||||
label: 10s
|
||||
- value: 15
|
||||
label: 15s
|
||||
- value: 30
|
||||
label: 30s
|
||||
- value: 60
|
||||
label: 1m
|
||||
- value: 180
|
||||
label: 3m
|
||||
- value: 300
|
||||
label: 5m
|
||||
- value: 600
|
||||
label: 10m
|
||||
enablement:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: OnroadScreenOffBrightness
|
||||
equals: 0
|
||||
- type: param
|
||||
key: OnroadScreenOffBrightness
|
||||
equals: 1
|
||||
- key: InteractivityTimeout
|
||||
widget: multiple_button
|
||||
title: Interactivity Timeout
|
||||
description: Apply a custom timeout for settings UI. This is the time after which settings UI closes automatically if
|
||||
user is not interacting with the screen.
|
||||
options:
|
||||
- value: 0
|
||||
label: Default
|
||||
- value: 10
|
||||
label: 10 s
|
||||
- value: 20
|
||||
label: 20 s
|
||||
- value: 30
|
||||
label: 30 s
|
||||
- value: 40
|
||||
label: 40 s
|
||||
- value: 50
|
||||
label: 50 s
|
||||
- value: 60
|
||||
label: 1 m
|
||||
- value: 70
|
||||
label: 1 m
|
||||
- value: 80
|
||||
label: 1 m
|
||||
- value: 90
|
||||
label: 1 m
|
||||
- value: 100
|
||||
label: 1 m
|
||||
- value: 110
|
||||
label: 1 m
|
||||
- value: 120
|
||||
label: 2 m
|
||||
@@ -0,0 +1,89 @@
|
||||
# Page: models
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: models
|
||||
label: Models
|
||||
icon: models
|
||||
order: 10
|
||||
remote_configurable: false
|
||||
description: Driving model behavior and camera calibration
|
||||
sections:
|
||||
- id: model_behavior
|
||||
title: Model Behavior
|
||||
description: Lane desire and lead-vehicle awareness tuning
|
||||
items:
|
||||
- key: LaneTurnDesire
|
||||
widget: toggle
|
||||
title: Use Lane Turn Desires
|
||||
description: If you are driving at 20 mph (32 km/h) or below and have your blinker on, the car will plan a turn in that
|
||||
direction at the nearest drivable path. This prevents situations (like at red lights) where the car might plan the wrong
|
||||
turn direction.
|
||||
- key: LaneTurnValue
|
||||
widget: option
|
||||
title: Adjust Lane Turn Speed
|
||||
description: Set the maximum speed for lane turn desires.
|
||||
min: 0
|
||||
max: 20
|
||||
step: 1
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
enablement:
|
||||
- type: param
|
||||
key: LaneTurnDesire
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: LagdToggle
|
||||
widget: toggle
|
||||
title: Live Learning Steer Delay
|
||||
description: Allow device to learn and adapt car's steering response time
|
||||
- key: LagdToggleDelay
|
||||
widget: option
|
||||
title: Adjust Software Delay
|
||||
description: Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value
|
||||
is 0.2
|
||||
min: 0.05
|
||||
max: 0.5
|
||||
step: 0.01
|
||||
enablement:
|
||||
- type: not
|
||||
condition:
|
||||
type: param
|
||||
key: LagdToggle
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- id: lateral_control
|
||||
title: Lateral Control
|
||||
description: Neural network lateral control for supported models
|
||||
items:
|
||||
- key: NeuralNetworkLateralControl
|
||||
widget: toggle
|
||||
title: Neural Network Lateral Control (NNLC)
|
||||
description: Use a neural network for lateral control instead of the default torque controller.
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: steer_control_type
|
||||
equals: angle
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- type: param
|
||||
key: EnforceTorqueControl
|
||||
equals: false
|
||||
- id: camera
|
||||
title: Camera
|
||||
description: Camera position and calibration
|
||||
items:
|
||||
- key: CameraOffset
|
||||
widget: option
|
||||
title: Adjust Camera Offset
|
||||
description: Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)
|
||||
min: -0.35
|
||||
max: 0.35
|
||||
step: 0.01
|
||||
unit: meters
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,20 @@
|
||||
# Page: software
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: software
|
||||
label: Software
|
||||
icon: software
|
||||
order: 7
|
||||
remote_configurable: true
|
||||
description: Software update preferences
|
||||
sections:
|
||||
- id: updates
|
||||
title: Updates
|
||||
description: Control software updates
|
||||
items:
|
||||
- key: DisableUpdates
|
||||
widget: toggle
|
||||
title: Disable Updates
|
||||
description: When enabled, software updates will be off. This requires a reboot to take effect.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,257 @@
|
||||
# Page: steering
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: steering
|
||||
label: Steering
|
||||
icon: steering_wheel
|
||||
order: 1
|
||||
remote_configurable: true
|
||||
description: Lateral control, lane changes, and steering behavior
|
||||
sections:
|
||||
- id: mads
|
||||
title: Modular Assistive Driving System (MADS)
|
||||
description: ''
|
||||
items:
|
||||
- key: Mads
|
||||
widget: toggle
|
||||
title: Enable Modular Assistive Driving System (MADS)
|
||||
description: Enable MADS. Disable toggle to revert back to stock sunnypilot engagement/disengagement.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
sub_panels:
|
||||
- id: mads_settings
|
||||
label: MADS Settings
|
||||
trigger_key: Mads
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: Mads
|
||||
equals: true
|
||||
items:
|
||||
- key: MadsMainCruiseAllowed
|
||||
widget: toggle
|
||||
title: Toggle with Main Cruise
|
||||
description: 'Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement.'
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- key: MadsUnifiedEngagementMode
|
||||
widget: toggle
|
||||
title: Unified Engagement Mode (UEM)
|
||||
description: 'Engage lateral and longitudinal control with cruise control engagement. Note: Once lateral control is
|
||||
engaged via UEM, it will remain engaged until it is manually disabled via the MADS button or car shut off.'
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- key: MadsSteeringMode
|
||||
widget: multiple_button
|
||||
title: Steering Mode on Brake Pedal
|
||||
description: Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot.
|
||||
options:
|
||||
- value: 0
|
||||
label: Remain Active
|
||||
enablement:
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- value: 1
|
||||
label: Pause
|
||||
enablement:
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- value: 2
|
||||
label: Disengage
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: blinker
|
||||
title: Blinker Control
|
||||
description: Lateral pause behavior during turn signals
|
||||
items:
|
||||
- key: BlinkerPauseLateralControl
|
||||
widget: toggle
|
||||
title: Pause Lateral Control with Blinker
|
||||
description: Pause lateral control with blinker when traveling below the desired speed selected.
|
||||
sub_items:
|
||||
- key: BlinkerMinLateralControlSpeed
|
||||
widget: option
|
||||
title: Minimum Speed to Pause Lateral Control
|
||||
min: 0
|
||||
max: 255
|
||||
step: 5
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
enablement:
|
||||
- type: param
|
||||
key: BlinkerPauseLateralControl
|
||||
equals: true
|
||||
- key: BlinkerLateralReengageDelay
|
||||
widget: option
|
||||
title: Post-Blinker Delay
|
||||
description: Delay before lateral control resumes after the turn signal ends.
|
||||
min: 0
|
||||
max: 10
|
||||
step: 1
|
||||
unit: second
|
||||
enablement:
|
||||
- type: param
|
||||
key: BlinkerPauseLateralControl
|
||||
equals: true
|
||||
- id: torque
|
||||
title: Torque Control
|
||||
description: Steering torque tuning and lateral control method
|
||||
enablement:
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
items:
|
||||
- key: EnforceTorqueControl
|
||||
widget: toggle
|
||||
title: Enforce Torque Lateral Control
|
||||
description: Enable this to enforce sunnypilot to steer with Torque lateral control.
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: steer_control_type
|
||||
equals: angle
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- type: param
|
||||
key: NeuralNetworkLateralControl
|
||||
equals: false
|
||||
sub_panels:
|
||||
- id: torque_settings
|
||||
label: Torque Settings
|
||||
trigger_key: EnforceTorqueControl
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: EnforceTorqueControl
|
||||
equals: true
|
||||
items:
|
||||
- key: LiveTorqueParamsToggle
|
||||
widget: toggle
|
||||
title: Self-Tune
|
||||
description: Enables self-tune for Torque lateral control for platforms that do not use Torque lateral control by default.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: LiveTorqueParamsRelaxedToggle
|
||||
widget: toggle
|
||||
title: Less Restrict Settings for Self-Tune (Beta)
|
||||
description: Less strict settings when using Self-Tune. This allows torqued to be more forgiving when learning values.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: param
|
||||
key: LiveTorqueParamsToggle
|
||||
equals: true
|
||||
- key: CustomTorqueParams
|
||||
widget: toggle
|
||||
title: Enable Custom Tuning
|
||||
description: Enables custom tuning for Torque lateral control. Modifying Lateral Acceleration Factor and Friction below
|
||||
will override the offline values indicated in the YAML files within "opendbc/car/torque_data". The values will also
|
||||
be used live when "Manual Real-Time Tuning" toggle is enabled.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: TorqueParamsOverrideEnabled
|
||||
widget: toggle
|
||||
title: Manual Real-Time Tuning
|
||||
description: Enforces the torque lateral controller to use the fixed values instead of the learned values from Self-Tune.
|
||||
Enabling this toggle overrides Self-Tune values.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- key: TorqueParamsOverrideLatAccelFactor
|
||||
widget: option
|
||||
title: Lateral Acceleration Factor
|
||||
title_param_suffix:
|
||||
param: TorqueParamsOverrideEnabled
|
||||
values:
|
||||
'true': (Real-Time & Offline)
|
||||
'false': (Offline Only)
|
||||
min: 0.1
|
||||
max: 5.0
|
||||
step: 0.1
|
||||
unit: m/s²
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: TorqueParamsOverrideEnabled
|
||||
equals: true
|
||||
- type: offroad_only
|
||||
- key: TorqueParamsOverrideFriction
|
||||
widget: option
|
||||
title: Friction
|
||||
title_param_suffix:
|
||||
param: TorqueParamsOverrideEnabled
|
||||
values:
|
||||
'true': (Real-Time & Offline)
|
||||
'false': (Offline Only)
|
||||
min: 0.0
|
||||
max: 1.0
|
||||
step: 0.01
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: TorqueParamsOverrideEnabled
|
||||
equals: true
|
||||
- type: offroad_only
|
||||
- key: TorqueControlTune
|
||||
widget: multiple_button
|
||||
title: Torque Control Tune Version
|
||||
description: Select the version of Torque Control Tune to use.
|
||||
options:
|
||||
- value: ''
|
||||
label: Default
|
||||
- value: 1.0
|
||||
label: v1.0
|
||||
- value: 0.0
|
||||
label: v0.0
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: lane_change
|
||||
title: Lane Change
|
||||
description: Automatic lane change timing and behavior
|
||||
items:
|
||||
- key: AutoLaneChangeTimer
|
||||
widget: option
|
||||
title: Auto Lane Change by Blinker
|
||||
description: |-
|
||||
Set a timer to delay the auto lane change operation when the blinker is used. No nudge on the steering wheel is required to auto lane change if a timer is set. Default is Nudge.
|
||||
details: |-
|
||||
Please use caution when using this feature. Only use the blinker when traffic and road conditions permit.
|
||||
options:
|
||||
- value: -1
|
||||
label: 'Off'
|
||||
- value: 0
|
||||
label: Nudge
|
||||
- value: 1
|
||||
label: Nudgeless
|
||||
- value: 2
|
||||
label: 0.5 second
|
||||
- value: 3
|
||||
label: 1 second
|
||||
- value: 4
|
||||
label: 2 seconds
|
||||
- value: 5
|
||||
label: 3 seconds
|
||||
- key: AutoLaneChangeBsmDelay
|
||||
widget: toggle
|
||||
title: 'Auto Lane Change: Delay with Blind Spot'
|
||||
description: Toggle to enable a delay timer for lane changes when blind spot monitoring (BSM) detects a vehicle in your blind
|
||||
spot.
|
||||
enablement:
|
||||
- type: capability
|
||||
field: enable_bsm
|
||||
equals: true
|
||||
- type: param_compare
|
||||
key: AutoLaneChangeTimer
|
||||
op: '>'
|
||||
value: 0
|
||||
@@ -0,0 +1,49 @@
|
||||
# Page: toggles
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: toggles
|
||||
label: Toggles
|
||||
icon: toggles
|
||||
order: 5
|
||||
remote_configurable: true
|
||||
description: Core openpilot feature toggles
|
||||
sections:
|
||||
- id: core_toggles
|
||||
title: ''
|
||||
description: ''
|
||||
items:
|
||||
- key: OpenpilotEnabledToggle
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enable sunnypilot
|
||||
description: Use the sunnypilot system for adaptive cruise control and lane keep driver assistance. Your attention is
|
||||
required at all times to use this feature.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: IsLdwEnabled
|
||||
widget: toggle
|
||||
title: Enable Lane Departure Warnings
|
||||
description: Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line without a turn
|
||||
signal activated while driving over 31 mph (50 km/h).
|
||||
- key: AlwaysOnDM
|
||||
widget: toggle
|
||||
title: Always-On Driver Monitoring
|
||||
description: Enable driver monitoring even when sunnypilot is not engaged.
|
||||
- key: IsMetric
|
||||
widget: toggle
|
||||
title: Use Metric System
|
||||
description: Display speed in km/h instead of mph.
|
||||
- id: recording
|
||||
title: Recording
|
||||
description: Camera and audio recording during drives
|
||||
items:
|
||||
- key: RecordFront
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Record and Upload Driver Camera
|
||||
description: Upload data from the driver facing camera and help improve the driver monitoring algorithm.
|
||||
- key: RecordAudio
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Record and Upload Microphone Audio
|
||||
description: Record and store microphone audio while driving. The audio will be included in the dashcam video in comma
|
||||
connect.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Page: vehicle (per-brand settings)
|
||||
# Compiles to settings_ui.json#vehicle_settings (brand = section id).
|
||||
id: vehicle
|
||||
label: Vehicle
|
||||
icon: vehicle
|
||||
order: 99
|
||||
kind: vehicle
|
||||
sections:
|
||||
- id: hyundai
|
||||
title: Hyundai / Kia / Genesis Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: HyundaiLongitudinalTuning
|
||||
widget: multiple_button
|
||||
title: Custom Longitudinal Tuning
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Dynamic
|
||||
- value: 2
|
||||
label: Predictive
|
||||
visibility:
|
||||
- type: capability
|
||||
field: hyundai_alpha_long_available
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: subaru
|
||||
title: Subaru Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: SubaruStopAndGo
|
||||
widget: toggle
|
||||
title: Stop and Go (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: has_stop_and_go
|
||||
equals: true
|
||||
- key: SubaruStopAndGoManualParkingBrake
|
||||
widget: toggle
|
||||
title: Stop and Go for Manual Parking Brake (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: has_stop_and_go
|
||||
equals: true
|
||||
- id: tesla
|
||||
title: Tesla Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: TeslaCoopSteering
|
||||
widget: toggle
|
||||
title: Cooperative Steering (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: toyota
|
||||
title: Toyota / Lexus Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ToyotaEnforceStockLongitudinal
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enforce Factory Longitudinal Control
|
||||
description: sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaStopAndGoHack
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Stop and Go Hack (Alpha)
|
||||
description: sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. This feature is only
|
||||
applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: ToyotaEnforceStockLongitudinal
|
||||
equals: false
|
||||
@@ -0,0 +1,111 @@
|
||||
# Page: visuals
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: visuals
|
||||
label: Visuals
|
||||
icon: visuals
|
||||
order: 4
|
||||
remote_configurable: true
|
||||
description: HUD overlays, alerts, and on-screen display elements
|
||||
sections:
|
||||
- id: hud_elements
|
||||
title: HUD Elements
|
||||
description: Overlays shown on the driving screen
|
||||
items:
|
||||
- key: BlindSpot
|
||||
widget: toggle
|
||||
title: Show Blind Spot Warnings
|
||||
description: Enabling this will display warnings when a vehicle is detected in your blind spot as long as your car has
|
||||
BSM supported.
|
||||
- key: TorqueBar
|
||||
widget: toggle
|
||||
title: Steering Arc
|
||||
description: Display steering arc on the driving screen when lateral control is enabled.
|
||||
- key: ShowTurnSignals
|
||||
widget: toggle
|
||||
title: Display Turn Signals
|
||||
description: When enabled, visual turn indicators are drawn on the HUD.
|
||||
- key: RoadNameToggle
|
||||
widget: toggle
|
||||
title: Display Road Name
|
||||
description: Displays the name of the road the car is traveling on. The OpenStreetMap database of the location must be
|
||||
downloaded to fetch the road name.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: StandstillTimer
|
||||
widget: toggle
|
||||
title: Standstill Timer
|
||||
description: Show a timer on the HUD when the car is at a standstill.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: RocketFuel
|
||||
widget: toggle
|
||||
title: Real-time Acceleration Bar
|
||||
description: Show an indicator on the left side of the screen to display real-time vehicle acceleration and deceleration.
|
||||
This displays what the car is currently doing, not what the planner is requesting.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: ChevronInfo
|
||||
widget: option
|
||||
title: Display Metrics Below Chevron
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Distance
|
||||
- value: 2
|
||||
label: Speed
|
||||
- value: 3
|
||||
label: Time
|
||||
- value: 4
|
||||
label: All
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: developer_ui
|
||||
title: Developer UI Info
|
||||
description: Speedometer and debug display options
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
items:
|
||||
- key: DevUIInfo
|
||||
widget: option
|
||||
title: Developer UI
|
||||
description: Display real-time parameters and metrics from various sources.
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Bottom
|
||||
- value: 2
|
||||
label: Right
|
||||
- value: 3
|
||||
label: Right & Bottom
|
||||
- key: TrueVEgoUI
|
||||
widget: toggle
|
||||
title: 'Speedometer: Always Display True Speed'
|
||||
description: For applicable vehicles, always display the true vehicle current speed from wheel speed sensors.
|
||||
- key: HideVEgoUI
|
||||
widget: toggle
|
||||
title: 'Speedometer: Hide from Onroad Screen'
|
||||
description: When enabled, the speedometer on the onroad screen is not displayed.
|
||||
- id: alerts_extras
|
||||
title: Alerts & Extras
|
||||
description: Traffic light alerts and visual flair
|
||||
items:
|
||||
- key: GreenLightAlert
|
||||
widget: toggle
|
||||
title: Green Traffic Light Alert (Beta)
|
||||
description: 'A chime and on-screen alert will play when the traffic light you are waiting for turns green and you have
|
||||
no vehicle in front of you. On-screen visual alert is only available on comma 3X. Note: This chime is only designed
|
||||
as a notification. It is the driver''s responsibility to observe their environment and make decisions accordingly.'
|
||||
- key: LeadDepartAlert
|
||||
widget: toggle
|
||||
title: Lead Departure Alert (Beta)
|
||||
description: 'A chime and on-screen alert will play when you are stopped, and the vehicle in front of you start moving.
|
||||
On-screen visual alert is only available on comma 3X. Note: This chime is only designed as a notification. It is the
|
||||
driver''s responsibility to observe their environment and make decisions accordingly.'
|
||||
- key: RainbowMode
|
||||
widget: toggle
|
||||
title: Tesla Rainbow Mode
|
||||
description: Display a rainbow effect on the path the model wants to take. It does not affect driving in any way.
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/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 openpilot.cereal.messaging import SubMaster
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
from openpilot.common.utils import atomic_write
|
||||
from openpilot.common.version import get_build_metadata
|
||||
from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, STATS_FLUSH_TIME_S
|
||||
from openpilot.sunnypilot.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()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
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 enum import IntEnum
|
||||
import threading
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
|
||||
|
||||
class RoleType(IntEnum):
|
||||
READONLY = 0
|
||||
SPONSOR = 1
|
||||
ADMIN = 2
|
||||
|
||||
|
||||
class SponsorTier(IntEnum):
|
||||
FREE = 0
|
||||
NOVICE = 1
|
||||
SUPPORTER = 2
|
||||
CONTRIBUTOR = 3
|
||||
BENEFACTOR = 4
|
||||
GUARDIAN = 5
|
||||
|
||||
|
||||
class User:
|
||||
device_id: str
|
||||
user_id: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
token_hash: str
|
||||
|
||||
def __init__(self, json_data):
|
||||
self.device_id = json_data.get("device_id")
|
||||
self.user_id = json_data.get("user_id")
|
||||
self.created_at = json_data.get("created_at")
|
||||
self.updated_at = json_data.get("updated_at")
|
||||
self.token_hash = json_data.get("token_hash")
|
||||
|
||||
|
||||
class Role:
|
||||
role_type: str
|
||||
role_tier: str
|
||||
|
||||
def __init__(self, json_data):
|
||||
self.role_type = json_data.get("role_type")
|
||||
self.role_tier = json_data.get("role_tier")
|
||||
|
||||
|
||||
def _parse_roles(roles: str) -> list[Role]:
|
||||
lst_roles = []
|
||||
try:
|
||||
roles_list = json.loads(roles)
|
||||
for r in roles_list:
|
||||
try:
|
||||
role = Role(r)
|
||||
lst_roles.append(role)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to parse role {r}: {e}")
|
||||
return lst_roles
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing roles: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _parse_users(users: str) -> list[User]:
|
||||
lst_users = []
|
||||
try:
|
||||
users_list = json.loads(users)
|
||||
for u in users_list:
|
||||
try:
|
||||
user = User(u)
|
||||
lst_users.append(user)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to parse user {u}: {e}")
|
||||
return lst_users
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing users: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class SunnylinkState:
|
||||
FETCH_INTERVAL = 5.0 # seconds between API calls
|
||||
API_TIMEOUT = 10.0 # seconds for API requests
|
||||
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
|
||||
NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"]
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._lock = threading.Lock()
|
||||
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._sm = messaging.SubMaster(['deviceState'])
|
||||
|
||||
self._roles: list[Role] = []
|
||||
self._users: list[User] = []
|
||||
self.sponsor_tier: SponsorTier = SponsorTier.FREE
|
||||
self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId")
|
||||
self._api = SunnylinkApi(self.sunnylink_dongle_id)
|
||||
|
||||
self._panel_open = False
|
||||
|
||||
self._load_initial_state()
|
||||
|
||||
def _load_initial_state(self) -> None:
|
||||
roles_cache = self._params.get("SunnylinkCache_Roles")
|
||||
users_cache = self._params.get("SunnylinkCache_Users")
|
||||
if roles_cache is not None:
|
||||
self._roles = _parse_roles(roles_cache)
|
||||
self.sponsor_tier = self._get_highest_tier()
|
||||
if users_cache is not None:
|
||||
self._users = _parse_users(users_cache)
|
||||
|
||||
def _get_highest_tier(self) -> SponsorTier:
|
||||
role_tier = SponsorTier.FREE
|
||||
for role in self._roles:
|
||||
try:
|
||||
if RoleType[role.role_type.upper()] == RoleType.SPONSOR:
|
||||
role_tier = max(role_tier, SponsorTier[role.role_tier.upper()])
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
return role_tier
|
||||
|
||||
def _fetch_roles(self) -> None:
|
||||
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
token = self._api.get_token()
|
||||
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token, session=self._session)
|
||||
if response.status_code == 200:
|
||||
roles = response.text
|
||||
self._params.put("SunnylinkCache_Roles", roles, block=True)
|
||||
with self._lock:
|
||||
self._roles = _parse_roles(roles)
|
||||
sponsor_tier = self._get_highest_tier()
|
||||
if sponsor_tier != self.sponsor_tier:
|
||||
self.sponsor_tier = sponsor_tier
|
||||
cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
|
||||
def _fetch_users(self) -> None:
|
||||
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
token = self._api.get_token()
|
||||
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token, session=self._session)
|
||||
if response.status_code == 200:
|
||||
users = response.text
|
||||
self._params.put("SunnylinkCache_Users", users, block=True)
|
||||
with self._lock:
|
||||
self._users = _parse_users(users)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
|
||||
def _worker_thread(self) -> None:
|
||||
while self._running:
|
||||
with self._lock:
|
||||
panel_open = self._panel_open
|
||||
|
||||
if panel_open:
|
||||
self._sm.update()
|
||||
if self.is_connected():
|
||||
self._fetch_roles()
|
||||
self._fetch_users()
|
||||
|
||||
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(self.SLEEP_INTERVAL)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def get_sponsor_tier(self) -> SponsorTier:
|
||||
with self._lock:
|
||||
return self.sponsor_tier
|
||||
|
||||
def is_sponsor(self) -> bool:
|
||||
with self._lock:
|
||||
is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name
|
||||
for role in self._roles)
|
||||
return is_sponsor
|
||||
|
||||
def is_paired(self) -> bool:
|
||||
with self._lock:
|
||||
is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users)
|
||||
return is_paired
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
network_type = self._sm["deviceState"].networkType
|
||||
return bool(network_type != 0)
|
||||
|
||||
def get_sponsor_tier_color(self) -> rl.Color:
|
||||
tier = self.get_sponsor_tier()
|
||||
|
||||
if tier == SponsorTier.GUARDIAN:
|
||||
return rl.Color(255, 215, 0, 255)
|
||||
elif tier == SponsorTier.BENEFACTOR:
|
||||
return rl.Color(60, 179, 113, 255)
|
||||
elif tier == SponsorTier.CONTRIBUTOR:
|
||||
return rl.Color(70, 130, 180, 255)
|
||||
elif tier == SponsorTier.SUPPORTER:
|
||||
return rl.Color(147, 112, 219, 255)
|
||||
else:
|
||||
return style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def set_settings_open(self, _open: bool) -> None:
|
||||
with self._lock:
|
||||
self._panel_open = _open
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Sentinel tests for the capabilities payload contract. PROTOCOL_VERSION is the
|
||||
wire-protocol version observable by the dashboard; bumping it is a breaking
|
||||
change and must be intentional. KNOWN_PROTOCOL_VERSIONS pins the set we
|
||||
explicitly support — when the constant is bumped, this list must be edited in
|
||||
the same commit so the bump shows up in code review.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import (
|
||||
CAPABILITY_DEFAULTS,
|
||||
CAPABILITY_FIELDS,
|
||||
CAPABILITY_LABELS,
|
||||
PROTOCOL_VERSION,
|
||||
generate_capabilities,
|
||||
)
|
||||
|
||||
|
||||
KNOWN_PROTOCOL_VERSIONS = (1,)
|
||||
LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def caps():
|
||||
return generate_capabilities()
|
||||
|
||||
|
||||
class TestProtocolVersion:
|
||||
def test_protocol_version_in_capability_fields(self):
|
||||
assert "protocol_version" in CAPABILITY_FIELDS
|
||||
|
||||
def test_protocol_version_has_label(self):
|
||||
assert "protocol_version" in CAPABILITY_LABELS
|
||||
|
||||
def test_protocol_version_default_is_set(self):
|
||||
assert CAPABILITY_DEFAULTS.get("protocol_version") == PROTOCOL_VERSION
|
||||
|
||||
def test_protocol_version_emitted(self, caps):
|
||||
assert "protocol_version" in caps
|
||||
assert isinstance(caps["protocol_version"], int)
|
||||
assert caps["protocol_version"] >= 1
|
||||
|
||||
def test_protocol_version_matches_constant(self, caps):
|
||||
assert caps["protocol_version"] == PROTOCOL_VERSION
|
||||
|
||||
def test_protocol_version_is_known(self):
|
||||
"""Sentinel against accidental bumps. Edit KNOWN_PROTOCOL_VERSIONS if intentional."""
|
||||
assert PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS, (
|
||||
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. " +
|
||||
"If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
|
||||
)
|
||||
|
||||
def test_protocol_version_matches_latest_known(self):
|
||||
assert PROTOCOL_VERSION == LATEST_KNOWN, (
|
||||
"Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
|
||||
)
|
||||
|
||||
|
||||
class TestOpaquePerBrandFlags:
|
||||
def test_subaru_has_sng_field_present(self):
|
||||
assert "subaru_has_sng" in CAPABILITY_FIELDS
|
||||
|
||||
def test_hyundai_alpha_long_available_field_present(self):
|
||||
assert "hyundai_alpha_long_available" in CAPABILITY_FIELDS
|
||||
|
||||
def test_subaru_has_sng_default_false(self, caps):
|
||||
assert caps["subaru_has_sng"] is False
|
||||
|
||||
def test_hyundai_alpha_long_available_default_false(self, caps):
|
||||
assert caps["hyundai_alpha_long_available"] is False
|
||||
|
||||
|
||||
class TestCapabilitiesShape:
|
||||
def test_all_fields_present(self, caps):
|
||||
for field in CAPABILITY_FIELDS:
|
||||
assert field in caps, f"capabilities missing {field}"
|
||||
|
||||
def test_all_fields_have_labels(self):
|
||||
for field in CAPABILITY_FIELDS:
|
||||
assert field in CAPABILITY_LABELS, f"CAPABILITY_LABELS missing {field}"
|
||||
|
||||
def test_string_defaults_are_strings(self, caps):
|
||||
assert isinstance(caps["brand"], str)
|
||||
assert isinstance(caps["steer_control_type"], str)
|
||||
assert isinstance(caps["device_type"], str)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Tests for the settings_ui_src/ -> settings_ui.json compiler. Covers:
|
||||
- Roundtrip: compiled output matches the checked-in settings_ui.json
|
||||
- $ref macro resolution semantics (list-splice, scalar-substitute, depth, cycles)
|
||||
- Per-page tree integrity (every page has id; vehicle page emits to vehicle_settings)
|
||||
|
||||
Does not cover device-side generator (test_settings_schema.py) or per-bug
|
||||
regression (test_settings_changes.py); those continue to validate the
|
||||
compiled output once the compiler has produced it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import (
|
||||
CompileError,
|
||||
DEFAULT_OUT,
|
||||
DEFAULT_SRC,
|
||||
_resolve_refs,
|
||||
compile_schema,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def compiled() -> dict:
|
||||
return compile_schema(DEFAULT_SRC)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def committed() -> dict:
|
||||
with open(DEFAULT_OUT) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestRoundtrip:
|
||||
def test_compiled_matches_committed(self, compiled, committed):
|
||||
"""Compiled output must match the checked-in JSON."""
|
||||
if compiled == committed:
|
||||
return
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
json.dumps(committed, indent=2).splitlines(),
|
||||
json.dumps(compiled, indent=2).splitlines(),
|
||||
fromfile="settings_ui.json (committed)",
|
||||
tofile="settings_ui.json (freshly compiled)",
|
||||
lineterm="",
|
||||
))
|
||||
pytest.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}")
|
||||
|
||||
def test_committed_file_is_canonical(self):
|
||||
"""Compiled output must byte-match the checked-in file (including trailing newline).
|
||||
Drift means someone edited settings_ui.json by hand instead of editing settings_ui_src/."""
|
||||
schema = compile_schema(DEFAULT_SRC)
|
||||
rendered = json.dumps(schema, indent=2) + "\n"
|
||||
with open(DEFAULT_OUT) as f:
|
||||
current = f.read()
|
||||
if current == rendered:
|
||||
return
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
current.splitlines(),
|
||||
rendered.splitlines(),
|
||||
fromfile="settings_ui.json (on disk)",
|
||||
tofile="settings_ui.json (freshly compiled)",
|
||||
lineterm="",
|
||||
))
|
||||
pytest.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}")
|
||||
|
||||
|
||||
class TestRefResolution:
|
||||
def test_list_context_splices(self):
|
||||
macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]}
|
||||
out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros)
|
||||
assert out == [{"type": "offroad_only"}, {"type": "not_engaged"}]
|
||||
|
||||
def test_scalar_context_substitutes(self):
|
||||
macros = {"x": {"type": "capability", "field": "brand", "equals": "tesla"}}
|
||||
out = _resolve_refs({"condition": {"$ref": "#/macros/x"}}, macros)
|
||||
assert out == {"condition": {"type": "capability", "field": "brand", "equals": "tesla"}}
|
||||
|
||||
def test_chained_ref_resolves(self):
|
||||
macros = {
|
||||
"leaf": [{"type": "offroad_only"}],
|
||||
"middle": [{"$ref": "#/macros/leaf"}],
|
||||
}
|
||||
out = _resolve_refs([{"$ref": "#/macros/middle"}], macros)
|
||||
assert out == [{"type": "offroad_only"}]
|
||||
|
||||
def test_unknown_macro_raises(self):
|
||||
with pytest.raises(CompileError, match="unknown macro"):
|
||||
_resolve_refs([{"$ref": "#/macros/missing"}], {})
|
||||
|
||||
def test_cycle_raises(self):
|
||||
macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]}
|
||||
with pytest.raises(CompileError, match="cycle"):
|
||||
_resolve_refs([{"$ref": "#/macros/a"}], macros)
|
||||
|
||||
def test_depth_limit(self):
|
||||
# Depth 4 chain should fail (limit is 3).
|
||||
macros = {
|
||||
"l1": [{"$ref": "#/macros/l2"}],
|
||||
"l2": [{"$ref": "#/macros/l3"}],
|
||||
"l3": [{"$ref": "#/macros/l4"}],
|
||||
"l4": [{"type": "offroad_only"}],
|
||||
}
|
||||
with pytest.raises(CompileError, match="depth"):
|
||||
_resolve_refs([{"$ref": "#/macros/l1"}], macros)
|
||||
|
||||
def test_invalid_ref_scheme(self):
|
||||
with pytest.raises(CompileError, match="unsupported"):
|
||||
_resolve_refs([{"$ref": "https://example.com/x"}], {})
|
||||
|
||||
def test_scalar_macro_in_list_context_raises(self):
|
||||
macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list
|
||||
with pytest.raises(CompileError, match="must resolve to a list"):
|
||||
_resolve_refs([{"$ref": "#/macros/x"}], macros)
|
||||
|
||||
|
||||
class TestCompiledShape:
|
||||
def test_panels_present(self, compiled):
|
||||
assert isinstance(compiled["panels"], list)
|
||||
assert len(compiled["panels"]) == 9
|
||||
panel_ids = {p["id"] for p in compiled["panels"]}
|
||||
assert {"steering", "cruise", "display", "visuals", "toggles",
|
||||
"device", "software", "developer", "models"} <= panel_ids
|
||||
|
||||
def test_vehicle_settings_consistent_shape(self, compiled):
|
||||
"""Each brand in vehicle_settings must have {title, description, items}."""
|
||||
for brand, data in compiled["vehicle_settings"].items():
|
||||
assert isinstance(data, dict), f"{brand}: expected object, got {type(data).__name__}"
|
||||
assert "title" in data, f"{brand}: missing title"
|
||||
assert "description" in data, f"{brand}: missing description"
|
||||
assert "items" in data, f"{brand}: missing items"
|
||||
|
||||
def test_no_dangling_refs_after_compile(self, compiled):
|
||||
"""All $ref objects must be resolved during compilation."""
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
if "$ref" in node:
|
||||
pytest.fail(f"unresolved $ref: {node}")
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for x in node:
|
||||
walk(x)
|
||||
walk(compiled)
|
||||
|
||||
|
||||
class TestSourceTreeIntegrity:
|
||||
def test_macros_yaml_well_formed(self):
|
||||
with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert "macros" in doc
|
||||
for name, body in doc["macros"].items():
|
||||
assert name.replace("_", "").isalnum(), f"macro name '{name}' must be alphanumeric_"
|
||||
assert body, f"macro '{name}' empty"
|
||||
|
||||
def test_pages_dir_well_formed(self):
|
||||
pages_dir = os.path.join(DEFAULT_SRC, "pages")
|
||||
assert os.path.isdir(pages_dir), "pages/ directory missing"
|
||||
page_files = sorted(fn for fn in os.listdir(pages_dir) if fn.endswith(".yaml"))
|
||||
# 9 panels + 1 vehicle = 10
|
||||
assert len(page_files) == 10, f"expected 10 pages, found {len(page_files)}: {page_files}"
|
||||
|
||||
def test_every_page_has_id(self):
|
||||
pages_dir = os.path.join(DEFAULT_SRC, "pages")
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith(".yaml"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
with open(path) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert isinstance(doc, dict), f"{path}: top-level must be a mapping"
|
||||
assert "id" in doc, f"{path}: page missing 'id'"
|
||||
# File basename should match page id (modulo .yaml extension).
|
||||
expected_id = os.path.splitext(fn)[0]
|
||||
assert doc["id"] == expected_id, (
|
||||
f"{path}: page id '{doc['id']}' must match filename '{expected_id}'"
|
||||
)
|
||||
|
||||
def test_vehicle_page_kind(self):
|
||||
"""vehicle.yaml must declare kind: vehicle so it routes to vehicle_settings."""
|
||||
path = os.path.join(DEFAULT_SRC, "pages", "vehicle.yaml")
|
||||
with open(path) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert doc.get("kind") == "vehicle", "vehicle.yaml must declare kind: vehicle"
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Per-bug regression tests for the Raylib-vs-schema parity audit. Each test
|
||||
isolates one of the gating bugs that the design-overhaul branch fixes so a
|
||||
future regression is loud and obvious. These tests are intentionally narrow
|
||||
and additive — they do not replace the broader test_settings_schema.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
|
||||
DEFINITION_PATH,
|
||||
TORQUE_VERSIONS_PATH,
|
||||
_build_torque_options,
|
||||
_load_torque_versions,
|
||||
generate_schema,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json")
|
||||
|
||||
|
||||
def _walk_items(schema: dict[str, Any]):
|
||||
"""Yield every item dict from the schema."""
|
||||
def _yield(item: dict[str, Any]):
|
||||
yield item
|
||||
for sub in item.get("sub_items", []):
|
||||
yield from _yield(sub)
|
||||
|
||||
for panel in schema.get("panels", []):
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
yield from _yield(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield from _yield(item)
|
||||
for item in panel.get("items", []):
|
||||
yield from _yield(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield from _yield(item)
|
||||
for brand in schema.get("vehicle_settings", {}).values():
|
||||
items = brand.get("items", []) if isinstance(brand, dict) else brand
|
||||
for item in items:
|
||||
yield from _yield(item)
|
||||
|
||||
|
||||
def _find_item(schema: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
for item in _walk_items(schema):
|
||||
if item.get("key") == key:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _find_section(schema: dict[str, Any], panel_id: str, section_id: str) -> dict[str, Any] | None:
|
||||
for panel in schema.get("panels", []):
|
||||
if panel.get("id") != panel_id:
|
||||
continue
|
||||
for section in panel.get("sections", []):
|
||||
if section.get("id") == section_id:
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_rule_types(rules: list[dict[str, Any]] | None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
|
||||
def _walk(rule: dict[str, Any]) -> None:
|
||||
out.add(rule.get("type", ""))
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
_walk(rule["condition"])
|
||||
elif rule.get("type") in ("any", "all"):
|
||||
for c in rule.get("conditions", []):
|
||||
_walk(c)
|
||||
|
||||
for rule in rules or []:
|
||||
_walk(rule)
|
||||
return out
|
||||
|
||||
|
||||
def _references_capability_field(rules: list[dict[str, Any]] | None, field: str) -> bool:
|
||||
found = False
|
||||
|
||||
def _walk(rule: dict[str, Any]) -> None:
|
||||
nonlocal found
|
||||
if rule.get("type") == "capability" and rule.get("field") == field:
|
||||
found = True
|
||||
elif rule.get("type") == "not" and "condition" in rule:
|
||||
_walk(rule["condition"])
|
||||
elif rule.get("type") in ("any", "all"):
|
||||
for c in rule.get("conditions", []):
|
||||
_walk(c)
|
||||
|
||||
for rule in rules or []:
|
||||
_walk(rule)
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema():
|
||||
return generate_schema()
|
||||
|
||||
|
||||
class TestMadsBrandGates:
|
||||
def test_mads_main_cruise_has_brand_gate(self, schema):
|
||||
"""MadsMainCruiseAllowed must gate on brand and tesla_has_vehicle_bus."""
|
||||
item = _find_item(schema, "MadsMainCruiseAllowed")
|
||||
assert item is not None
|
||||
assert _references_capability_field(item.get("enablement"), "brand")
|
||||
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
|
||||
|
||||
def test_mads_unified_engagement_has_brand_gate(self, schema):
|
||||
"""MadsUnifiedEngagementMode must mirror MadsMainCruiseAllowed brand-gate."""
|
||||
item = _find_item(schema, "MadsUnifiedEngagementMode")
|
||||
assert item is not None
|
||||
assert _references_capability_field(item.get("enablement"), "brand")
|
||||
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
|
||||
|
||||
|
||||
class TestTestManeuversSection:
|
||||
def test_lateral_maneuver_mode_in_test_maneuvers(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None, "developer.test_maneuvers section missing"
|
||||
keys = {item["key"] for item in section.get("items", [])}
|
||||
assert "LateralManeuverMode" in keys
|
||||
assert "LongitudinalManeuverMode" in keys
|
||||
|
||||
def test_test_maneuvers_section_requires_attestation(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None
|
||||
assert section.get("attestation_required") is True
|
||||
|
||||
def test_test_maneuvers_section_visibility_gate(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None
|
||||
visibility = section.get("visibility")
|
||||
assert visibility, "test_maneuvers must have visibility gate"
|
||||
vis_refs = json.dumps(visibility)
|
||||
assert "is_development" in vis_refs
|
||||
assert "is_sp_release" in vis_refs
|
||||
enablement = section.get("enablement") or []
|
||||
enable_refs = json.dumps(enablement)
|
||||
assert "ShowAdvancedControls" in enable_refs, \
|
||||
"test_maneuvers must gate ShowAdvancedControls via enablement"
|
||||
|
||||
|
||||
class TestValidator:
|
||||
def test_validator_accepts_real_json(self):
|
||||
"""settings_ui.json validates against settings_ui.schema.json."""
|
||||
jsonschema = pytest.importorskip("jsonschema")
|
||||
with open(DEFINITION_PATH) as f:
|
||||
data = json.load(f)
|
||||
with open(SCHEMA_VALIDATOR_PATH) as f:
|
||||
validator = json.load(f)
|
||||
jsonschema.validate(instance=data, schema=validator)
|
||||
|
||||
|
||||
class TestTorqueOptionGeneration:
|
||||
def test_torque_versions_match_generated_options(self, schema):
|
||||
versions = _load_torque_versions()
|
||||
assert versions, "latcontrol_torque_versions.json must have at least one version"
|
||||
expected = _build_torque_options(versions)
|
||||
item = _find_item(schema, "TorqueControlTune")
|
||||
assert item is not None, "TorqueControlTune item must be present"
|
||||
assert item.get("options") == expected
|
||||
|
||||
def test_torque_versions_path_resolves(self):
|
||||
assert os.path.exists(TORQUE_VERSIONS_PATH), (
|
||||
f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
|
||||
)
|
||||
|
||||
|
||||
class TestReleaseBranchGates:
|
||||
@pytest.mark.parametrize("key", [
|
||||
"EnableGithubRunner",
|
||||
"QuickBootToggle",
|
||||
])
|
||||
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
|
||||
"""sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate)."""
|
||||
item = _find_item(schema, key)
|
||||
assert item is not None, f"{key} not found in schema"
|
||||
rules = (item.get("visibility") or []) + (item.get("enablement") or [])
|
||||
assert _references_capability_field(rules, "is_sp_release"), f"{key} missing is_sp_release gate"
|
||||
|
||||
|
||||
class TestSpuriousOffroadGatesDropped:
|
||||
def test_disengage_on_accelerator_has_no_offroad_only(self, schema):
|
||||
item = _find_item(schema, "DisengageOnAccelerator")
|
||||
assert item is not None
|
||||
assert "offroad_only" not in _flatten_rule_types(item.get("enablement"))
|
||||
|
||||
def test_dynamic_experimental_has_no_offroad_only(self, schema):
|
||||
item = _find_item(schema, "DynamicExperimentalControl")
|
||||
assert item is not None
|
||||
assert "offroad_only" not in _flatten_rule_types(item.get("enablement"))
|
||||
|
||||
|
||||
class TestNotEngagedReplacement:
|
||||
@pytest.mark.parametrize("key", [
|
||||
"AlphaLongitudinalEnabled",
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
])
|
||||
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
|
||||
"""These items should use not_engaged, not offroad_only."""
|
||||
item = _find_item(schema, key)
|
||||
assert item is not None, f"{key} not found"
|
||||
rule_types = _flatten_rule_types(item.get("enablement"))
|
||||
assert "offroad_only" not in rule_types, f"{key} still uses offroad_only"
|
||||
assert "not_engaged" in rule_types, f"{key} missing not_engaged"
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
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 pytest
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
|
||||
SCHEMA_VERSION,
|
||||
generate_schema,
|
||||
generate_schema_json,
|
||||
collect_all_keys,
|
||||
collect_capability_refs,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
|
||||
|
||||
|
||||
VALID_WIDGET_TYPES = {"toggle", "option", "multiple_button", "button", "info"}
|
||||
VALID_RULE_TYPES = {"offroad_only", "not_engaged", "capability", "param", "param_compare", "not", "any", "all"}
|
||||
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
|
||||
MAX_ALLOWED_MISSING_TITLES = 0 # All items must have titles (metadata is inline in settings_ui.json)
|
||||
|
||||
|
||||
def _iter_panel_items(panel: dict):
|
||||
"""Yield top-level items from a panel (non-recursive)."""
|
||||
for item in panel.get("items", []):
|
||||
yield item
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield item
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
yield item
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield item
|
||||
|
||||
|
||||
def _iter_all_sub_panels(panel: dict):
|
||||
"""Yield all sub_panels from a panel and its sections."""
|
||||
yield from panel.get("sub_panels", [])
|
||||
for section in panel.get("sections", []):
|
||||
yield from section.get("sub_panels", [])
|
||||
|
||||
|
||||
def _brand_items(brand_data) -> list[dict]:
|
||||
"""Extract items from vehicle_settings[brand] (handles dict or list)."""
|
||||
if isinstance(brand_data, dict):
|
||||
return brand_data.get("items", [])
|
||||
if isinstance(brand_data, list):
|
||||
return brand_data
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema():
|
||||
return generate_schema()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def all_param_keys():
|
||||
"""All keys registered in the device param store."""
|
||||
return {k.decode("utf-8") for k in Params().all_keys()}
|
||||
|
||||
|
||||
class TestSchemaStructure:
|
||||
def test_schema_is_valid_json(self):
|
||||
"""Schema serializes to valid JSON."""
|
||||
raw = generate_schema_json()
|
||||
parsed = json.loads(raw)
|
||||
assert isinstance(parsed, dict)
|
||||
|
||||
def test_has_required_top_level_fields(self, schema):
|
||||
assert "schema_version" in schema
|
||||
assert schema["schema_version"] == SCHEMA_VERSION
|
||||
assert "generated_at" in schema
|
||||
assert "panels" in schema
|
||||
assert "vehicle_settings" in schema
|
||||
assert "capability_fields" in schema
|
||||
|
||||
def test_panels_are_list(self, schema):
|
||||
assert isinstance(schema["panels"], list)
|
||||
assert len(schema["panels"]) > 0
|
||||
|
||||
def test_all_panels_have_required_fields(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
assert "id" in panel, f"Panel missing 'id': {panel}"
|
||||
assert "label" in panel, f"Panel {panel.get('id')} missing 'label'"
|
||||
assert "order" in panel, f"Panel {panel.get('id')} missing 'order'"
|
||||
has_sections = "sections" in panel
|
||||
has_items = "items" in panel
|
||||
assert has_sections or has_items, \
|
||||
f"Panel {panel['id']} must have 'sections' or 'items'"
|
||||
if has_sections:
|
||||
assert isinstance(panel["sections"], list)
|
||||
for sec in panel["sections"]:
|
||||
assert "id" in sec, f"Section in panel {panel['id']} missing 'id'"
|
||||
assert "items" in sec, f"Section {panel['id']}.{sec.get('id')} missing 'items'"
|
||||
assert isinstance(sec["items"], list)
|
||||
if has_items:
|
||||
assert isinstance(panel["items"], list)
|
||||
|
||||
def test_all_items_have_key_and_widget(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
assert "key" in item, f"Item in panel {panel['id']} missing 'key'"
|
||||
assert "widget" in item, f"Item {item.get('key')} missing 'widget'"
|
||||
assert item["widget"] in VALID_WIDGET_TYPES, \
|
||||
f"Item {item['key']} has invalid widget type: {item['widget']}"
|
||||
|
||||
def test_sub_panel_items_have_key_and_widget(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
for sp in _iter_all_sub_panels(panel):
|
||||
assert "id" in sp
|
||||
assert "items" in sp
|
||||
for item in sp["items"]:
|
||||
assert "key" in item
|
||||
assert "widget" in item
|
||||
assert item["widget"] in VALID_WIDGET_TYPES
|
||||
|
||||
def test_vehicle_settings_structure(self, schema):
|
||||
vs = schema["vehicle_settings"]
|
||||
assert isinstance(vs, dict)
|
||||
for brand, data in vs.items():
|
||||
assert isinstance(brand, str)
|
||||
assert isinstance(data, dict), \
|
||||
f"vehicle_settings[{brand}] must be a dict {{title, items, ...}}"
|
||||
assert "items" in data, f"vehicle_settings[{brand}] missing 'items'"
|
||||
assert isinstance(data["items"], list)
|
||||
for item in data["items"]:
|
||||
assert "key" in item, f"Vehicle item for {brand} missing 'key'"
|
||||
assert "widget" in item, f"Vehicle item {item.get('key')} missing 'widget'"
|
||||
|
||||
def test_no_duplicate_keys_across_panels(self, schema):
|
||||
"""Param keys should appear in at most one panel."""
|
||||
seen: dict[str, str] = {} # key -> panel_id
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
key = item["key"]
|
||||
if key in seen:
|
||||
pytest.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'")
|
||||
seen[key] = panel["id"]
|
||||
for sub in item.get("sub_items", []):
|
||||
sub_key = sub["key"]
|
||||
if sub_key in seen:
|
||||
pytest.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'")
|
||||
seen[sub_key] = panel["id"]
|
||||
|
||||
|
||||
class TestSchemaCoverage:
|
||||
def test_all_schema_keys_exist_in_params(self, schema, all_param_keys):
|
||||
"""Schema keys must exist in Params().all_keys()."""
|
||||
schema_keys = collect_all_keys(schema)
|
||||
missing = schema_keys - all_param_keys
|
||||
assert not missing, f"Schema references keys not in Params: {missing}"
|
||||
|
||||
def test_all_capability_fields_are_declared(self, schema):
|
||||
"""Capability fields used in rules must be declared."""
|
||||
declared = set(schema["capability_fields"])
|
||||
referenced = collect_capability_refs(schema)
|
||||
undeclared = referenced - declared
|
||||
assert not undeclared, f"Rules reference undeclared capability fields: {undeclared}"
|
||||
|
||||
def test_capability_fields_match_constant(self, schema):
|
||||
"""Schema capability_fields must match CAPABILITY_FIELDS constant."""
|
||||
assert set(schema["capability_fields"]) == set(CAPABILITY_FIELDS)
|
||||
|
||||
|
||||
class TestRuleWellFormedness:
|
||||
def _validate_rule(self, rule: dict, context: str = ""):
|
||||
"""Recursively validate a single rule dict."""
|
||||
assert "type" in rule, f"Rule missing 'type' in {context}"
|
||||
rtype = rule["type"]
|
||||
assert rtype in VALID_RULE_TYPES, f"Invalid rule type '{rtype}' in {context}"
|
||||
|
||||
if rtype == "capability":
|
||||
assert "field" in rule, f"Capability rule missing 'field' in {context}"
|
||||
assert "equals" in rule, f"Capability rule missing 'equals' in {context}"
|
||||
elif rtype == "param":
|
||||
assert "key" in rule, f"Param rule missing 'key' in {context}"
|
||||
assert "equals" in rule, f"Param rule missing 'equals' in {context}"
|
||||
elif rtype == "param_compare":
|
||||
assert "key" in rule, f"Param compare rule missing 'key' in {context}"
|
||||
assert "op" in rule, f"Param compare rule missing 'op' in {context}"
|
||||
assert rule["op"] in VALID_COMPARE_OPS, f"Invalid op '{rule['op']}' in {context}"
|
||||
assert "value" in rule, f"Param compare rule missing 'value' in {context}"
|
||||
elif rtype == "not":
|
||||
assert "condition" in rule, f"Not rule missing 'condition' in {context}"
|
||||
self._validate_rule(rule["condition"], context=f"{context} > not")
|
||||
elif rtype in ("any", "all"):
|
||||
assert "conditions" in rule, f"{rtype} rule missing 'conditions' in {context}"
|
||||
assert isinstance(rule["conditions"], list)
|
||||
for c in rule["conditions"]:
|
||||
self._validate_rule(c, context=f"{context} > {rtype}")
|
||||
|
||||
def _validate_items(self, items: list[dict], context: str):
|
||||
for item in items:
|
||||
key = item.get("key", "unknown")
|
||||
for rules_field in ("visibility", "enablement"):
|
||||
rules = item.get(rules_field)
|
||||
if rules:
|
||||
assert isinstance(rules, list), f"{key}.{rules_field} must be a list"
|
||||
for rule in rules:
|
||||
self._validate_rule(rule, context=f"{context}.{key}.{rules_field}")
|
||||
for sub in item.get("sub_items", []):
|
||||
self._validate_items([sub], context=f"{context}.{key}")
|
||||
|
||||
def _validate_section_rules(self, section: dict, context: str):
|
||||
for rules_field in ("visibility", "enablement"):
|
||||
rules = section.get(rules_field) or []
|
||||
for rule in rules:
|
||||
self._validate_rule(rule, context=f"{context}.{rules_field}")
|
||||
|
||||
def test_all_panel_rules_well_formed(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
self._validate_items(list(_iter_panel_items(panel)), context=f"panel:{panel['id']}")
|
||||
for sp in _iter_all_sub_panels(panel):
|
||||
self._validate_items(sp["items"], context=f"subpanel:{sp['id']}")
|
||||
for section in panel.get("sections", []):
|
||||
self._validate_section_rules(section, context=f"section:{panel['id']}.{section['id']}")
|
||||
|
||||
def test_all_vehicle_rules_well_formed(self, schema):
|
||||
for brand, data in schema["vehicle_settings"].items():
|
||||
self._validate_items(_brand_items(data), context=f"vehicle:{brand}")
|
||||
|
||||
def test_no_self_referencing_visibility(self, schema):
|
||||
"""An item's visibility/enablement rules should not depend on its own key."""
|
||||
def _check_self_ref(item: dict, rules_field: str):
|
||||
key = item.get("key")
|
||||
for rule in item.get(rules_field, []):
|
||||
if rule.get("type") == "param" and rule.get("key") == key:
|
||||
pytest.fail(f"Item {key} has self-referencing {rules_field} rule")
|
||||
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
_check_self_ref(item, "visibility")
|
||||
_check_self_ref(item, "enablement")
|
||||
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
for item in _brand_items(brand_data):
|
||||
_check_self_ref(item, "visibility")
|
||||
_check_self_ref(item, "enablement")
|
||||
|
||||
|
||||
class TestKnownPanels:
|
||||
def test_expected_panels_exist(self, schema):
|
||||
panel_ids = {p["id"] for p in schema["panels"]}
|
||||
expected = {"steering", "cruise", "display", "visuals", "device", "software", "developer"}
|
||||
assert expected.issubset(panel_ids), f"Missing panels: {expected - panel_ids}"
|
||||
|
||||
def test_mads_sub_panel_exists(self, schema):
|
||||
steering = next(p for p in schema["panels"] if p["id"] == "steering")
|
||||
sub_ids = {sp["id"] for sp in _iter_all_sub_panels(steering)}
|
||||
assert "mads_settings" in sub_ids
|
||||
|
||||
def test_mutual_exclusion_torque_nnlc(self, schema):
|
||||
"""EnforceTorqueControl and NNLC must reference each other in enablement."""
|
||||
torque = nnlc = None
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
if item["key"] == "EnforceTorqueControl":
|
||||
torque = item
|
||||
elif item["key"] == "NeuralNetworkLateralControl":
|
||||
nnlc = item
|
||||
assert torque is not None, "EnforceTorqueControl item missing"
|
||||
assert nnlc is not None, "NeuralNetworkLateralControl item missing"
|
||||
torque_enable_keys = {r.get("key") for r in torque.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "NeuralNetworkLateralControl" in torque_enable_keys
|
||||
nnlc_enable_keys = {r.get("key") for r in nnlc.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "EnforceTorqueControl" in nnlc_enable_keys
|
||||
|
||||
|
||||
class TestKnownVehicleSettings:
|
||||
def test_hyundai_has_longitudinal_tuning(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))}
|
||||
assert "HyundaiLongitudinalTuning" in keys
|
||||
|
||||
def test_toyota_has_enforce_stock_and_stop_go(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
|
||||
assert "ToyotaEnforceStockLongitudinal" in keys
|
||||
assert "ToyotaStopAndGoHack" in keys
|
||||
|
||||
def test_tesla_has_coop_steering(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
|
||||
assert "TeslaCoopSteering" in keys
|
||||
|
||||
def test_subaru_has_stop_and_go(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("subaru"))}
|
||||
assert "SubaruStopAndGo" in keys
|
||||
assert "SubaruStopAndGoManualParkingBrake" in keys
|
||||
|
||||
|
||||
class TestItemCompleteness:
|
||||
def _collect_all_items(self, schema):
|
||||
"""Collect all items and sub_items from panels and vehicle_settings."""
|
||||
items = []
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
items.append(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
items.append(sub)
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
for item in _brand_items(brand_data):
|
||||
items.append(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
items.append(sub)
|
||||
return items
|
||||
|
||||
def test_all_items_have_titles(self, schema):
|
||||
"""All items must have titles."""
|
||||
missing = [i["key"] for i in self._collect_all_items(schema) if "title" not in i]
|
||||
if len(missing) > MAX_ALLOWED_MISSING_TITLES:
|
||||
pytest.fail(f"Items without titles ({len(missing)}): {missing[:10]}")
|
||||
|
||||
def test_no_default_titles(self, schema):
|
||||
"""Item titles must differ from keys."""
|
||||
defaults = [i["key"] for i in self._collect_all_items(schema) if i.get("title") == i["key"]]
|
||||
assert not defaults, f"Items with default titles (title == key): {defaults}"
|
||||
|
||||
def test_options_structure(self, schema):
|
||||
"""Options must be a list of {value, label} dicts."""
|
||||
for item in self._collect_all_items(schema):
|
||||
opts = item.get("options")
|
||||
if opts is None:
|
||||
continue
|
||||
assert isinstance(opts, list), f"{item['key']}: options must be a list"
|
||||
for opt in opts:
|
||||
assert isinstance(opt, dict), f"{item['key']}: each option must be a dict"
|
||||
assert "value" in opt, f"{item['key']}: option missing 'value': {opt}"
|
||||
assert "label" in opt, f"{item['key']}: option missing 'label': {opt}"
|
||||
|
||||
def test_numeric_constraints(self, schema):
|
||||
"""If any of min/max/step is present, all three must be, and min < max."""
|
||||
for item in self._collect_all_items(schema):
|
||||
has_min = "min" in item
|
||||
has_max = "max" in item
|
||||
has_step = "step" in item
|
||||
if has_min or has_max or has_step:
|
||||
assert has_min and has_max and has_step, \
|
||||
f"{item['key']}: must have all of min/max/step or none"
|
||||
assert item["min"] < item["max"], \
|
||||
f"{item['key']}: min ({item['min']}) must be < max ({item['max']})"
|
||||
|
||||
def test_known_param_has_options(self, schema):
|
||||
"""LongitudinalPersonality should have 3 options."""
|
||||
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
|
||||
lp = next((i for i in _iter_panel_items(cruise) if i["key"] == "LongitudinalPersonality"), None)
|
||||
assert lp is not None
|
||||
assert "options" in lp
|
||||
assert len(lp["options"]) == 3
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
#!/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.
|
||||
|
||||
Scans all per-page YAML files in settings_ui_src/pages/ and replaces inlined
|
||||
rule blocks that exactly match a macro definition in _macros.yaml with $ref
|
||||
references.
|
||||
|
||||
Match policy:
|
||||
- Whole-list match (exact): replace the entire list with [{$ref}].
|
||||
- Single-rule match: any individual rule whose canonical form equals macro[0]
|
||||
(when macro is exactly one rule) is replaced with {$ref}.
|
||||
|
||||
Re-run is idempotent (already-substituted $refs are skipped).
|
||||
|
||||
Usage:
|
||||
python apply_macros.py [--src DIR] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
def _canon(node) -> str:
|
||||
return json.dumps(node, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _make_ref(name: str) -> dict:
|
||||
return {"$ref": f"#/macros/{name}"}
|
||||
|
||||
|
||||
def _substitute_rules(rules: list, whole_list: dict, single_rule: dict) -> list:
|
||||
if not isinstance(rules, list) or not rules:
|
||||
return rules
|
||||
full_canon = _canon(rules)
|
||||
if full_canon in whole_list:
|
||||
return [_make_ref(whole_list[full_canon])]
|
||||
out = []
|
||||
for r in rules:
|
||||
if _is_ref(r):
|
||||
out.append(r)
|
||||
continue
|
||||
rc = _canon(r)
|
||||
if rc in single_rule:
|
||||
out.append(_make_ref(single_rule[rc]))
|
||||
else:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _walk_item(item: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in item:
|
||||
new = _substitute_rules(item[ctx], whole_list, single_rule)
|
||||
if new != item[ctx]:
|
||||
item[ctx] = new
|
||||
changed = True
|
||||
if "options" in item:
|
||||
for opt in item["options"]:
|
||||
if not isinstance(opt, dict):
|
||||
continue
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
new = _substitute_rules(opt[ctx], whole_list, single_rule)
|
||||
if new != opt[ctx]:
|
||||
opt[ctx] = new
|
||||
changed = True
|
||||
if "sub_items" in item:
|
||||
for sub in item["sub_items"]:
|
||||
if _walk_item(sub, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _walk_page(page: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for sec in page.get("sections", []) or []:
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in sec:
|
||||
new = _substitute_rules(sec[ctx], whole_list, single_rule)
|
||||
if new != sec[ctx]:
|
||||
sec[ctx] = new
|
||||
changed = True
|
||||
for it in sec.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in sec.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for it in page.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in page.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _build_macro_indices(macros: dict) -> tuple[dict, dict]:
|
||||
whole_list = {}
|
||||
single_rule = {}
|
||||
for name, body in macros.items():
|
||||
if isinstance(body, list):
|
||||
whole_list[_canon(body)] = name
|
||||
if len(body) == 1 and isinstance(body[0], dict):
|
||||
single_rule[_canon(body[0])] = name
|
||||
elif isinstance(body, dict):
|
||||
single_rule[_canon(body)] = name
|
||||
return whole_list, single_rule
|
||||
|
||||
|
||||
def _read_yaml_with_header(path: str) -> tuple[list[str], dict]:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
header_lines = []
|
||||
for line in text.splitlines(keepends=True):
|
||||
if line.startswith("#"):
|
||||
header_lines.append(line)
|
||||
elif line.strip() == "":
|
||||
header_lines.append(line)
|
||||
else:
|
||||
break
|
||||
doc = yaml.safe_load(text) or {}
|
||||
return header_lines, doc
|
||||
|
||||
|
||||
def _write_yaml_with_header(path: str, header: list[str], doc: dict) -> None:
|
||||
with open(path, "w") as f:
|
||||
if header:
|
||||
# Keep at most one trailing blank line in header
|
||||
while len(header) > 1 and header[-1].strip() == "" and header[-2].strip() == "":
|
||||
header.pop()
|
||||
for line in header:
|
||||
f.write(line)
|
||||
f.write(_dump_yaml(doc))
|
||||
|
||||
|
||||
def apply(src_dir: str, dry_run: bool) -> int:
|
||||
with open(os.path.join(src_dir, "_macros.yaml")) as f:
|
||||
macros_doc = yaml.safe_load(f) or {}
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
whole_list, single_rule = _build_macro_indices(macros)
|
||||
|
||||
changed_files = 0
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")) or fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
header, doc = _read_yaml_with_header(path)
|
||||
if _walk_page(doc, whole_list, single_rule):
|
||||
changed_files += 1
|
||||
if not dry_run:
|
||||
_write_yaml_with_header(path, header, doc)
|
||||
else:
|
||||
print(f"would-rewrite: {path}")
|
||||
|
||||
print(f"{'Would rewrite' if dry_run else 'Rewrote'} {changed_files} files")
|
||||
return 0
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Substitute inlined rule blocks with $ref macros across pages/.")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return apply(args.src, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
#!/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.
|
||||
|
||||
Reads settings_ui_src/ (the dev-friendly authoring tree) and emits the
|
||||
canonical settings_ui.json that the device generator + frontend consume.
|
||||
|
||||
Source layout:
|
||||
_macros.yaml # named rule fragments
|
||||
pages/<page_id>.yaml # one file per panel/page
|
||||
pages/vehicle.yaml # special: emits vehicle_settings
|
||||
|
||||
Each page.yaml contains the full panel: metadata + sections + items + sub_panels
|
||||
inline. Sub-panels are nested inside the section they belong to. Items appear
|
||||
in the order written in the file.
|
||||
|
||||
Macro references use JSON-Schema-style $ref pointers:
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
|
||||
Macros may reference other macros (max depth 3). Cycles raise an error.
|
||||
|
||||
Usage:
|
||||
python compile_settings_ui.py [--src DIR] [--out PATH] [--check]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
DEFAULT_OUT = os.path.join(DIR, "settings_ui.json")
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
MAX_MACRO_DEPTH = 3
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _load_yaml(path: str):
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _ref_name(node: dict) -> str:
|
||||
ref = node["$ref"]
|
||||
if not isinstance(ref, str) or not ref.startswith("#/macros/"):
|
||||
raise CompileError(f"unsupported $ref: {ref!r} (must start with '#/macros/')")
|
||||
return ref[len("#/macros/"):]
|
||||
|
||||
|
||||
def _resolve_refs(node, macros: dict, visiting: tuple[str, ...] = ()):
|
||||
"""Resolve $ref nodes against macros. Recursive; depth-limited; cycle-safe.
|
||||
|
||||
- $ref in list-context: macro's list spliced into parent list.
|
||||
- $ref in scalar-context (object value): macro's value substitutes.
|
||||
- Non-list macro values may not be referenced from list contexts.
|
||||
"""
|
||||
if _is_ref(node):
|
||||
name = _ref_name(node)
|
||||
if name in visiting:
|
||||
raise CompileError(f"$ref cycle: {' -> '.join(visiting + (name,))}")
|
||||
if len(visiting) >= MAX_MACRO_DEPTH:
|
||||
raise CompileError(f"$ref nesting exceeds depth {MAX_MACRO_DEPTH}: {' -> '.join(visiting + (name,))}")
|
||||
if name not in macros:
|
||||
raise CompileError(f"unknown macro: {name}")
|
||||
return _resolve_refs(copy.deepcopy(macros[name]), macros, visiting + (name,))
|
||||
|
||||
if isinstance(node, dict):
|
||||
return {k: _resolve_refs(v, macros, visiting) for k, v in node.items()}
|
||||
|
||||
if isinstance(node, list):
|
||||
out = []
|
||||
for item in node:
|
||||
if _is_ref(item):
|
||||
resolved = _resolve_refs(item, macros, visiting)
|
||||
if not isinstance(resolved, list):
|
||||
raise CompileError(
|
||||
f"macro '{_ref_name(item)}' must resolve to a list when used in a list context"
|
||||
)
|
||||
out.extend(resolved)
|
||||
else:
|
||||
out.append(_resolve_refs(item, macros, visiting))
|
||||
return out
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# Output JSON key order. Mirrors the conventions in the original hand-written
|
||||
# settings_ui.json so structural diffs after extraction are minimal.
|
||||
_ITEM_KEY_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _canon_item(item: dict, macros: dict) -> dict:
|
||||
resolved = dict(item)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in resolved:
|
||||
resolved[ctx] = _resolve_refs(resolved[ctx], macros)
|
||||
if "options" in resolved:
|
||||
new_opts = []
|
||||
for opt in resolved["options"]:
|
||||
if isinstance(opt, dict):
|
||||
opt = dict(opt)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
opt[ctx] = _resolve_refs(opt[ctx], macros)
|
||||
new_opts.append(opt)
|
||||
resolved["options"] = new_opts
|
||||
if "sub_items" in resolved:
|
||||
resolved["sub_items"] = [_canon_item(s, macros) for s in resolved["sub_items"]]
|
||||
|
||||
out: dict = {}
|
||||
for k in _ITEM_KEY_ORDER:
|
||||
if k in resolved:
|
||||
out[k] = resolved[k]
|
||||
for k, v in resolved.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_section(section: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
if "description" in section:
|
||||
out["description"] = section["description"]
|
||||
for k in ("visibility", "enablement"):
|
||||
if k in section:
|
||||
out[k] = _resolve_refs(section[k], macros)
|
||||
if "attestation_required" in section:
|
||||
out["attestation_required"] = section["attestation_required"]
|
||||
out["items"] = [_canon_item(i, macros) for i in section.get("items", [])]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out and k != "order":
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_sub_panel(sp: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = _resolve_refs(sp["trigger_condition"], macros)
|
||||
out["items"] = [_canon_item(i, macros) for i in sp.get("items", [])]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_panel(page: dict, macros: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": page["id"],
|
||||
"label": page["label"],
|
||||
"icon": page["icon"],
|
||||
"order": page["order"],
|
||||
}
|
||||
if "remote_configurable" in page:
|
||||
out["remote_configurable"] = page["remote_configurable"]
|
||||
if "description" in page:
|
||||
out["description"] = page["description"]
|
||||
if "sections" in page and page["sections"]:
|
||||
out["sections"] = [_canon_section(s, macros) for s in page["sections"]]
|
||||
if "items" in page and page["items"]:
|
||||
out["items"] = [_canon_item(i, macros) for i in page["items"]]
|
||||
if "sub_panels" in page and page["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in page["sub_panels"]]
|
||||
return out
|
||||
|
||||
|
||||
def _canon_vehicle(page: dict, macros: dict) -> dict:
|
||||
"""Convert page-shape vehicle.yaml to wire-format vehicle_settings dict."""
|
||||
out: dict = {}
|
||||
for sec in page.get("sections", []):
|
||||
brand = sec["id"]
|
||||
brand_out: dict = {"title": sec.get("title", "")}
|
||||
if "description" in sec:
|
||||
brand_out["description"] = sec["description"]
|
||||
brand_out["items"] = [_canon_item(i, macros) for i in sec.get("items", [])]
|
||||
out[brand] = brand_out
|
||||
return out
|
||||
|
||||
|
||||
def _load_pages(src: str) -> list[dict]:
|
||||
pages_dir = os.path.join(src, "pages")
|
||||
if not os.path.isdir(pages_dir):
|
||||
return []
|
||||
pages = []
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
if fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
page = _load_yaml(path)
|
||||
if not isinstance(page, dict):
|
||||
raise CompileError(f"{path}: page YAML must be an object")
|
||||
if "id" not in page:
|
||||
raise CompileError(f"{path}: page missing 'id'")
|
||||
page["__source"] = path
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def compile_schema(src: str) -> dict:
|
||||
macros_doc = _load_yaml(os.path.join(src, "_macros.yaml"))
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
|
||||
pages = _load_pages(src)
|
||||
|
||||
panels_out = []
|
||||
vehicle_out: dict = {}
|
||||
|
||||
# Order panels by `order` field (falling back to file position).
|
||||
panel_pages = [p for p in pages if p.get("kind") != "vehicle"]
|
||||
panel_pages.sort(key=lambda p: (p.get("order", 999), p["id"]))
|
||||
|
||||
for page in panel_pages:
|
||||
panels_out.append(_canon_panel(page, macros))
|
||||
|
||||
for page in pages:
|
||||
if page.get("kind") == "vehicle":
|
||||
vehicle_out = _canon_vehicle(page, macros)
|
||||
|
||||
return {
|
||||
"$schema": "./settings_ui.schema.json",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"panels": panels_out,
|
||||
"vehicle_settings": vehicle_out,
|
||||
}
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Compile settings_ui_src/ -> settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--out", default=DEFAULT_OUT)
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Compile and diff against existing settings_ui.json; exit non-zero on diff.")
|
||||
args = parser.parse_args()
|
||||
|
||||
schema = compile_schema(args.src)
|
||||
rendered = json.dumps(schema, indent=2) + "\n"
|
||||
|
||||
if args.check:
|
||||
if not os.path.exists(args.out):
|
||||
print(f"--check: {args.out} does not exist", file=sys.stderr)
|
||||
return 1
|
||||
with open(args.out) as f:
|
||||
current = f.read()
|
||||
if current.strip() == rendered.strip():
|
||||
print(f"--check: {args.out} matches compiled output")
|
||||
return 0
|
||||
print(f"--check: {args.out} differs from compiled output", file=sys.stderr)
|
||||
cur_obj = json.loads(current)
|
||||
if cur_obj == schema:
|
||||
print("(structurally equal; only formatting differs)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(args.out, "w") as f:
|
||||
f.write(rendered)
|
||||
print(f"Wrote {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/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.
|
||||
|
||||
One-shot extractor: settings_ui.json -> settings_ui_src/pages/*.yaml.
|
||||
|
||||
Reads the existing monolithic settings_ui.json and produces one YAML file per
|
||||
page (panel) plus pages/vehicle.yaml for the per-brand settings. Macros remain
|
||||
unwritten by extract; populate _macros.yaml separately.
|
||||
|
||||
Usage:
|
||||
python extract_settings_ui.py [--src DIR] [--definition PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_DEFINITION = os.path.join(DIR, "settings_ui.json")
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
# Item field order in per-page YAML (mirrors settings_ui.json conventions).
|
||||
_ITEM_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _ordered_item(item: dict) -> dict:
|
||||
out: dict = {}
|
||||
for k in _ITEM_ORDER:
|
||||
if k in item:
|
||||
v = item[k]
|
||||
if k == "sub_items":
|
||||
v = [_ordered_item(s) for s in v]
|
||||
out[k] = v
|
||||
for k, v in item.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_section(section: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
for f in ("description", "order", "visibility", "enablement", "attestation_required"):
|
||||
if f in section:
|
||||
out[f] = section[f]
|
||||
if "items" in section:
|
||||
out["items"] = [_ordered_item(i) for i in section["items"]]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_sub_panel(sp: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = sp["trigger_condition"]
|
||||
if "items" in sp:
|
||||
out["items"] = [_ordered_item(i) for i in sp["items"]]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_page(panel: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": panel["id"],
|
||||
"label": panel["label"],
|
||||
"icon": panel["icon"],
|
||||
"order": panel["order"],
|
||||
}
|
||||
for f in ("remote_configurable", "description"):
|
||||
if f in panel:
|
||||
out[f] = panel[f]
|
||||
if "sections" in panel and panel["sections"]:
|
||||
out["sections"] = [_ordered_section(s) for s in panel["sections"]]
|
||||
if "items" in panel and panel["items"]:
|
||||
out["items"] = [_ordered_item(i) for i in panel["items"]]
|
||||
if "sub_panels" in panel and panel["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in panel["sub_panels"]]
|
||||
for k, v in panel.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_vehicle_page(vehicle_settings: dict) -> dict:
|
||||
"""Convert wire-format vehicle_settings dict into a page-shape YAML.
|
||||
|
||||
Brand becomes a section id. Each brand's items become section items.
|
||||
"""
|
||||
sections = []
|
||||
for brand in sorted(vehicle_settings.keys()):
|
||||
bd = vehicle_settings[brand]
|
||||
items = bd.get("items", []) if isinstance(bd, dict) else bd
|
||||
sec: dict = {
|
||||
"id": brand,
|
||||
"title": bd.get("title", "") if isinstance(bd, dict) else "",
|
||||
}
|
||||
if isinstance(bd, dict) and "description" in bd:
|
||||
sec["description"] = bd["description"]
|
||||
sec["items"] = [_ordered_item(i) for i in items]
|
||||
sections.append(sec)
|
||||
return {
|
||||
"id": "vehicle",
|
||||
"label": "Vehicle",
|
||||
"icon": "vehicle",
|
||||
"order": 99,
|
||||
"kind": "vehicle", # signals to compiler: emit as vehicle_settings, not panels
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def extract(definition_path: str, src_dir: str) -> None:
|
||||
with open(definition_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
shutil.rmtree(pages_dir)
|
||||
os.makedirs(pages_dir)
|
||||
|
||||
count = 0
|
||||
for panel in data.get("panels", []):
|
||||
page = _ordered_page(panel)
|
||||
path = os.path.join(pages_dir, f"{panel['id']}.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write(f"# Page: {panel['id']}\n")
|
||||
f.write("# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.\n")
|
||||
f.write(_dump_yaml(page))
|
||||
count += 1
|
||||
|
||||
vehicle_settings = data.get("vehicle_settings", {})
|
||||
if vehicle_settings:
|
||||
vehicle_page = _ordered_vehicle_page(vehicle_settings)
|
||||
path = os.path.join(pages_dir, "vehicle.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write("# Page: vehicle (per-brand settings)\n")
|
||||
f.write("# Compiles to settings_ui.json#vehicle_settings (brand = section id).\n")
|
||||
f.write(_dump_yaml(vehicle_page))
|
||||
count += 1
|
||||
|
||||
# Ensure _macros.yaml exists
|
||||
macros_path = os.path.join(src_dir, "_macros.yaml")
|
||||
if not os.path.exists(macros_path):
|
||||
with open(macros_path, "w") as f:
|
||||
f.write("# Named rule fragments. Reference from pages via {$ref: \"#/macros/<name>\"}.\n")
|
||||
f.write(_dump_yaml({"macros": {}}))
|
||||
|
||||
print(f"Extracted: {count} pages -> {pages_dir}")
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract settings_ui.json into per-page YAML files.")
|
||||
parser.add_argument("--definition", default=DEFAULT_DEFINITION, help="path to settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC, help="path to settings_ui_src/ output dir")
|
||||
args = parser.parse_args()
|
||||
extract(args.definition, args.src)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/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
|
||||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS, CAPABILITY_LABELS
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFINITION_PATH = os.path.join(_DIR, "settings_ui.json")
|
||||
TORQUE_VERSIONS_PATH = os.path.normpath(
|
||||
os.path.join(_DIR, "..", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json")
|
||||
)
|
||||
|
||||
|
||||
def _load_torque_versions() -> dict:
|
||||
"""Load latcontrol_torque_versions.json so TorqueControlTune options stay in sync."""
|
||||
try:
|
||||
with open(TORQUE_VERSIONS_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _build_torque_options(versions: dict) -> list[dict]:
|
||||
options: list[dict] = [{"value": "", "label": "Default"}]
|
||||
parsed: list[tuple[float, str]] = []
|
||||
for label, info in versions.items():
|
||||
try:
|
||||
parsed.append((float(info["version"]), label))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
for version, label in sorted(parsed, key=lambda kv: kv[0], reverse=True):
|
||||
options.append({"value": version, "label": label})
|
||||
return options
|
||||
|
||||
|
||||
def _inject_dynamic_options(schema: dict) -> None:
|
||||
versions = _load_torque_versions()
|
||||
if not versions:
|
||||
return
|
||||
options = _build_torque_options(versions)
|
||||
|
||||
def visitor(item: dict) -> None:
|
||||
if item.get("key") == "TorqueControlTune":
|
||||
item["options"] = options
|
||||
|
||||
_walk_all_items(schema, visitor)
|
||||
|
||||
|
||||
def _load_definition() -> dict:
|
||||
"""Load settings_ui.json and inject dynamic options sourced from runtime data files."""
|
||||
with open(DEFINITION_PATH) as f:
|
||||
schema = json.load(f)
|
||||
_inject_dynamic_options(schema)
|
||||
return schema
|
||||
|
||||
|
||||
# Public API
|
||||
def generate_schema() -> dict:
|
||||
"""Return the settings_ui.json content augmented with runtime metadata.
|
||||
|
||||
Adds three top-level fields the frontend consumes:
|
||||
- generated_at: ISO timestamp (drives schema-cache freshness checks)
|
||||
- capability_fields: declared CAPABILITY_FIELDS, used for rule validation
|
||||
- capability_labels: human-readable labels for capability_fields
|
||||
"""
|
||||
schema = _load_definition()
|
||||
schema["generated_at"] = datetime.datetime.now(datetime.UTC).isoformat()
|
||||
schema["capability_fields"] = list(CAPABILITY_FIELDS)
|
||||
schema["capability_labels"] = dict(CAPABILITY_LABELS)
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_json() -> str:
|
||||
"""Generate SettingsSchema as a compact JSON string."""
|
||||
return json.dumps(generate_schema(), separators=(",", ":"))
|
||||
|
||||
|
||||
def generate_schema_compressed() -> str:
|
||||
"""Generate SettingsSchema as gzip-compressed, base64-encoded string.
|
||||
|
||||
Compression pipeline:
|
||||
1. JSON serialize (compact, no whitespace)
|
||||
2. UTF-8 encode
|
||||
3. gzip compress
|
||||
4. base64 encode
|
||||
"""
|
||||
raw = json.dumps(generate_schema(), separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
|
||||
|
||||
# Schema introspection utilities
|
||||
def _walk_rules(rules: list[dict] | None, visitor: Callable[[dict], None]) -> None:
|
||||
"""Recursively walk all rules, calling visitor on each leaf rule."""
|
||||
if not rules:
|
||||
return
|
||||
for rule in rules:
|
||||
visitor(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
_walk_rules([rule["condition"]], visitor)
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
_walk_rules(rule["conditions"], visitor)
|
||||
|
||||
|
||||
def _walk_all_items(schema: dict, visitor: Callable[[dict], None]) -> None:
|
||||
"""Walk every item in the schema (panels, sections, sub_panels, sub_items, vehicle_settings)."""
|
||||
def _visit_item(item: dict) -> None:
|
||||
visitor(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
_visit_item(sub)
|
||||
|
||||
for panel in schema.get("panels", []):
|
||||
# Walk section items (V2)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
# Walk flat items (V1)
|
||||
for item in panel.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in items:
|
||||
_visit_item(item)
|
||||
|
||||
|
||||
def collect_all_keys(schema: dict) -> set[str]:
|
||||
"""Collect all param keys referenced in the schema (items + rules)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") in ("param", "param_compare") and "key" in rule:
|
||||
keys.add(rule["key"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_capability_refs(schema: dict) -> set[str]:
|
||||
"""Collect all capability field names referenced in rules."""
|
||||
refs: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") == "capability" and "field" in rule:
|
||||
refs.add(rule["field"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return refs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI: print schema for inspection
|
||||
schema = generate_schema()
|
||||
print(json.dumps(schema, indent=2))
|
||||
print(f"\nTotal panels: {len(schema.get('panels', []))}")
|
||||
print(f"Total vehicle brands: {len(schema.get('vehicle_settings', {}))}")
|
||||
keys = collect_all_keys(schema)
|
||||
print(f"Total unique param keys: {len(keys)}")
|
||||
|
||||
# Show compression stats
|
||||
raw_json = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
compressed = gzip.compress(raw_json)
|
||||
print(f"\nRaw JSON size: {len(raw_json):,} bytes")
|
||||
print(f"Compressed size: {len(compressed):,} bytes")
|
||||
print(f"Compression ratio: {len(compressed)/len(raw_json):.1%}")
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/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.
|
||||
|
||||
Validates settings_ui.json against structural, semantic, and referential integrity constraints.
|
||||
|
||||
Usage:
|
||||
python validate_settings_ui.py
|
||||
python validate_settings_ui.py /path/to/settings_ui.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
|
||||
|
||||
VALID_WIDGETS = {"toggle", "option", "multiple_button", "button", "info"}
|
||||
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
|
||||
|
||||
DEFAULT_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"settings_ui.json",
|
||||
)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
"""Tracks pass/fail for each named check."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.passed: list[str] = []
|
||||
self.failed: list[tuple[str, str]] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def ok(self, name: str) -> None:
|
||||
self.passed.append(name)
|
||||
print(f"OK: {name}")
|
||||
|
||||
def error(self, name: str, details: str) -> None:
|
||||
self.failed.append((name, details))
|
||||
print(f"ERROR: {name}: {details}")
|
||||
|
||||
def warn(self, msg: str) -> None:
|
||||
self.warnings.append(msg)
|
||||
print(f"WARNING: {msg}")
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return len(self.failed) == 0
|
||||
|
||||
def summary(self) -> None:
|
||||
total_passed = len(self.passed)
|
||||
total_failed = len(self.failed)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Summary: {total_passed} checks passed, {total_failed} checks failed")
|
||||
if self.warnings:
|
||||
print(f" {len(self.warnings)} warnings")
|
||||
if self.success:
|
||||
print("Result: PASS")
|
||||
else:
|
||||
print("Result: FAIL")
|
||||
|
||||
|
||||
def validate_rule(rule: dict, path: str, result: ValidationResult,
|
||||
capability_fields: tuple[str, ...]) -> bool:
|
||||
"""Validate a single rule dict. Returns True if valid."""
|
||||
if not isinstance(rule, dict):
|
||||
result.error("rule well-formedness", f"{path}: rule is not a dict: {rule!r}")
|
||||
return False
|
||||
|
||||
rule_type = rule.get("type")
|
||||
if rule_type is None:
|
||||
result.error("rule well-formedness", f"{path}: rule missing 'type' field")
|
||||
return False
|
||||
|
||||
if rule_type in ("offroad_only", "not_engaged"):
|
||||
# Only type required
|
||||
return True
|
||||
|
||||
if rule_type == "capability":
|
||||
valid = True
|
||||
if "field" not in rule or not isinstance(rule["field"], str):
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing/invalid 'field'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: param rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param_compare":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "op" not in rule or rule["op"] not in VALID_COMPARE_OPS:
|
||||
result.error("rule well-formedness",
|
||||
f"{path}: param_compare rule missing/invalid 'op' (must be one of {VALID_COMPARE_OPS})")
|
||||
valid = False
|
||||
if "value" not in rule or not isinstance(rule["value"], (int, float)):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'value' (must be number)")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "not":
|
||||
if "condition" not in rule or not isinstance(rule["condition"], dict):
|
||||
result.error("rule well-formedness", f"{path}: 'not' rule missing/invalid 'condition'")
|
||||
return False
|
||||
return validate_rule(rule["condition"], f"{path}.not", result, capability_fields)
|
||||
|
||||
if rule_type in ("any", "all"):
|
||||
if "conditions" not in rule or not isinstance(rule["conditions"], list):
|
||||
result.error("rule well-formedness", f"{path}: '{rule_type}' rule missing/invalid 'conditions' array")
|
||||
return False
|
||||
valid = True
|
||||
for i, cond in enumerate(rule["conditions"]):
|
||||
if not validate_rule(cond, f"{path}.{rule_type}[{i}]", result, capability_fields):
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
result.error("rule well-formedness", f"{path}: unknown rule type '{rule_type}'")
|
||||
return False
|
||||
|
||||
|
||||
def collect_rules_from_item(item: dict) -> list[tuple[str, list[dict]]]:
|
||||
"""Return list of (context, rules_list) for an item's visibility + enablement."""
|
||||
result = []
|
||||
key = item.get("key", "?")
|
||||
if "visibility" in item:
|
||||
result.append((f"item '{key}' visibility", item["visibility"]))
|
||||
if "enablement" in item:
|
||||
result.append((f"item '{key}' enablement", item["enablement"]))
|
||||
return result
|
||||
|
||||
|
||||
def walk_rules_flat(rules: list[dict]) -> list[dict]:
|
||||
"""Flatten all rules recursively into a single list."""
|
||||
flat: list[dict] = []
|
||||
for rule in rules:
|
||||
flat.append(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
flat.extend(walk_rules_flat([rule["condition"]]))
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
flat.extend(walk_rules_flat(rule["conditions"]))
|
||||
return flat
|
||||
|
||||
|
||||
def collect_all_items(data: dict) -> list[tuple[str, dict]]:
|
||||
"""Collect all items with their location path from the schema.
|
||||
|
||||
Returns (path, item_dict) tuples. Traverses sections, sub_panels, sub_items,
|
||||
and vehicle_settings.
|
||||
"""
|
||||
items: list[tuple[str, dict]] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
|
||||
# Flat items on panel
|
||||
for item in panel.get("items", []):
|
||||
_collect_item(f"panel '{pid}'", item, items)
|
||||
|
||||
# Sections
|
||||
for section in panel.get("sections", []):
|
||||
sid = section.get("id", "?")
|
||||
for item in section.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}'", item, items)
|
||||
for sp in section.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Top-level sub_panels on panel (no section)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Vehicle settings (supports both flat list and { title, items } structure)
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
_collect_item(f"vehicle_settings '{brand}'", item, items)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _collect_item(path: str, item: dict, items: list[tuple[str, dict]]) -> None:
|
||||
"""Recursively collect an item and its sub_items."""
|
||||
items.append((path, item))
|
||||
for sub in item.get("sub_items", []):
|
||||
_collect_item(f"{path} > sub_item", sub, items)
|
||||
|
||||
|
||||
def collect_panel_keys(panel: dict) -> set[str]:
|
||||
"""Collect all item keys within a single panel (sections, sub_panels, sub_items)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _add(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
for sub in item.get("sub_items", []):
|
||||
_add(sub)
|
||||
|
||||
for item in panel.get("items", []):
|
||||
_add(item)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_add(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def check_json_parseable(path: str, result: ValidationResult) -> dict | None:
|
||||
"""Check 1: JSON parseable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
result.ok("JSON parseable")
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
result.error("JSON parseable", str(e))
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
result.error("JSON parseable", f"file not found: {path}")
|
||||
return None
|
||||
|
||||
|
||||
def check_structural(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 2: Required fields on panels, sections, items, sub_panels."""
|
||||
errors: list[str] = []
|
||||
|
||||
for i, panel in enumerate(data.get("panels", [])):
|
||||
for field in ("id", "label", "icon", "order"):
|
||||
if field not in panel:
|
||||
errors.append(f"panels[{i}]: missing required field '{field}'")
|
||||
|
||||
for j, section in enumerate(panel.get("sections", [])):
|
||||
for field in ("id", "title"):
|
||||
if field not in section:
|
||||
errors.append(f"panels[{i}].sections[{j}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(section.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sections[{j}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(panel.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
# Validate items
|
||||
all_items = collect_all_items(data)
|
||||
for path, item in all_items:
|
||||
if "key" not in item:
|
||||
errors.append(f"{path}: item missing required field 'key'")
|
||||
if "widget" not in item:
|
||||
errors.append(f"{path}: item missing required field 'widget'")
|
||||
elif item["widget"] not in VALID_WIDGETS:
|
||||
errors.append(
|
||||
f"{path}: item '{item.get('key', '?')}' has invalid widget '{item['widget']}'"
|
||||
+ f" (must be one of {VALID_WIDGETS})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("structural", "; ".join(errors))
|
||||
else:
|
||||
result.ok("structural")
|
||||
|
||||
|
||||
def check_item_completeness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 3: All items have required metadata (title, options for dropdowns)."""
|
||||
all_items = collect_all_items(data)
|
||||
issues: list[str] = []
|
||||
|
||||
for _path, item in all_items:
|
||||
key = item.get("key", "unknown")
|
||||
if "title" not in item:
|
||||
issues.append(f"{key}: missing 'title'")
|
||||
elif item["title"] == key:
|
||||
issues.append(f"{key}: title must not equal key (use a human-readable title)")
|
||||
widget = item.get("widget")
|
||||
if widget in ("multiple_button", "option") and "options" in item:
|
||||
opts = item["options"]
|
||||
if not isinstance(opts, list):
|
||||
issues.append(f"{key}: options must be a list")
|
||||
else:
|
||||
for opt in opts:
|
||||
if not isinstance(opt, dict) or "value" not in opt or "label" not in opt:
|
||||
issues.append(f"{key}: each option must have 'value' and 'label'")
|
||||
break
|
||||
|
||||
if issues:
|
||||
for issue in issues:
|
||||
result.error("item completeness", issue)
|
||||
else:
|
||||
result.ok("item completeness")
|
||||
|
||||
|
||||
def check_no_duplicate_keys(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 4: No param key appears in more than one panel."""
|
||||
panel_keys: dict[str, list[str]] = {} # key -> list of panel ids
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
keys = collect_panel_keys(panel)
|
||||
for key in keys:
|
||||
panel_keys.setdefault(key, []).append(pid)
|
||||
|
||||
# Also check vehicle_settings keys don't collide with panel keys
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
key = item.get("key")
|
||||
if key:
|
||||
panel_keys.setdefault(key, []).append(f"vehicle_settings.{brand}")
|
||||
|
||||
duplicates = {k: v for k, v in panel_keys.items() if len(v) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"'{k}' in [{', '.join(v)}]" for k, v in duplicates.items())
|
||||
result.error("no duplicate keys", details)
|
||||
else:
|
||||
result.ok("no duplicate keys")
|
||||
|
||||
|
||||
def check_rule_wellformedness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 5: All rules have valid structure."""
|
||||
all_items = collect_all_items(data)
|
||||
|
||||
# Save current error count to detect new errors
|
||||
error_count_before = len(result.failed)
|
||||
|
||||
for path, item in all_items:
|
||||
for ctx, rules in collect_rules_from_item(item):
|
||||
for i, rule in enumerate(rules):
|
||||
validate_rule(rule, f"{path} > {ctx}[{i}]", result, CAPABILITY_FIELDS)
|
||||
|
||||
# Also validate trigger_condition rules on sub_panels
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
validate_rule(sp["trigger_condition"], f"panel '{pid}' > sub_panel '{sp.get('id', '?')}' trigger_condition",
|
||||
result, CAPABILITY_FIELDS)
|
||||
|
||||
if len(result.failed) == error_count_before:
|
||||
result.ok("rule well-formedness")
|
||||
|
||||
|
||||
def check_capability_refs(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 6: All capability rule field values are in CAPABILITY_FIELDS."""
|
||||
all_items = collect_all_items(data)
|
||||
invalid_refs: list[str] = []
|
||||
cap_set = set(CAPABILITY_FIELDS)
|
||||
|
||||
for _path, item in all_items:
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in item '{item.get('key', '?')}'")
|
||||
|
||||
# Also check trigger_conditions
|
||||
for panel in data.get("panels", []):
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
for rule in walk_rules_flat([sp["trigger_condition"]]):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in sub_panel '{sp.get('id', '?')}' trigger_condition")
|
||||
|
||||
if invalid_refs:
|
||||
result.error("capability refs", f"unknown capability fields: {', '.join(invalid_refs)}")
|
||||
else:
|
||||
result.ok("capability refs")
|
||||
|
||||
|
||||
def check_no_self_reference(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 7: Item's rules must not reference the item's own key."""
|
||||
all_items = collect_all_items(data)
|
||||
self_refs: list[str] = []
|
||||
|
||||
for path, item in all_items:
|
||||
key = item.get("key")
|
||||
if not key:
|
||||
continue
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") in ("param", "param_compare") and rule.get("key") == key:
|
||||
self_refs.append(f"'{key}' at {path}")
|
||||
|
||||
if self_refs:
|
||||
result.error("no self-reference", f"items reference their own key: {', '.join(self_refs)}")
|
||||
else:
|
||||
result.ok("no self-reference")
|
||||
|
||||
|
||||
def check_sub_panel_triggers(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 8: Sub-panel trigger_key must reference a key in the same panel."""
|
||||
errors: list[str] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
panel_keys = collect_panel_keys(panel)
|
||||
|
||||
# Check sub_panels at section level
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
# Check top-level sub_panels
|
||||
for sp in panel.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("sub-panel triggers", "; ".join(errors))
|
||||
else:
|
||||
result.ok("sub-panel triggers")
|
||||
|
||||
|
||||
def check_ordering(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 9: Panel order values must be unique."""
|
||||
orders: dict[int, list[str]] = {}
|
||||
for panel in data.get("panels", []):
|
||||
order = panel.get("order")
|
||||
if order is not None:
|
||||
orders.setdefault(order, []).append(panel.get("id", "?"))
|
||||
|
||||
duplicates = {o: ids for o, ids in orders.items() if len(ids) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"order {o}: [{', '.join(ids)}]" for o, ids in duplicates.items())
|
||||
result.error("ordering", f"duplicate order values: {details}")
|
||||
else:
|
||||
result.ok("ordering")
|
||||
|
||||
|
||||
def check_vehicle_brands(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 10: Vehicle settings keys lowercase + each brand has consistent {title, description, items} shape."""
|
||||
vehicle = data.get("vehicle_settings", {})
|
||||
errors: list[str] = []
|
||||
|
||||
for brand, brand_data in vehicle.items():
|
||||
if not isinstance(brand, str) or brand != brand.lower():
|
||||
errors.append(f"non-lowercase brand key: '{brand}'")
|
||||
if not isinstance(brand_data, dict):
|
||||
errors.append(f"brand '{brand}': expected object with {{title, description, items}}, got bare list")
|
||||
continue
|
||||
if "items" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'items'")
|
||||
if "title" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'title' (use empty string for none)")
|
||||
|
||||
if errors:
|
||||
result.error("vehicle brands", "; ".join(errors))
|
||||
else:
|
||||
result.ok("vehicle brands")
|
||||
|
||||
|
||||
def validate(path: str) -> bool:
|
||||
"""Run all validation checks on the given settings_ui.json file.
|
||||
|
||||
Returns True if all checks pass.
|
||||
"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Check 1: JSON parseable
|
||||
data = check_json_parseable(path, result)
|
||||
if data is None:
|
||||
result.summary()
|
||||
return False
|
||||
|
||||
# Checks 2-10
|
||||
check_structural(data, result)
|
||||
check_item_completeness(data, result)
|
||||
check_no_duplicate_keys(data, result)
|
||||
check_rule_wellformedness(data, result)
|
||||
check_capability_refs(data, result)
|
||||
check_no_self_reference(data, result)
|
||||
check_sub_panel_triggers(data, result)
|
||||
check_ordering(data, result)
|
||||
check_vehicle_brands(data, result)
|
||||
|
||||
result.summary()
|
||||
return result.success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH
|
||||
success = validate(target)
|
||||
sys.exit(0 if success else 1)
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import datetime
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openpilot.cereal import log
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.common.utils import get_upload_stream
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import set_core_affinity
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.system.loggerd.xattr_cache import getxattr, setxattr
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
UPLOAD_ATTR_NAME = 'user.sunny.upload'
|
||||
UPLOAD_ATTR_VALUE = b'1'
|
||||
|
||||
MAX_UPLOAD_SIZES = {
|
||||
"qlog": 25*1e6, # can't be too restrictive here since we use qlogs to find
|
||||
# bugs, including ones that can cause massive log sizes
|
||||
"qcam": 5*1e6,
|
||||
}
|
||||
|
||||
allow_sleep = bool(os.getenv("UPLOADER_SLEEP", "1"))
|
||||
force_wifi = os.getenv("FORCEWIFI") is not None
|
||||
fake_upload = os.getenv("FAKEUPLOAD") is not None
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Length": "0"}
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.request = FakeRequest()
|
||||
|
||||
|
||||
def get_directory_sort(d: str) -> list[str]:
|
||||
# ensure old format is sorted sooner
|
||||
o = ["0", ] if d.startswith("2024-") else ["1", ]
|
||||
return o + [s.rjust(10, '0') for s in d.rsplit('--', 1)]
|
||||
|
||||
def listdir_by_creation(d: str) -> list[str]:
|
||||
if not os.path.isdir(d):
|
||||
return []
|
||||
|
||||
try:
|
||||
paths = [f for f in os.listdir(d) if os.path.isdir(os.path.join(d, f))]
|
||||
paths = sorted(paths, key=get_directory_sort)
|
||||
return paths
|
||||
except OSError:
|
||||
cloudlog.exception("listdir_by_creation failed")
|
||||
return []
|
||||
|
||||
def clear_locks(root: str) -> None:
|
||||
for logdir in os.listdir(root):
|
||||
path = os.path.join(root, logdir)
|
||||
try:
|
||||
for fname in os.listdir(path):
|
||||
if fname.endswith(".lock"):
|
||||
os.unlink(os.path.join(path, fname))
|
||||
except OSError:
|
||||
cloudlog.exception("clear_locks failed")
|
||||
|
||||
|
||||
class Uploader:
|
||||
def __init__(self, dongle_id: str, root: str):
|
||||
self.dongle_id = dongle_id
|
||||
self.api = SunnylinkApi(dongle_id)
|
||||
self.root = root
|
||||
|
||||
self.params = Params()
|
||||
|
||||
# stats for last successfully uploaded file
|
||||
self.last_filename = ""
|
||||
|
||||
self.immediate_folders = ["crash/", "boot/"]
|
||||
self.immediate_priority = {"qlog": 0, "qlog.zst": 0, "qcamera.ts": 1}
|
||||
|
||||
def list_upload_files(self, metered: bool) -> Iterator[tuple[str, str, str]]:
|
||||
r = self.params.get("AthenadRecentlyViewedRoutes")
|
||||
requested_routes = [] if r is None else [route for route in r.split(",") if route]
|
||||
|
||||
for logdir in listdir_by_creation(self.root):
|
||||
path = os.path.join(self.root, logdir)
|
||||
try:
|
||||
names = os.listdir(path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if any(name.endswith(".lock") for name in names):
|
||||
continue
|
||||
|
||||
for name in sorted(names, key=lambda n: self.immediate_priority.get(n, 1000)):
|
||||
key = os.path.join(logdir, name)
|
||||
fn = os.path.join(path, name)
|
||||
# skip files already uploaded
|
||||
try:
|
||||
ctime = os.path.getctime(fn)
|
||||
is_uploaded = getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE
|
||||
except OSError:
|
||||
cloudlog.event("uploader_getxattr_failed", key=key, fn=fn)
|
||||
# deleter could have deleted, so skip
|
||||
continue
|
||||
if is_uploaded:
|
||||
continue
|
||||
|
||||
# limit uploading on metered connections
|
||||
if metered:
|
||||
dt = datetime.timedelta(hours=12)
|
||||
if logdir in self.immediate_folders and (datetime.datetime.now() - datetime.datetime.fromtimestamp(ctime)) < dt:
|
||||
continue
|
||||
|
||||
if name == "qcamera.ts" and not any(logdir.startswith(r.split('|')[-1]) for r in requested_routes):
|
||||
continue
|
||||
|
||||
yield name, key, fn
|
||||
|
||||
def next_file_to_upload(self, metered: bool) -> tuple[str, str, str] | None:
|
||||
upload_files = list(self.list_upload_files(metered))
|
||||
|
||||
for name, key, fn in upload_files:
|
||||
if any(f in fn for f in self.immediate_folders):
|
||||
return name, key, fn
|
||||
|
||||
for name, key, fn in upload_files:
|
||||
if name in self.immediate_priority:
|
||||
return name, key, fn
|
||||
|
||||
return None
|
||||
|
||||
def do_upload(self, key: str, fn: str):
|
||||
url_resp = self.api.get(
|
||||
f"device/{self.dongle_id}/upload_url/",
|
||||
timeout=10,
|
||||
path=key,
|
||||
access_token=self.api.get_token(),
|
||||
)
|
||||
if url_resp.status_code == 412:
|
||||
return url_resp
|
||||
|
||||
url_resp_json = json.loads(url_resp.text)
|
||||
url = url_resp_json['url']
|
||||
headers = url_resp_json['headers']
|
||||
cloudlog.debug("sunnylink upload_url %s | Headers: %s", url, headers)
|
||||
|
||||
if fake_upload:
|
||||
return FakeResponse()
|
||||
|
||||
stream = None
|
||||
try:
|
||||
compress = key.endswith('.zst') and not fn.endswith('.zst')
|
||||
stream, _ = get_upload_stream(fn, compress)
|
||||
response = requests.put(url, data=stream, headers=headers, timeout=10)
|
||||
return response
|
||||
finally:
|
||||
if stream:
|
||||
stream.close()
|
||||
|
||||
def upload(self, name: str, key: str, fn: str, network_type: int, metered: bool) -> bool:
|
||||
try:
|
||||
sz = os.path.getsize(fn)
|
||||
except OSError:
|
||||
cloudlog.exception("upload: getsize failed")
|
||||
return False
|
||||
|
||||
cloudlog.event("upload_start", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
|
||||
|
||||
if sz == 0:
|
||||
# tag files of 0 size as uploaded
|
||||
success = True
|
||||
elif name in MAX_UPLOAD_SIZES and sz > MAX_UPLOAD_SIZES[name]:
|
||||
cloudlog.event("uploader_too_large", key=key, fn=fn, sz=sz)
|
||||
success = True
|
||||
else:
|
||||
start_time = time.monotonic()
|
||||
|
||||
stat = None
|
||||
last_exc = None
|
||||
try:
|
||||
stat = self.do_upload(key, fn)
|
||||
except Exception as e:
|
||||
last_exc = (e, traceback.format_exc())
|
||||
|
||||
if stat is not None and stat.status_code in (200, 201, 412):
|
||||
self.last_filename = fn
|
||||
dt = time.monotonic() - start_time
|
||||
if stat.status_code == 412:
|
||||
cloudlog.event("upload_ignored", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
|
||||
else:
|
||||
content_length = int(stat.request.headers.get("Content-Length", 0))
|
||||
speed = (content_length / 1e6) / dt
|
||||
cloudlog.event("upload_success", key=key, fn=fn, sz=sz, content_length=content_length,
|
||||
network_type=network_type, metered=metered, speed=speed)
|
||||
success = True
|
||||
elif stat is not None: # 401, 403... Not sure why they were up to begin with
|
||||
success = False
|
||||
cloudlog.event("upload_failed with content", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered,
|
||||
error=stat.content.decode("utf-8"))
|
||||
else:
|
||||
success = False
|
||||
cloudlog.event("upload_failed", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
|
||||
|
||||
if success:
|
||||
# tag file as uploaded
|
||||
try:
|
||||
setxattr(fn, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE)
|
||||
except OSError:
|
||||
cloudlog.event("uploader_setxattr_failed", exc=last_exc, key=key, fn=fn, sz=sz)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def step(self, network_type: int, metered: bool) -> bool | None:
|
||||
d = self.next_file_to_upload(metered)
|
||||
if d is None:
|
||||
return None
|
||||
|
||||
name, key, fn = d
|
||||
|
||||
# qlogs and bootlogs need to be compressed before uploading
|
||||
if key.endswith(('qlog', 'rlog')) or (key.startswith('boot/') and not key.endswith('.zst')):
|
||||
key += ".zst"
|
||||
|
||||
return self.upload(name, key, fn, network_type, metered)
|
||||
|
||||
|
||||
def main(exit_event: threading.Event | None = None) -> None:
|
||||
if exit_event is None:
|
||||
exit_event = threading.Event()
|
||||
|
||||
try:
|
||||
set_core_affinity([0, 1, 2, 3])
|
||||
except Exception:
|
||||
cloudlog.exception("failed to set core affinity")
|
||||
|
||||
clear_locks(Paths.log_root())
|
||||
|
||||
params = Params()
|
||||
dongle_id = params.get("SunnylinkDongleId")
|
||||
|
||||
if dongle_id is None:
|
||||
cloudlog.info("uploader missing dongle_id")
|
||||
raise Exception("uploader can't start without dongle id")
|
||||
|
||||
sm = messaging.SubMaster(['deviceState'])
|
||||
uploader = Uploader(dongle_id, Paths.log_root())
|
||||
|
||||
backoff = 0.1
|
||||
while not exit_event.is_set():
|
||||
sm.update(0)
|
||||
offroad = params.get_bool("IsOffroad")
|
||||
network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi
|
||||
if network_type == NetworkType.none:
|
||||
if allow_sleep:
|
||||
time.sleep(60 if offroad else 5)
|
||||
continue
|
||||
|
||||
success = uploader.step(sm['deviceState'].networkType.raw, sm['deviceState'].networkMetered)
|
||||
if success is None:
|
||||
backoff = 60 if offroad else 5
|
||||
elif success:
|
||||
backoff = 0.1
|
||||
else:
|
||||
cloudlog.info("upload backoff %r", backoff)
|
||||
backoff = min(backoff*2, 120)
|
||||
if allow_sleep:
|
||||
time.sleep(backoff + random.uniform(0, backoff))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.version import is_prebuilt
|
||||
|
||||
|
||||
def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]:
|
||||
"""Get the status of Sunnylink on the device. Returns a tuple of (is_sunnylink_enabled, is_registered)."""
|
||||
params = params or Params()
|
||||
is_sunnylink_enabled = params.get_bool("SunnylinkEnabled")
|
||||
is_registered = params.get("SunnylinkDongleId") not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID)
|
||||
is_on_temporary_fault = params.get_bool("SunnylinkTempFault")
|
||||
return is_sunnylink_enabled, is_registered, is_on_temporary_fault
|
||||
|
||||
|
||||
def sunnylink_ready(params=None) -> bool:
|
||||
"""Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered."""
|
||||
params = params or Params()
|
||||
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
|
||||
return is_sunnylink_enabled and is_registered and not is_on_temporary_fault
|
||||
|
||||
|
||||
def use_sunnylink_uploader(params) -> bool:
|
||||
"""Check if the device is ready to use Sunnylink and the uploader is enabled."""
|
||||
return not params.get_bool("NetworkMetered") and sunnylink_ready(params) and params.get_bool("EnableSunnylinkUploader")
|
||||
|
||||
|
||||
def sunnylink_need_register(params=None) -> bool:
|
||||
"""Check if the device needs to be registered with Sunnylink."""
|
||||
params = params or Params()
|
||||
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
|
||||
return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault
|
||||
|
||||
|
||||
def register_sunnylink():
|
||||
"""Register the device with Sunnylink if it is enabled."""
|
||||
extra_args = {}
|
||||
|
||||
if not Params().get_bool("SunnylinkEnabled"):
|
||||
print("Sunnylink is not enabled. Exiting.")
|
||||
exit(0)
|
||||
|
||||
if not is_prebuilt():
|
||||
extra_args = {
|
||||
"verbose": True,
|
||||
"timeout": 60
|
||||
}
|
||||
|
||||
try:
|
||||
sunnylink_id = SunnylinkApi(None).register_device(None, **extra_args)
|
||||
print(f"SunnyLinkId: {sunnylink_id}")
|
||||
except Exception:
|
||||
Params().put_bool("SunnylinkTempFault", True, block=True)
|
||||
raise
|
||||
|
||||
|
||||
def get_api_token():
|
||||
"""Get the API token for the device."""
|
||||
params = Params()
|
||||
sunnylink_dongle_id = params.get("SunnylinkDongleId")
|
||||
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
|
||||
token = sunnylink_api.get_token()
|
||||
print(f"API Token: {token}")
|
||||
|
||||
|
||||
def get_param_as_byte(param_name: str, params=None, get_default=False) -> bytes | None:
|
||||
"""Get a parameter as bytes. Returns None if the parameter does not exist."""
|
||||
params = params or Params()
|
||||
param = params.get(param_name) if not get_default else params.get_default_value(param_name)
|
||||
|
||||
if param is None:
|
||||
return None
|
||||
|
||||
param_type = params.get_type(param_name)
|
||||
return _to_bytes(param, param_type)
|
||||
|
||||
|
||||
def _to_bytes(param: bytes, param_type: ParamKeyType) -> bytes | None:
|
||||
"""Convert a parameter value to bytes based on its type."""
|
||||
if param_type == ParamKeyType.BYTES:
|
||||
return bytes(param)
|
||||
elif param_type == ParamKeyType.JSON:
|
||||
return json.dumps(param).encode('utf-8')
|
||||
return str(param).encode('utf-8')
|
||||
|
||||
|
||||
def save_param_from_base64_encoded_string(param_name: str, base64_encoded_data: str, is_compressed=False) -> None:
|
||||
"""Save a parameter from bytes. Overwrites the parameter if it already exists."""
|
||||
params = Params()
|
||||
# Find real param name (with correct casing)
|
||||
param_type = params.get_type(param_name)
|
||||
value = base64.b64decode(base64_encoded_data)
|
||||
|
||||
if is_compressed:
|
||||
value = gzip.decompress(value)
|
||||
|
||||
# We convert to string anything that isn't bytes first. We later transform further.
|
||||
param_value = _convert_param_to_type(value, param_type)
|
||||
params.put(param_name, param_value, block=True)
|
||||
|
||||
|
||||
def _convert_param_to_type(value: bytes, param_type: ParamKeyType) -> bytes | str | int | float | bool | dict | None:
|
||||
"""
|
||||
Convert a byte value to the specified param type. Used internally when getting a Param to convert it to the right type.
|
||||
If this method looks familiar, it's because on SP we have a similar one in openpilot/sunnypilot/car/__init__.py.
|
||||
"""
|
||||
|
||||
# We convert to string anything that isn't bytes first. We later transform further.
|
||||
if param_type != ParamKeyType.BYTES:
|
||||
value = value.decode('utf-8')
|
||||
|
||||
if param_type == ParamKeyType.STRING:
|
||||
value = value
|
||||
elif param_type == ParamKeyType.BOOL:
|
||||
value = value.lower() in ('true', '1', 'yes')
|
||||
elif param_type == ParamKeyType.INT:
|
||||
value = int(value)
|
||||
elif param_type == ParamKeyType.FLOAT:
|
||||
value = float(value)
|
||||
elif param_type == ParamKeyType.TIME:
|
||||
value = str(value)
|
||||
elif param_type == ParamKeyType.JSON:
|
||||
value = json.loads(value)
|
||||
|
||||
return value
|
||||
Reference in new issue
Block a user