diff --git a/common/api/__init__.py b/common/api/__init__.py index 79875023a2..ab18ed1abc 100644 --- a/common/api/__init__.py +++ b/common/api/__init__.py @@ -1,46 +1,27 @@ -import jwt -import os -import requests -from datetime import datetime, timedelta -from openpilot.system.hardware.hw import Paths -from openpilot.system.version import get_version +from .comma_connect import CommaConnectApi +from .sunnylink import SunnylinkApi -API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com') -class Api(): - def __init__(self, dongle_id): - self.dongle_id = dongle_id - with open(Paths.persist_root()+'/comma/id_rsa') as f: - self.private_key = f.read() +class Api: + def __init__(self, dongle_id, use_sunnylink=False): + if use_sunnylink: + self.service = SunnylinkApi(dongle_id) + else: + self.service = CommaConnectApi(dongle_id) + + def request(self, method, endpoint, **params): + return self.service.request(method, endpoint, **params) def get(self, *args, **kwargs): - return self.request('GET', *args, **kwargs) + return self.service.get(*args, **kwargs) def post(self, *args, **kwargs): - return self.request('POST', *args, **kwargs) - - def request(self, method, endpoint, timeout=None, access_token=None, **params): - return api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) + return self.service.post(*args, **kwargs) def get_token(self, expiry_hours=1): - now = datetime.utcnow() - payload = { - 'identity': self.dongle_id, - 'nbf': now, - 'iat': now, - 'exp': now + timedelta(hours=expiry_hours) - } - token = jwt.encode(payload, self.private_key, algorithm='RS256') - if isinstance(token, bytes): - token = token.decode('utf8') - return token + return self.service.get_token(expiry_hours) -def api_get(endpoint, method='GET', timeout=None, access_token=None, **params): - headers = {} - if access_token is not None: - headers['Authorization'] = "JWT " + access_token - - headers['User-Agent'] = "openpilot-" + get_version() - - return requests.request(method, API_HOST + "/" + endpoint, timeout=timeout, headers=headers, params=params) +def api_get(endpoint, method='GET', timeout=None, access_token=None, use_sunnylink=False, **params): + return CommaConnectApi(None).api_get(endpoint, method, timeout, access_token, **params) if not use_sunnylink else \ + SunnylinkApi(None).api_get(endpoint, method, timeout, access_token, **params) diff --git a/common/api/base.py b/common/api/base.py new file mode 100644 index 0000000000..a61566c3ba --- /dev/null +++ b/common/api/base.py @@ -0,0 +1,49 @@ +import jwt +import requests +from datetime import datetime, timedelta +from openpilot.system.hardware.hw import Paths +from openpilot.system.version import get_version + + +class BaseApi: + def __init__(self, dongle_id, api_host, user_agent="openpilot-"): + self.dongle_id = dongle_id + self.api_host = api_host + self.user_agent = user_agent + with open(Paths.persist_root()+'/comma/id_rsa') as f: + self.private_key = f.read() + + def get(self, *args, **kwargs): + return self.request('GET', *args, **kwargs) + + def post(self, *args, **kwargs): + return self.request('POST', *args, **kwargs) + + def request(self, method, endpoint, timeout=None, access_token=None, **params): + return self.api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) + + def _get_token(self, expiry_hours=1, **extra_payload): + now = datetime.utcnow() + payload = { + 'identity': self.dongle_id, + 'nbf': now, + 'iat': now, + 'exp': now + timedelta(hours=expiry_hours), + **extra_payload + } + token = jwt.encode(payload, self.private_key, algorithm='RS256') + if isinstance(token, bytes): + token = token.decode('utf8') + return token + + def get_token(self, expiry_hours=1): + return self._get_token(expiry_hours) + + def api_get(self, endpoint, method='GET', timeout=None, access_token=None, **params): + headers = {} + if access_token is not None: + headers['Authorization'] = "JWT " + access_token + + headers['User-Agent'] = self.user_agent + get_version() + + return requests.request(method, self.api_host + "/" + endpoint, timeout=timeout, headers=headers, params=params) diff --git a/common/api/comma_connect.py b/common/api/comma_connect.py new file mode 100644 index 0000000000..1c705f3722 --- /dev/null +++ b/common/api/comma_connect.py @@ -0,0 +1,11 @@ +import os + +from openpilot.common.api.base import BaseApi + +API_HOST = os.getenv('API_HOST', 'https://api.commadotai.com') + + +class CommaConnectApi(BaseApi): + def __init__(self, dongle_id): + super().__init__(dongle_id, API_HOST) + self.user_agent = "openpilot-" diff --git a/common/api/sunnylink.py b/common/api/sunnylink.py new file mode 100644 index 0000000000..9166760dc7 --- /dev/null +++ b/common/api/sunnylink.py @@ -0,0 +1,118 @@ +import os +import time +import jwt +import json +from pathlib import Path +from datetime import datetime, timedelta +from openpilot.common.params import Params +from openpilot.system.hardware import HARDWARE +from openpilot.system.hardware.hw import Paths + +from openpilot.common.api.base import BaseApi + +API_HOST = os.getenv('SUNNYLINK_API_HOST', 'https://stg.api.sunnypilot.ai') +UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice" +MAX_RETRIES = 6 + + +class SunnylinkApi(BaseApi): + def __init__(self, dongle_id): + super().__init__(dongle_id, API_HOST) + self.user_agent = "sunnypilot-" + self.spinner = None + + def get_token(self, expiry_hours=1): + # Add your additional data here + additional_data = {} + return super()._get_token(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, params): + sunnylink_dongle_id = params.get("SunnylinkDongleId", encoding='utf-8') + comma_dongle_id = self.dongle_id or params.get("DongleId", encoding='utf-8') + return sunnylink_dongle_id, comma_dongle_id + + def _resolve_imeis(self, params): + imei1, imei2 = None, None + imei_try = 0 + while imei1 is None and imei2 is None and imei_try < MAX_RETRIES: + try: + imei1, imei2 = params.get("IMEI", encoding='utf8') or HARDWARE.get_imei(0), HARDWARE.get_imei(1) + except Exception: + self._status_update(f"Error getting imei, trying again... [{imei_try+1}/{MAX_RETRIES}]") + time.sleep(1) + imei_try += 1 + return imei1, imei2 + + def _resolve_serial(self, params): + serial = params.get("HardwareSerial", encoding='utf8') or HARDWARE.get_serial() + return serial + + def register_device(self, spinner=None, timeout=60, verbose=False): + self.spinner = spinner + params = Params() + + sunnylink_dongle_id, comma_dongle_id = self._resolve_dongle_ids(params) + + 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(params) + serial = self._resolve_serial(params) + + if sunnylink_dongle_id not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID): + return sunnylink_dongle_id + + privkey_path = Path(Paths.persist_root()+"/comma/id_rsa") + pubkey_path = Path(Paths.persist_root()+"/comma/id_rsa.pub") + + if not pubkey_path.is_file(): + sunnylink_dongle_id = UNREGISTERED_SUNNYLINK_DONGLE_ID + self._status_update("Public key not found, setting dongle ID to unregistered.") + else: + with pubkey_path.open() as f1, privkey_path.open() as f2: + public_key = f1.read() + private_key = f2.read() + + start_time = time.monotonic() + backoff = 1 + while True: + register_token = jwt.encode({'register': True, 'exp': datetime.utcnow() + timedelta(hours=1)}, private_key, algorithm='RS256') + 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.status_code != 200: + raise Exception(f"Failed to register with sunnylink. Status code: {resp.status_code}") + else: + dongleauth = json.loads(resp.text) + sunnylink_dongle_id = dongleauth["dongle_id"] + if sunnylink_dongle_id: + self._status_update("Device registered successfully.") + break + except Exception as e: + if verbose: + self._status_update(f"Waiting {backoff}s before retry, Exception occurred during registration: [{str(e)}]") + backoff = min(backoff * 2, 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 + + if sunnylink_dongle_id: + params.put("SunnylinkDongleId", sunnylink_dongle_id) + + self.spinner = None + return sunnylink_dongle_id diff --git a/common/params.cc b/common/params.cc index 0ad913c9fe..e06508a283 100644 --- a/common/params.cc +++ b/common/params.cc @@ -305,6 +305,7 @@ std::unordered_map keys = { {"StandStillTimer", PERSISTENT | BACKUP}, {"StockLongToyota", PERSISTENT | BACKUP}, {"SubaruManualParkingBrakeSng", PERSISTENT | BACKUP}, + {"SunnylinkDongleId", PERSISTENT}, {"TorqueDeadzoneDeg", PERSISTENT | BACKUP}, {"TorqueFriction", PERSISTENT | BACKUP}, {"TorqueMaxLatAccel", PERSISTENT | BACKUP}, diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index e2ea717d13..9ef8ae2c0a 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -86,7 +86,8 @@ function launch { if [ ! -f $DIR/prebuilt ]; then ./build.py fi - ./mapd_installer.py && ./manager.py + + ./sunnylink.py; ./mapd_installer.py; ./manager.py # if broken, keep on screen error while true; do sleep 1; done diff --git a/release/files_common b/release/files_common index 1378fa903f..b8bf8c709f 100644 --- a/release/files_common +++ b/release/files_common @@ -44,6 +44,9 @@ common/transformations/transformations.pxd common/transformations/transformations.pyx common/api/__init__.py +common/api/base.py +common/api/comma_connect.py +common/api/sunnylink.py release/* @@ -329,6 +332,7 @@ selfdrive/manager/__init__.py selfdrive/manager/build.py selfdrive/manager/helpers.py selfdrive/manager/mapd_installer.py +selfdrive/manager/sunnylink.py selfdrive/manager/manager.py selfdrive/manager/process_config.py selfdrive/manager/process.py diff --git a/selfdrive/athena/registration.py b/selfdrive/athena/registration.py index 6574d9ac20..840aa6541c 100755 --- a/selfdrive/athena/registration.py +++ b/selfdrive/athena/registration.py @@ -6,6 +6,7 @@ from pathlib import Path from datetime import datetime, timedelta from openpilot.common.api import api_get +from openpilot.common.api.sunnylink import SunnylinkApi from openpilot.common.params import Params from openpilot.common.spinner import Spinner from openpilot.selfdrive.controls.lib.alertmanager import set_offroad_alert @@ -86,6 +87,8 @@ def register(show_spinner=False) -> str | None: if time.monotonic() - start_time > 60 and show_spinner: spinner.update(f"registering device - serial: {serial}, IMEI: ({imei1}, {imei2})") + SunnylinkApi(dongle_id).register_device(spinner if show_spinner else None) + if show_spinner: spinner.close() diff --git a/selfdrive/manager/mapd_installer.py b/selfdrive/manager/mapd_installer.py index 2767115861..ec11e4aeb1 100755 --- a/selfdrive/manager/mapd_installer.py +++ b/selfdrive/manager/mapd_installer.py @@ -139,5 +139,4 @@ if __name__ == "__main__": install_manager.update_installed_version(VERSION) else: spinner.update(f"Checking if mapd is installed and valid. Prebuilt [{is_prebuilt()}]") - time.sleep(1) install_manager.non_prebuilt_install() diff --git a/selfdrive/manager/sunnylink.py b/selfdrive/manager/sunnylink.py new file mode 100755 index 0000000000..6b9b4a8318 --- /dev/null +++ b/selfdrive/manager/sunnylink.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from openpilot.common.api.sunnylink import SunnylinkApi +from openpilot.common.spinner import Spinner +from openpilot.system.version import is_prebuilt + + +if __name__ == "__main__": + spinner = Spinner() + extra_args = {} + if not is_prebuilt(): + extra_args = { + "verbose": True, + "timeout": 60 + } + + SunnylinkApi(None).register_device(spinner, **extra_args) + spinner.close()