diff --git a/CHANGELOGS.md b/CHANGELOGS.md index 57a373c944..72790751ae 100644 --- a/CHANGELOGS.md +++ b/CHANGELOGS.md @@ -5,14 +5,17 @@ sunnypilot - 0.9.7.0 (2024-xx-xx) ************************ * UPDATED: Synced with commaai's openpilot * master commit 56e343b (February 27, 2024) -* NEWโ—: Config Backup (Alpha access only for GitHub Sponsors and Patreon supporters) +* NEWโ—: Config Backup (Alpha early access) * Remotely back up and restore sunnypilot settings easily * Device registration with sunnylink ensures a secure, integrated experience across services * AES encryption derived from the device's RSA private key is used for utmost security * Settings are encrypted on-device, transmitted securely via HTTPS, and stored encrypted on sunnylink * Prevents loss of settings after device resets, offering peace of mind through end-to-end encryption * Early alpha access to all current and previous GitHub Sponsors and Patreon supporters - * Go to https://discord.gg/sunnypilot and reach out to one of the moderators to confirm your alpha access + * GitHub account pairing from device settings scanning QR code + * Pairing your account will allow you to access features via our API (still WIP but accessible if you dig a little on our code ๐Ÿ˜‰) + * Allow inheritance of your sponsorship status, allowing you to get extra features and early access whenever applicable + * Immediate sponsor recognition works only for new sponsors. If you're an earlier sponsor, we've got you โ€” just reach out to a moderator on Discord (https://discord.gg/sunnypilot) to get sorted! * RE-ENABLED: Map-based Turn Speed Control (M-TSC) for supported platforms * openpilot Longitudianl Control available cars * Custom Stock Longitudinal Control available cars diff --git a/common/api/sunnylink.py b/common/api/sunnylink.py index 9166760dc7..c2ac89d6dd 100644 --- a/common/api/sunnylink.py +++ b/common/api/sunnylink.py @@ -20,6 +20,13 @@ class SunnylinkApi(BaseApi): 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, **kwargs): + if not self.params.get_bool("SunnylinkEnabled"): + return None + + return super().api_get(endpoint, method, timeout, **kwargs) def get_token(self, expiry_hours=1): # Add your additional data here @@ -32,39 +39,38 @@ class SunnylinkApi(BaseApi): 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') + def _resolve_dongle_ids(self): + sunnylink_dongle_id = self.params.get("SunnylinkDongleId", encoding='utf-8') + comma_dongle_id = self.dongle_id or self.params.get("DongleId", encoding='utf-8') return sunnylink_dongle_id, comma_dongle_id - def _resolve_imeis(self, params): + def _resolve_imeis(self): 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) + imei1, imei2 = self.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() + def _resolve_serial(self): + serial = self.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) + 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(params) - serial = self._resolve_serial(params) + imei1, imei2 = self._resolve_imeis() + serial = self._resolve_serial() if sunnylink_dongle_id not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID): return sunnylink_dongle_id @@ -96,7 +102,7 @@ class SunnylinkApi(BaseApi): 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"] + sunnylink_dongle_id = dongleauth["device_id"] if sunnylink_dongle_id: self._status_update("Device registered successfully.") break @@ -112,7 +118,7 @@ class SunnylinkApi(BaseApi): break if sunnylink_dongle_id: - params.put("SunnylinkDongleId", sunnylink_dongle_id) + self.params.put("SunnylinkDongleId", sunnylink_dongle_id) self.spinner = None return sunnylink_dongle_id diff --git a/common/params.cc b/common/params.cc index e06508a283..e323eb826e 100644 --- a/common/params.cc +++ b/common/params.cc @@ -263,6 +263,7 @@ std::unordered_map keys = { {"HotspotOnBootConfirmed", PERSISTENT}, {"LastCarModel", PERSISTENT | BACKUP}, {"LastSpeedLimitSignTap", PERSISTENT}, + {"LastSunnylinkPingTime", CLEAR_ON_MANAGER_START}, {"LiveTorque", PERSISTENT | BACKUP}, {"LiveTorqueRelaxed", PERSISTENT | BACKUP}, {"LkasToggle", PERSISTENT | BACKUP}, @@ -306,6 +307,8 @@ std::unordered_map keys = { {"StockLongToyota", PERSISTENT | BACKUP}, {"SubaruManualParkingBrakeSng", PERSISTENT | BACKUP}, {"SunnylinkDongleId", PERSISTENT}, + {"SunnylinkEnabled", PERSISTENT}, + {"SunnylinkdPid", PERSISTENT}, {"TorqueDeadzoneDeg", PERSISTENT | BACKUP}, {"TorqueFriction", PERSISTENT | BACKUP}, {"TorqueMaxLatAccel", PERSISTENT | BACKUP}, @@ -321,6 +324,9 @@ std::unordered_map keys = { {"VwCCOnly", PERSISTENT | BACKUP}, {"Offroad_SupersededUpdate", PERSISTENT}, + {"SunnylinkCache_Users", PERSISTENT}, + {"SunnylinkCache_Roles", PERSISTENT}, + // PFEIFER - MAPD {{ {"MapdVersion", PERSISTENT}, {"RoadName", CLEAR_ON_ONROAD_TRANSITION}, diff --git a/release/files_common b/release/files_common index b8bf8c709f..880cbce0f4 100644 --- a/release/files_common +++ b/release/files_common @@ -70,7 +70,9 @@ system/version.py selfdrive/athena/__init__.py selfdrive/athena/athenad.py selfdrive/athena/manage_athenad.py +selfdrive/athena/manage_sunnylinkd.py selfdrive/athena/registration.py +selfdrive/athena/sunnylinkd.py selfdrive/boardd/.gitignore selfdrive/boardd/SConscript @@ -298,6 +300,11 @@ selfdrive/ui/qt/*.cc selfdrive/ui/qt/*.h selfdrive/ui/qt/network/*.cc selfdrive/ui/qt/network/*.h +selfdrive/ui/qt/network/sunnylink/*.cc +selfdrive/ui/qt/network/sunnylink/*.h +selfdrive/ui/qt/network/sunnylink/models/*.h +selfdrive/ui/qt/network/sunnylink/services/*.cc +selfdrive/ui/qt/network/sunnylink/services/*.h selfdrive/ui/qt/offroad/*.cc selfdrive/ui/qt/offroad/*.h selfdrive/ui/qt/offroad/*.qml diff --git a/selfdrive/athena/athenad.py b/selfdrive/athena/athenad.py index 9f901498b7..21c4d736a0 100755 --- a/selfdrive/athena/athenad.py +++ b/selfdrive/athena/athenad.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import platform import base64 import bz2 import hashlib @@ -751,11 +752,15 @@ def ws_manage(ws: WebSocket, end_event: threading.Event) -> None: onroad_prev = onroad if sock is not None: - # While not sending data, onroad, we can expect to time out in 7 + (7 * 2) = 21s - # offroad, we can expect to time out in 30 + (10 * 3) = 60s - # FIXME: TCP_USER_TIMEOUT is effectively 2x for some reason (32s), so it's mostly unused - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 16000 if onroad else 0) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 7 if onroad else 30) + if platform.system() == 'Darwin': # macOS + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 7 if onroad else 30) + else: + # While not sending data, onroad, we can expect to time out in 7 + (7 * 2) = 21s + # offroad, we can expect to time out in 30 + (10 * 3) = 60s + # FIXME: TCP_USER_TIMEOUT is effectively 2x for some reason (32s), so it's mostly unused + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 16000 if onroad else 0) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 7 if onroad else 30) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 7 if onroad else 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 2 if onroad else 3) diff --git a/selfdrive/athena/manage_sunnylinkd.py b/selfdrive/athena/manage_sunnylinkd.py new file mode 100755 index 0000000000..788d9fdf5e --- /dev/null +++ b/selfdrive/athena/manage_sunnylinkd.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +#TODO: Add this to files_common to allow release to public + +import time +from multiprocessing import Process + +from openpilot.common.params import Params +from openpilot.selfdrive.manager.process import launcher +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware import HARDWARE +from openpilot.system.version import get_version, get_normalized_origin, get_short_branch, get_commit, is_dirty + +SUNNYLINK_MGR_PID_PARAM = "SunnylinkdPid" + + +def main(): + params = Params() + dongle_id = params.get("SunnylinkDongleId").decode('utf-8') + cloudlog.bind_global(dongle_id=dongle_id, + version=get_version(), + origin=get_normalized_origin(), + branch=get_short_branch(), + commit=get_commit(), + dirty=is_dirty(), + device=HARDWARE.get_device_type()) + + try: + while 1: + cloudlog.info("starting athena daemon") + proc = Process(name='sunnylinkd', target=launcher, args=('selfdrive.athena.sunnylinkd', 'sunnylinkd')) + proc.start() + proc.join() + cloudlog.event("sunnylinkd exited", exitcode=proc.exitcode) + time.sleep(5) + except Exception: + cloudlog.exception("manage_sunnylinkd.exception") + finally: + params.remove(SUNNYLINK_MGR_PID_PARAM) + + +if __name__ == '__main__': + main() diff --git a/selfdrive/athena/sunnylinkd.py b/selfdrive/athena/sunnylinkd.py new file mode 100755 index 0000000000..f08cf2797b --- /dev/null +++ b/selfdrive/athena/sunnylinkd.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +#TODO: Add this to files_common to allow release to public + +from __future__ import annotations + +import os +import threading +import time + +from openpilot.selfdrive.athena.athenad import ws_send, jsonrpc_handler, \ + recv_queue, RECONNECT_TIMEOUT_S, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage +from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException, + create_connection) + +from openpilot.common.api import SunnylinkApi +from openpilot.common.params import Params +from openpilot.common.realtime import set_core_affinity +from openpilot.common.swaglog import cloudlog + +SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://ws.stg.api.sunnypilot.ai') +HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4")) +LOCAL_PORT_WHITELIST = {8022} + + +def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: + 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=log_handler, args=(end_event,), name='log_handler'), + # threading.Thread(target=stat_handler, args=(end_event,), name='stat_handler'), + ] + [ + threading.Thread(target=jsonrpc_handler, args=(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 exit_event is not None and exit_event.is_set(): + end_event.set() + except (KeyboardInterrupt, SystemExit): + end_event.set() + raise + finally: + for thread in threads: + cloudlog.debug(f"athena.joining {thread.name}") + thread.join() + + +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) + elif opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): + last_ping = int(time.monotonic() * 1e9) + Params().put("LastSunnylinkPingTime", str(last_ping)) + except WebSocketTimeoutException: + ns_since_last_ping = int(time.monotonic() * 1e9) - last_ping + if ns_since_last_ping > RECONNECT_TIMEOUT_S * 1e9: + cloudlog.exception("sunnylinkd.ws_recv.timeout") + end_event.set() + except Exception: + cloudlog.exception("sunnylinkd.ws_recv.exception") + end_event.set() + + +def ws_ping(ws: WebSocket, end_event: threading.Event) -> None: + # last_ping = int(time.monotonic() * 1e9) + while not end_event.is_set(): + try: + ws.ping() + except Exception: + cloudlog.exception("sunnylinkd.ws_ping.exception") + end_event.set() + time.sleep(RECONNECT_TIMEOUT_S * 0.8) # Sleep about 80% before a timeout + + +def main(exit_event: threading.Event = None): + try: + set_core_affinity([0, 1, 2, 3]) + except Exception: + cloudlog.exception("failed to set core affinity") + + params = Params() + dongle_id = params.get("SunnylinkDongleId", encoding='utf-8') + UploadQueueCache.initialize(upload_queue) + + ws_uri = SUNNYLINK_ATHENA_HOST + api = SunnylinkApi(dongle_id) + conn_start = None + conn_retries = 0 + while exit_event is None or not exit_event.is_set(): + 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, + cookie="jwt=" + api.get_token(), + enable_multithread=True, + timeout=30.0) + 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 (ConnectionError, TimeoutError, WebSocketException): + conn_retries += 1 + params.remove("LastSunnylinkPingTime") + except Exception: + cloudlog.exception("sunnylinkd.main.exception") + + conn_retries += 1 + params.remove("LastSunnylinkPingTime") + + time.sleep(backoff(conn_retries)) + + +if __name__ == "__main__": + main() diff --git a/selfdrive/manager/manager.py b/selfdrive/manager/manager.py index 944fb12789..4fc5a7412d 100755 --- a/selfdrive/manager/manager.py +++ b/selfdrive/manager/manager.py @@ -70,6 +70,7 @@ def manager_init() -> None: ("LastSpeedLimitSignTap", "0"), ("LkasToggle", "0"), ("MadsIconToggle", "1"), + ("MapdVersion", f"{VERSION}"), ("MaxTimeOffroad", "9"), ("NNFF", "0"), ("OnroadScreenOff", "-2"), @@ -100,7 +101,7 @@ def manager_init() -> None: ("OsmDbUpdatesCheck", "0"), ("OsmDownloadedDate", "0"), ("OSMDownloadProgress", "{}"), - ("MapdVersion", f"{VERSION}"), + ("SunnylinkEnabled", "1"), ] if not PC: default_params.append(("LastUpdateTime", datetime.datetime.utcnow().isoformat().encode('utf8'))) diff --git a/selfdrive/manager/process_config.py b/selfdrive/manager/process_config.py index cc9d9900bc..b85704442e 100644 --- a/selfdrive/manager/process_config.py +++ b/selfdrive/manager/process_config.py @@ -98,6 +98,11 @@ procs = [ PythonProcess("webjoystick", "tools.bodyteleop.web", notcar), ] +if os.path.exists("../athena/manage_sunnylinkd.py") and Params().get_bool("SunnylinkEnabled"): + procs += [ + DaemonProcess("manage_sunnylinkd", "selfdrive.athena.manage_sunnylinkd", "SunnylinkdPid"), + ] + if os.path.exists("./gitlab_runner.sh") and True: # Of course and True is always true. Placeholder for a param :D # Only devs! procs += [ diff --git a/selfdrive/manager/sunnylink.py b/selfdrive/manager/sunnylink.py index 6b9b4a8318..b329132bc2 100755 --- a/selfdrive/manager/sunnylink.py +++ b/selfdrive/manager/sunnylink.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 + from openpilot.common.api.sunnylink import SunnylinkApi +from openpilot.common.params import Params from openpilot.common.spinner import Spinner from openpilot.system.version import is_prebuilt @@ -7,11 +9,18 @@ from openpilot.system.version import is_prebuilt if __name__ == "__main__": spinner = Spinner() extra_args = {} + + if not Params().get_bool("SunnylinkEnabled"): + print("Sunnylink is not enabled. Exiting.") + spinner.close() + exit(0) + if not is_prebuilt(): extra_args = { "verbose": True, "timeout": 60 } - SunnylinkApi(None).register_device(spinner, **extra_args) + sunnylink_id = SunnylinkApi(None).register_device(spinner, **extra_args) + print(f"SunnyLinkId: {sunnylink_id}") spinner.close() diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index 4c4d3e2dab..b5bb2bb3c5 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -36,6 +36,9 @@ widgets_src += ["qt/offroad/sunnypilot/display_settings.cc", "qt/offroad/sunnypi "qt/offroad/sunnypilot/speed_limit_warning_settings.cc", "qt/offroad/sunnypilot/speed_limit_policy_settings.cc", "qt/offroad/sunnypilot/sunnylink_settings.cc"] +widgets_src += ["qt/network/sunnylink/sunnylink_client.cc", "qt/network/sunnylink/services/base_device_service.cc", + "qt/network/sunnylink/services/role_service.cc", "qt/network/sunnylink/services/user_service.cc"] + qt_env['CPPDEFINES'] = [] if maps: base_libs += ['QMapLibre'] diff --git a/selfdrive/ui/qt/api.cc b/selfdrive/ui/qt/api.cc index 80049fca05..f39e10a64f 100644 --- a/selfdrive/ui/qt/api.cc +++ b/selfdrive/ui/qt/api.cc @@ -17,6 +17,7 @@ #include #include "common/params.h" +#include "common/swaglog.h" #include "common/util.h" #include "system/hardware/hw.h" #include "selfdrive/ui/qt/util.h" @@ -201,6 +202,7 @@ bool HttpRequest::timeout() const { } void HttpRequest::sendRequest(const QString &requestURL, const HttpRequest::Method method, const QByteArray &payload) { + LOGD("Requesting %s", qPrintable(requestURL)); if (active()) { qDebug() << "HttpRequest is active"; return; diff --git a/selfdrive/ui/qt/network/sunnylink/models/role_model.h b/selfdrive/ui/qt/network/sunnylink/models/role_model.h new file mode 100644 index 0000000000..d7f3a98281 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/models/role_model.h @@ -0,0 +1,60 @@ +#ifndef ROLE_MODEL_H +#define ROLE_MODEL_H + +#include + +enum class RoleType { + ReadOnly, + Sponsor, + Admin +}; + +// haha, a role model xD +class RoleModel { +protected: + QJsonObject m_raw_json_object; + +public: + RoleType roleType; + + explicit RoleModel(const RoleType &roleType) : roleType(roleType) { + m_raw_json_object = toJson(); + } + explicit RoleModel(const QJsonObject &json) : RoleModel(stringToRoleType(json["role_type"].toString())) { + m_raw_json_object = json; + } + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json; + json["role_type"] = roleTypeToString(roleType); + return json; + } + + static RoleType stringToRoleType(const QString &roleTypeString) { + if (roleTypeString == "ReadOnly") { + return RoleType::ReadOnly; + } else if (roleTypeString == "Sponsor") { + return RoleType::Sponsor; + } else { // Default to Admin + return RoleType::Admin; + } + } + + static QString roleTypeToString(const RoleType &roleType) { + switch (roleType) { + case RoleType::ReadOnly: + return "ReadOnly"; + case RoleType::Sponsor: + return "Sponsor"; + default: // RoleType::Admin + return "Admin"; + } + } + + template ::value>::type> + T as() const { + return T(m_raw_json_object); + } +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/models/sponsor_role_model.h b/selfdrive/ui/qt/network/sunnylink/models/sponsor_role_model.h new file mode 100644 index 0000000000..64aad1ca46 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/models/sponsor_role_model.h @@ -0,0 +1,84 @@ +#ifndef SPONSORROLE_MODEL_H +#define SPONSORROLE_MODEL_H + +#include + +enum class SponsorTier { + Free, + Novice, + Supporter, + Contributor, + Benefactor, + Guardian, +}; + +// haha, a role model xD +class SponsorRoleModel final : RoleModel { +public: + SponsorTier roleTier; + + explicit SponsorRoleModel(const RoleType &roleType, const SponsorTier &roleTier) : RoleModel(roleType), roleTier(roleTier) {} + explicit SponsorRoleModel(const QJsonObject &json) : RoleModel(json), roleTier(stringToSponsorTier(json["role_tier"].toString())) {} + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json = RoleModel::toJson(); + json["role_tier"] = sponsorTierToString(roleTier); + return json; + }; + + static SponsorTier stringToSponsorTier(const QString &sponsorTierString) { + const auto sponsorTierStringLower = sponsorTierString.toLower(); + if (sponsorTierStringLower == "guardian") + return SponsorTier::Guardian; + if (sponsorTierStringLower == "novice") + return SponsorTier::Novice; + if (sponsorTierStringLower == "supporter") + return SponsorTier::Supporter; + if (sponsorTierStringLower == "contributor") + return SponsorTier::Contributor; + if (sponsorTierStringLower == "benefactor") + return SponsorTier::Benefactor; + + // Default to Guardian + return SponsorTier::Free; + } + + static QString sponsorTierToString(const SponsorTier &sponsorTier) { + switch (sponsorTier) { + case SponsorTier::Guardian: + return "Guardian"; + case SponsorTier::Novice: + return "Novice"; + case SponsorTier::Supporter: + return "Supporter"; + case SponsorTier::Contributor: + return "Contributor"; + case SponsorTier::Benefactor: + return "Benefactor"; + + default: // SponsorTier::Free + return "Free"; + } + } + [[nodiscard]] auto getSponsorTierString() const { return sponsorTierToString(roleTier); } + + static QString sponsorTierToColor(const SponsorTier &sponsorTier) { + switch (sponsorTier) { + case SponsorTier::Guardian: + return "gold"; + case SponsorTier::Benefactor: + return "mediumseagreen"; + case SponsorTier::Contributor: + return "steelblue"; + case SponsorTier::Supporter: + return "mediumpurple"; + case SponsorTier::Novice: + return "white"; + default: // SponsorTier::Free + return "silver"; + } + } + [[nodiscard]] auto getSponsorTierColor() const { return sponsorTierToColor(roleTier); } +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/models/user_model.h b/selfdrive/ui/qt/network/sunnylink/models/user_model.h new file mode 100644 index 0000000000..72baa3983d --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/models/user_model.h @@ -0,0 +1,33 @@ +#ifndef USER_MODEL_H +#define USER_MODEL_H + +#include + +class UserModel { +public: + QString device_id; + QString user_id; + qint64 created_at; + qint64 updated_at; + QString token_hash; + + explicit UserModel(const QJsonObject &json) { + device_id = json["device_id"].toString(); + user_id = json["user_id"].toString(); + created_at = json["created_at"].toInt(); + updated_at = json["updated_at"].toInt(); + token_hash = json["token_hash"].toString(); + } + + [[nodiscard]] QJsonObject toJson() const { + QJsonObject json; + json["device_id"] = device_id; + json["user_id"] = user_id; + json["created_at"] = created_at; + json["updated_at"] = updated_at; + json["token_hash"] = token_hash; + return json; + } +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/services/base_device_service.cc b/selfdrive/ui/qt/network/sunnylink/services/base_device_service.cc new file mode 100644 index 0000000000..2f2561a49a --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/base_device_service.cc @@ -0,0 +1,48 @@ +#include "base_device_service.h" + +#include "common/swaglog.h" +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h" + +BaseDeviceService::BaseDeviceService(QObject* parent) : QObject(parent), initial_request(nullptr), repeater(nullptr) { + param_watcher = new ParamWatcher(this); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + paramsRefresh(); + }); + param_watcher->addParam("SunnylinkEnabled"); +} + +void BaseDeviceService::paramsRefresh() { +} + +void BaseDeviceService::loadDeviceData(const QString &url, bool poll) { + if (!is_sunnylink_enabled()) { + LOGW("Sunnylink is not enabled, refusing to load data."); + return; + } + + auto sl_dongle_id = getSunnylinkDongleId(); + if (!sl_dongle_id.has_value()) + return; + + QString fullUrl = SUNNYLINK_BASE_URL + "/device/" + *sl_dongle_id + url; + if (poll && !isCurrentyPolling()) { + LOGD("Polling %s", qPrintable(fullUrl)); + LOGD("Cache key: SunnylinkCache_%s", qPrintable(QString(getCacheKey()))); + repeater = new RequestRepeater(this, fullUrl, "SunnylinkCache_" + getCacheKey(), 60, false, true); + connect(repeater, &RequestRepeater::requestDone, this, &BaseDeviceService::handleResponse); + } else if(isCurrentyPolling()){ + repeater->ForceUpdate(); + } else { + LOGD("Sending one-time %s", qPrintable(fullUrl)); + initial_request = new HttpRequest(this, true, 10000, true); + connect(initial_request, &HttpRequest::requestDone, this, &BaseDeviceService::handleResponse); + } +} + +void BaseDeviceService::stopPolling() { + if (repeater != nullptr) { + repeater->deleteLater(); + repeater = nullptr; + } +} diff --git a/selfdrive/ui/qt/network/sunnylink/services/base_device_service.h b/selfdrive/ui/qt/network/sunnylink/services/base_device_service.h new file mode 100644 index 0000000000..cd7aa9d9b9 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/base_device_service.h @@ -0,0 +1,29 @@ +#ifndef BASESERVICE_H +#define BASESERVICE_H + + +#include "selfdrive/ui/qt/api.h" +#include "selfdrive/ui/qt/request_repeater.h" +#include "selfdrive/ui/qt/util.h" + +class BaseDeviceService : public QObject { + Q_OBJECT + +protected: + void paramsRefresh(); + void loadDeviceData(const QString &url, bool poll = false); + virtual void handleResponse(const QString &response, bool success) = 0; + + static bool is_sunnylink_enabled() { return Params().getBool("SunnylinkEnabled");}; + ParamWatcher* param_watcher; + HttpRequest* initial_request = nullptr; + RequestRepeater* repeater = nullptr; + +public: + explicit BaseDeviceService(QObject* parent = nullptr); + virtual QString getCacheKey() const = 0; + bool isCurrentyPolling() {return repeater != nullptr;} + void stopPolling(); +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/services/role_service.cc b/selfdrive/ui/qt/network/sunnylink/services/role_service.cc new file mode 100644 index 0000000000..8bd0904421 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/role_service.cc @@ -0,0 +1,29 @@ +#include "selfdrive/ui/qt/network/sunnylink/services/role_service.h" + +#include +#include + +RoleService::RoleService(QObject* parent) : BaseDeviceService(parent) {} + +void RoleService::load() { + loadDeviceData(url); +} + +void RoleService::startPolling() { + loadDeviceData(url, true); +} + +void RoleService::handleResponse(const QString &response, bool success) { + if (!success) return; + + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8()); + QJsonArray jsonArray = doc.array(); + + std::vector roles; + for (const auto &value : jsonArray) { + roles.emplace_back(value.toObject()); + } + + emit rolesReady(roles); + uiState()->setSunnylinkRoles(roles); +} diff --git a/selfdrive/ui/qt/network/sunnylink/services/role_service.h b/selfdrive/ui/qt/network/sunnylink/services/role_service.h new file mode 100644 index 0000000000..bc40342982 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/role_service.h @@ -0,0 +1,26 @@ +#ifndef ROLESERVICE_H +#define ROLESERVICE_H + +#include "selfdrive/ui/qt/network/sunnylink/services/base_device_service.h" +#include "selfdrive/ui/qt/network/sunnylink/models/role_model.h" + +class RoleService : public BaseDeviceService { + Q_OBJECT + +public: + explicit RoleService(QObject* parent = nullptr); + void load(); + void startPolling(); + [[nodiscard]] QString getCacheKey() const final { return "Roles"; }; + +signals: + void rolesReady(const std::vector &roles); + +protected: + void handleResponse(const QString&response, bool success) override; + +private: + QString url = "/roles"; +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/services/user_service.cc b/selfdrive/ui/qt/network/sunnylink/services/user_service.cc new file mode 100644 index 0000000000..2d52421d69 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/user_service.cc @@ -0,0 +1,31 @@ +#include "selfdrive/ui/qt/network/sunnylink/services/user_service.h" + +#include +#include + +UserService::UserService(QObject* parent) : BaseDeviceService(parent) { + url = "/users"; +} + +void UserService::load() { + loadDeviceData(url); +} + +void UserService::startPolling() { + loadDeviceData(url, true); +} + +void UserService::handleResponse(const QString &response, bool success) { + if (!success) return; + + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8()); + QJsonArray jsonArray = doc.array(); + + std::vector users; + for (const auto &value : jsonArray) { + users.emplace_back(value.toObject()); + } + + emit usersReady(users); + uiState()->setSunnylinkDeviceUsers(users); +} diff --git a/selfdrive/ui/qt/network/sunnylink/services/user_service.h b/selfdrive/ui/qt/network/sunnylink/services/user_service.h new file mode 100644 index 0000000000..e4af93fa29 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/services/user_service.h @@ -0,0 +1,26 @@ +#ifndef USERSERVICE_H +#define USERSERVICE_H + +#include "selfdrive/ui/qt/network/sunnylink/services/base_device_service.h" +#include "selfdrive/ui/qt/network/sunnylink/models/user_model.h" + +class UserService : public BaseDeviceService { + Q_OBJECT + +public: + explicit UserService(QObject* parent = nullptr); + void load(); + void startPolling(); + [[nodiscard]] QString getCacheKey() const final { return "Users"; }; + +signals: + void usersReady(const std::vector&users); + +protected: + void handleResponse(const QString&response, bool success) override; + +private: + QString url = "/users"; +}; + +#endif diff --git a/selfdrive/ui/qt/network/sunnylink/sunnylink_client.cc b/selfdrive/ui/qt/network/sunnylink/sunnylink_client.cc new file mode 100644 index 0000000000..2d1613c0eb --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/sunnylink_client.cc @@ -0,0 +1,7 @@ +#include "selfdrive/ui/qt/network/sunnylink/sunnylink_client.h" +#include "selfdrive/ui/qt/network/sunnylink/services/user_service.h" + +SunnylinkClient::SunnylinkClient(QObject* parent) : QObject(parent) { + role_service = new RoleService(parent); + user_service = new UserService(parent); +} diff --git a/selfdrive/ui/qt/network/sunnylink/sunnylink_client.h b/selfdrive/ui/qt/network/sunnylink/sunnylink_client.h new file mode 100644 index 0000000000..872d80ccf7 --- /dev/null +++ b/selfdrive/ui/qt/network/sunnylink/sunnylink_client.h @@ -0,0 +1,18 @@ +#ifndef SUNNYLINK_CLIENT_H +#define SUNNYLINK_CLIENT_H + +#include + +#include "selfdrive/ui/qt/network/sunnylink/services/role_service.h" +#include "selfdrive/ui/qt/network/sunnylink/services/user_service.h" + +class SunnylinkClient : public QObject { + Q_OBJECT + +public: + explicit SunnylinkClient(QObject* parent); + RoleService* role_service; + UserService* user_service; +}; + +#endif diff --git a/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.cc b/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.cc index 4e709fd72f..935ed3fc56 100644 --- a/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.cc +++ b/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.cc @@ -1,20 +1,76 @@ #include "selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h" +#include +#include + SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { main_layout = new QStackedLayout(this); + sunnylink_client = new SunnylinkClient(this); + param_watcher = new ParamWatcher(this); + param_watcher->addParam("SunnylinkEnabled"); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + paramsRefresh(param_name, param_value); + }); - ListWidget *list = new ListWidget(this, false); - list->addItem(new LabelControl(tr("sunnylink Dongle ID"), getSunnylinkDongleId().value_or(tr("N/A")))); + is_sunnylink_enabled = Params().getBool("SunnylinkEnabled"); + connect(uiState(), &UIState::sunnylinkRolesChanged, this, &SunnylinkPanel::updateLabels); + connect(uiState(), &UIState::sunnylinkDeviceUsersChanged, this, &SunnylinkPanel::updateLabels); + + auto list = new ListWidget(this, false); + sunnylinkEnabledBtn = new ParamControl( + "SunnylinkEnabled", + tr("Enable sunnylink"), + sunnylinkBtnDescription, + "../assets/offroad/icon_blank.png" + ); + sunnylinkEnabledBtn->setValue(tr("Device ID ")+ getSunnylinkDongleId().value_or(tr("N/A"))); + + list->addItem(sunnylinkEnabledBtn); list->addItem(horizontal_line()); - popup = new SunnylinkSponsorPopup(this); + sunnylinkBtnDescription = tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."); + connect(sunnylinkEnabledBtn, &ParamControl::showDescriptionEvent, [=]() { + //resets the description to the default one for the easter egg + sunnylinkEnabledBtn->setDescription(sunnylinkBtnDescription); + }); + + connect(sunnylinkEnabledBtn, &ParamControl::toggleFlipped, [=](bool enabled) { + if (enabled) { + auto proud_description = ""+ tr("๐ŸŽ‰Welcome back! We're excited to see you've enabled sunnylink again! ๐Ÿš€")+ ""; + sunnylinkEnabledBtn->showDescription(); + sunnylinkEnabledBtn->setDescription(proud_description); + } else { + auto shame_description = ""+ tr("๐Ÿ‘‹Not going to lie, it's sad to see you disabled sunnylink ๐Ÿ˜ข, but we'll be here when you're ready to come back ๐ŸŽ‰.")+ ""; + sunnylinkEnabledBtn->showDescription(); + sunnylinkEnabledBtn->setDescription(shame_description); + } + + auto dialog_text = tr("A reboot is required to") + " " + (enabled ? tr("start") : tr("stop")) +" "+ tr("all connections and processes from sunnylink.") + "
"+ tr("If that's not a problem for you, you can ignore this.")+ ""; + if (ConfirmationDialog::confirm(dialog_text, tr("Reboot Now!"), this)) { + Hardware::reboot(); + } + updateLabels(); + }); + + status_popup = new SunnylinkSponsorPopup(false, this); sponsorBtn = new ButtonControl( tr("Sponsor Status"), tr("SPONSOR"), - tr("Become a sponsor of sunnypilot to get early access to sunnylink features.") + tr("Become a sponsor of sunnypilot to get early access to sunnylink features when they become available.") ); list->addItem(sponsorBtn); connect(sponsorBtn, &ButtonControl::clicked, [=]() { - popup->exec(); + status_popup->exec(); + }); + list->addItem(horizontal_line()); + + pair_popup = new SunnylinkSponsorPopup(true, this); + pairSponsorBtn = new ButtonControl( + tr("Pair GitHub Account"), tr("PAIR"), + tr("Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink.") + "๐ŸŒŸ" + ); + list->addItem(pairSponsorBtn); + connect(pairSponsorBtn, &ButtonControl::clicked, [=]() { + pair_popup->exec(); }); list->addItem(horizontal_line()); @@ -26,22 +82,15 @@ SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { backupSettings = new SubPanelButton(tr("Backup Settings"), 720, this); backupSettings->setObjectName("backup_btn"); // Set margin on the outside of the button - QVBoxLayout* backupSettingsLayout = new QVBoxLayout; + auto backupSettingsLayout = new QVBoxLayout; backupSettingsLayout->setContentsMargins(0, 0, 0, 30); backupSettingsLayout->addWidget(backupSettings); connect(backupSettings, &QPushButton::clicked, [=]() { is_backup = true; backup_settings->started(); - if (uiState()->isSubscriber()) { - if (ConfirmationDialog::confirm(tr("Are you sure you want to backup sunnypilot settings?"), tr("Back Up"), this)) { - backup_settings->sendParams(backup_settings->backupParams()); - } else { - backup_settings->finished(); - } + if (ConfirmationDialog::confirm(tr("Are you sure you want to backup sunnypilot settings?"), tr("Back Up"), this)) { + backup_settings->sendParams(backup_settings->backupParams()); } else { - if (ConfirmationDialog::confirm(tr("Early alpha access only. Become a sponsor to get early access to sunnylink features."), tr("Become a Sponsor"), this)) { - popup->exec(); - } backup_settings->finished(); } is_backup = false; @@ -52,13 +101,13 @@ SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { restoreSettings = new SubPanelButton(tr("Restore Settings"), 720, this); restoreSettings->setObjectName("restore_btn"); // Set margin on the outside of the button - QVBoxLayout* restoreSettingsLayout = new QVBoxLayout; + auto restoreSettingsLayout = new QVBoxLayout; restoreSettingsLayout->setContentsMargins(0, 0, 0, 30); restoreSettingsLayout->addWidget(restoreSettings); connect(restoreSettings, &QPushButton::clicked, [=]() { is_restore = true; backup_settings->started(); - if (uiState()->isSubscriber()) { + if (uiState()->isSunnylinkSponsor()) { if (ConfirmationDialog::confirm(tr("Are you sure you want to restore the last backed up sunnypilot settings?"), tr("Restore"), this)) { backup_settings->getParams(); } else { @@ -66,7 +115,7 @@ SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { } } else { if (ConfirmationDialog::confirm(tr("Early alpha access only. Become a sponsor to get early access to sunnylink features."), tr("Become a Sponsor"), this)) { - popup->exec(); + status_popup->exec(); } backup_settings->finished(); } @@ -75,7 +124,7 @@ SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { connect(backup_settings, &BackupSettings::updateLabels, this, &SunnylinkPanel::updateLabels); // Settings Restore and Settings Backup in the same horizontal space - QHBoxLayout *settings_layout = new QHBoxLayout; + auto settings_layout = new QHBoxLayout; settings_layout->setContentsMargins(0, 0, 0, 30); settings_layout->addWidget(backupSettings); settings_layout->addSpacing(10); @@ -89,24 +138,52 @@ SunnylinkPanel::SunnylinkPanel(QWidget* parent) : QFrame(parent) { }); sunnylinkScreen = new QWidget(this); - QVBoxLayout* vlayout = new QVBoxLayout(sunnylinkScreen); + auto vlayout = new QVBoxLayout(sunnylinkScreen); vlayout->setContentsMargins(50, 20, 50, 20); vlayout->addWidget(new ScrollView(list, this), 1); main_layout->addWidget(sunnylinkScreen); - - getSubscriber(); - + if (is_sunnylink_enabled) { + startSunnylink(); + } updateLabels(); } void SunnylinkPanel::showEvent(QShowEvent* event) { - getSubscriber(true); + if (is_sunnylink_enabled) { + startSunnylink(); + } updateLabels(); // For snappier feeling } -void SunnylinkPanel::hideEvent(QHideEvent* event) { - sub_repeater->deleteLater(); +void SunnylinkPanel::paramsRefresh(const QString ¶m_name, const QString ¶m_value) { + // We do it on paramsRefresh because the toggleEvent happens before the value is updated + if (param_name == "SunnylinkEnabled" && param_value == "1") { + startSunnylink(); + } else if (param_name == "SunnylinkEnabled" && param_value == "0") { + stopSunnylink(); + } + + updateLabels(); +} + +void SunnylinkPanel::startSunnylink() const { + if (!sunnylink_client->role_service->isCurrentyPolling()) { + sunnylink_client->role_service->startPolling(); + } else { + sunnylink_client->role_service->load(); + } + + if (!sunnylink_client->user_service->isCurrentyPolling()) { + sunnylink_client->user_service->startPolling(); + } else { + sunnylink_client->user_service->load(); + } +} + +void SunnylinkPanel::stopSunnylink() const { + sunnylink_client->role_service->stopPolling(); + sunnylink_client->user_service->stopPolling(); } void SunnylinkPanel::updateLabels() { @@ -114,67 +191,40 @@ void SunnylinkPanel::updateLabels() { return; } - bool is_sub = uiState()->isSubscriber(); + is_sunnylink_enabled = Params().getBool("SunnylinkEnabled"); + const auto sunnylinkDongleId = getSunnylinkDongleId().value_or(tr("N/A")); + bool is_sub = uiState()->isSunnylinkSponsor() && is_sunnylink_enabled; + auto max_current_sponsor_rule = uiState()->sunnylinkSponsorRole(); + auto role_name = max_current_sponsor_rule.getSponsorTierString(); + std::optional role_color = max_current_sponsor_rule.getSponsorTierColor(); + bool is_paired = uiState()->isSunnylinkPaired(); + auto paired_users = uiState()->sunnylinkDeviceUsers(); - sponsorBtn->setEnabled(!is_onroad); + sunnylinkEnabledBtn->setEnabled(!is_onroad); + sunnylinkEnabledBtn->setValue(tr("Device ID ")+ sunnylinkDongleId); + + sponsorBtn->setEnabled(!is_onroad && is_sunnylink_enabled); sponsorBtn->setText(is_sub ? tr("THANKS") + " โค๏ธ" : tr("SPONSOR")); - sponsorBtn->setValue(is_sub ? tr("Sponsor") : tr("Not Sponsor")); + sponsorBtn->setValue(is_sub ? tr(role_name.toStdString().c_str()) : tr("Not Sponsor"), role_color); - backupSettings->setEnabled(!backup_settings->in_progress && !is_onroad); - restoreSettings->setEnabled(!backup_settings->in_progress && !is_onroad); + pairSponsorBtn->setEnabled(!is_onroad && is_sunnylink_enabled); + pairSponsorBtn->setValue(is_paired ? tr("Paired") : tr("Not Paired")); + + backupSettings->setEnabled(!backup_settings->in_progress && !is_onroad && is_sunnylink_enabled); + restoreSettings->setEnabled(!backup_settings->in_progress && !is_onroad && is_sunnylink_enabled); backupSettings->setText(backup_settings->in_progress && is_backup ? tr("Backing up...") : tr("Backup Settings")); restoreSettings->setText(backup_settings->in_progress && is_restore ? tr("Restoring...") : tr("Restore Settings")); + if (!is_sunnylink_enabled) { + sunnylinkEnabledBtn->setValue(""); + sponsorBtn->setValue(""); + pairSponsorBtn->setValue(""); + } + update(); } -void SunnylinkPanel::getSubscriber(bool poll) { - if (auto sl_dongle_id = getSunnylinkDongleId()) { - QString url = "https://stg.api.sunnypilot.ai/device/" + *sl_dongle_id + "/roles"; - - init_sub_request = new HttpRequest(this, true, 10000, true); - QObject::connect(init_sub_request, &HttpRequest::requestDone, [=](const QString &resp, bool success) { - replyFinished(resp, success); - updateLabels(); - init_sub_request->deleteLater(); - }); - init_sub_request->sendRequest(url); - - if (poll) { - sub_repeater = new RequestRepeater(this, url, "", 60, false, true); - QObject::connect(sub_repeater, &RequestRepeater::requestDone, [=](const QString &resp, bool success) { - replyFinished(resp, success); - updateLabels(); - }); - } - } -} - -void SunnylinkPanel::replyFinished(const QString &response, bool success) { - if (!success) return; - - SunnylinkRoleType role_type; - - if (response == "[]") { - role_type = static_cast(int(SunnylinkRoleType::SL_UNKNOWN)); - uiState()->setSunnylinkRoleType(role_type); - qDebug() << "JSON Parse failed on getting sponsor status"; - return; - } - - // TODO: Refactor to check additional role types with new mapping on the server side when implemented - QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8()); - QJsonArray jsonArray = doc.array(); - - for (const QJsonValue &value : jsonArray) { - QJsonObject json = value.toObject(); - role_type = static_cast(int(sunnylinkRoleTypeValue(QString(json["role_type"].toString())))); - - uiState()->setSunnylinkRoleType(role_type); - } -} - BackupSettings::BackupSettings(QWidget* parent) : QFrame(parent) { } @@ -207,17 +257,17 @@ QByteArray BackupSettings::backupParams(const bool encrypt) { QByteArray processed_data = encrypt ? CommaApi::rsa_encrypt(compressedData) : compressedData; // Encode the compressed QByteArray in Base64. - QString converted_params_processed_base64_data = QString(processed_data.toBase64()); + auto converted_params_processed_base64_data = QString(processed_data.toBase64()); payload["is_encrypted"] = encrypt; payload["config"] = converted_params_processed_base64_data; // Create a sub-object for sunnypilot_version QJsonObject version; QStringList versioning = getVersion().split('.'); - version["major"] = versioning.value(0, 0).toInt(); - version["minor"] = versioning.value(1, 0).toInt(); - version["patch"] = versioning.value(2, 0).toInt(); - version["build"] = versioning.value(3, 0).toInt(); + version["major"] = versioning.value(0, nullptr).toInt(); + version["minor"] = versioning.value(1, nullptr).toInt(); + version["patch"] = versioning.value(2, nullptr).toInt(); + version["build"] = versioning.value(3, nullptr).toInt(); version["branch"] = QString::fromStdString(params.get("GitBranch")); payload["sunnypilot_version"] = version; @@ -229,9 +279,9 @@ QByteArray BackupSettings::backupParams(const bool encrypt) { void BackupSettings::sendParams(const QByteArray &payload) { if (auto sl_dongle_id = getSunnylinkDongleId()) { - QString url = "https://stg.api.sunnypilot.ai/backup/" + *sl_dongle_id; - HttpRequest *request = new HttpRequest(this, true, 10000, true); - QObject::connect(request, &HttpRequest::requestDone, [=](const QString &resp, bool success) { + QString url = SUNNYLINK_BASE_URL + "/backup/" + *sl_dongle_id; + auto request = new HttpRequest(this, true, 10000, true); + connect(request, &HttpRequest::requestDone, [=](const QString &resp, bool success) { if (success && resp != "[]") { ConfirmationDialog::alert(tr("Settings backed up for sunnylink Device ID:") + " " + *sl_dongle_id, this); } else if (resp == "[]") { @@ -240,7 +290,7 @@ void BackupSettings::sendParams(const QByteArray &payload) { ConfirmationDialog::alert(tr("OOPS! We made a booboo.") + "\n" + tr("Please try again later."), this); } - QObject::connect(request, &QObject::destroyed, this, &BackupSettings::finished); + connect(request, &QObject::destroyed, this, &BackupSettings::finished); request->deleteLater(); }); @@ -297,9 +347,9 @@ void BackupSettings::restoreParams(const QString &resp) { void BackupSettings::getParams() { if (auto sl_dongle_id = getSunnylinkDongleId()) { - QString url = "https://stg.api.sunnypilot.ai/backup/" + *sl_dongle_id; - HttpRequest *request = new HttpRequest(this, true, 10000, true); - QObject::connect(request, &HttpRequest::requestDone, [=](const QString &resp, bool success) { + QString url = SUNNYLINK_BASE_URL + "/backup/" + *sl_dongle_id; + auto request = new HttpRequest(this, true, 10000, true); + connect(request, &HttpRequest::requestDone, [=](const QString &resp, bool success) { bool restart_ui = false; if (success && resp != "[]") { restoreParams(resp); @@ -311,7 +361,7 @@ void BackupSettings::getParams() { ConfirmationDialog::alert(tr("OOPS! We made a booboo.") + "\n" + tr("Please try again later."), this); } - QObject::connect(request, &QObject::destroyed, [=]() { + connect(request, &QObject::destroyed, [=]() { finished(); if (restart_ui) { @@ -340,7 +390,7 @@ void BackupSettings::finished() { // Sponsor Upsell using qrcodegen::QrCode; -SunnylinkSponsorQRWidget::SunnylinkSponsorQRWidget(QWidget* parent) : QWidget(parent) { +SunnylinkSponsorQRWidget::SunnylinkSponsorQRWidget(bool sponsor_pair, QWidget* parent) : QWidget(parent), sponsor_pair(sponsor_pair) { timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &SunnylinkSponsorQRWidget::refresh); } @@ -357,7 +407,17 @@ void SunnylinkSponsorQRWidget::hideEvent(QHideEvent *event) { } void SunnylinkSponsorQRWidget::refresh() { - QString qrString = "https://github.com/sponsors/sunnyhaibin"; + QString qrString; + + if (sponsor_pair) { + QString token = CommaApi::create_jwt({}, 3600, true); + auto sl_dongle_id = getSunnylinkDongleId(); + QByteArray payload = QString("1|" + *sl_dongle_id + "|" + token).toUtf8().toBase64(); + qrString = SUNNYLINK_BASE_URL + "/sso?state=" + payload; + } else { + qrString = "https://github.com/sponsors/sunnyhaibin"; + } + this->updateQrCode(qrString); update(); } @@ -388,50 +448,70 @@ void SunnylinkSponsorQRWidget::paintEvent(QPaintEvent *e) { p.drawPixmap(s.width(), s.height(), img); } -SunnylinkSponsorPopup::SunnylinkSponsorPopup(QWidget *parent) : DialogBase(parent) { - QHBoxLayout *hlayout = new QHBoxLayout(this); +QStringList SunnylinkSponsorPopup::getInstructions(bool sponsor_pair) { + QStringList instructions; + if (sponsor_pair) { + instructions << tr("Scan the QR code to login to your GitHub account") + << tr("Follow the prompts to complete the pairing process") + << tr("Re-enter the \"sunnylink\" panel to verify sponsorship status") + << tr("If sponsorship status was not updated, please contact a moderator on Discord at https://discord.gg/sunnypilot"); + } else { + instructions << tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page") + << tr("Choose your sponsorship tier and confirm your support") + << tr("Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status"); + } + return instructions; +} + +SunnylinkSponsorPopup::SunnylinkSponsorPopup(bool sponsor_pair, QWidget *parent) : DialogBase(parent), sponsor_pair(sponsor_pair) { + auto *hlayout = new QHBoxLayout(this); + auto sunnylink_client = new SunnylinkClient(this); hlayout->setContentsMargins(0, 0, 0, 0); hlayout->setSpacing(0); setStyleSheet("SunnylinkSponsorPopup { background-color: #E0E0E0; }"); // text - QVBoxLayout *vlayout = new QVBoxLayout(); + auto vlayout = new QVBoxLayout(); vlayout->setContentsMargins(85, 70, 50, 70); vlayout->setSpacing(50); hlayout->addLayout(vlayout, 1); { - QPushButton *close = new QPushButton(QIcon(":/icons/close.svg"), "", this); + auto close = new QPushButton(QIcon(":/icons/close.svg"), "", this); close->setIconSize(QSize(80, 80)); close->setStyleSheet("border: none;"); vlayout->addWidget(close, 0, Qt::AlignLeft); - QObject::connect(close, &QPushButton::clicked, this, &QDialog::reject); + connect(close, &QPushButton::clicked, this, [=] { + sunnylink_client->role_service->load(); + sunnylink_client->user_service->load(); + QDialog::reject(); + }); //vlayout->addSpacing(30); - QLabel *title = new QLabel(tr("Early Access: Become a sunnypilot Sponsor"), this); + const QString titleText = sponsor_pair ? tr("Pair your GitHub account") : tr("Early Access: Become a sunnypilot Sponsor"); + const auto title = new QLabel(titleText, this); title->setStyleSheet("font-size: 75px; color: black;"); title->setWordWrap(true); vlayout->addWidget(title); - QLabel *instructions = new QLabel(QString(R"( -
    -
  1. %1
  2. -
  3. %2
  4. -
  5. %3
  6. -
- )").arg(tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page")) - .arg(tr("Choose your sponsorship tier and confirm your support")) - .arg(tr("Join our community on Discord at https://discord.gg/sunnypilot and reach out to a moderator to confirm your sponsor status")), this); + QStringList instructions = getInstructions(sponsor_pair); + QString instructionsHtml = "
    "; + for (const auto & instruction : instructions) { + instructionsHtml += QString("
  1. %1
  2. ").arg(instruction); + } + instructionsHtml += "
"; + const auto instructionsLabel = new QLabel(instructionsHtml, this); - instructions->setStyleSheet("font-size: 47px; font-weight: bold; color: black;"); - instructions->setWordWrap(true); - vlayout->addWidget(instructions); + + instructionsLabel->setStyleSheet("font-size: 47px; font-weight: bold; color: black;"); + instructionsLabel->setWordWrap(true); + vlayout->addWidget(instructionsLabel); vlayout->addStretch(); } // QR code - SunnylinkSponsorQRWidget *qr = new SunnylinkSponsorQRWidget(this); + auto *qr = new SunnylinkSponsorQRWidget(sponsor_pair ? true : false, this); hlayout->addWidget(qr, 1); } diff --git a/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h b/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h index dabbcd3ab8..fdca62d1c5 100644 --- a/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h +++ b/selfdrive/ui/qt/offroad/sunnypilot/sunnylink_settings.h @@ -1,26 +1,22 @@ #pragma once -#include -#include -#include #include #include +#include "selfdrive/ui/qt/network/sunnylink/sunnylink_client.h" -#include "common/watchdog.h" #include "selfdrive/ui/ui.h" -#include "selfdrive/ui/qt/api.h" #include "selfdrive/ui/qt/util.h" -#include "selfdrive/ui/qt/request_repeater.h" #include "selfdrive/ui/qt/widgets/controls.h" #include "selfdrive/ui/qt/widgets/scrollview.h" +const QString SUNNYLINK_BASE_URL = util::getenv("SUNNYLINK_API_HOST", "https://stg.api.sunnypilot.ai").c_str(); // sponsor QR code class SunnylinkSponsorQRWidget : public QWidget { Q_OBJECT public: - explicit SunnylinkSponsorQRWidget(QWidget* parent = 0); + explicit SunnylinkSponsorQRWidget(bool sponsor_pair = false, QWidget* parent = 0); void paintEvent(QPaintEvent*) override; private: @@ -30,6 +26,8 @@ private: void showEvent(QShowEvent *event) override; void hideEvent(QHideEvent *event) override; + bool sponsor_pair = false; + private slots: void refresh(); }; @@ -40,7 +38,11 @@ class SunnylinkSponsorPopup : public DialogBase { Q_OBJECT public: - explicit SunnylinkSponsorPopup(QWidget* parent); + explicit SunnylinkSponsorPopup(bool sponsor_pair = false, QWidget* parent = 0); + +private: + static QStringList getInstructions(bool sponsor_pair); + bool sponsor_pair = false; }; class BackupSettings : public QFrame { @@ -50,7 +52,7 @@ public: explicit BackupSettings(QWidget *parent = nullptr); void sendParams(const QByteArray &payload); void getParams(); - QByteArray backupParams(const bool encrypt = true); + QByteArray backupParams(bool encrypt = true); void restoreParams(const QString &resp); void started(); void finished(); @@ -69,29 +71,33 @@ class SunnylinkPanel : public QFrame { public: explicit SunnylinkPanel(QWidget *parent = nullptr); void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; + void paramsRefresh(const QString¶m_name, const QString¶m_value); public slots: void updateLabels(); private: - void getSubscriber(bool poll = false); - void replyFinished(const QString &response, bool success); + ParamControl* sunnylinkEnabledBtn; QStackedLayout* main_layout = nullptr; QWidget* sunnylinkScreen = nullptr; ScrollView *scrollView = nullptr; - RequestRepeater* sub_repeater = nullptr; - HttpRequest* init_sub_request = nullptr; - LabelControl *sunnylinkId; BackupSettings *backup_settings; SubPanelButton *restoreSettings; SubPanelButton *backupSettings; - SunnylinkSponsorPopup *popup; + SunnylinkSponsorPopup *status_popup; + SunnylinkSponsorPopup *pair_popup; ButtonControl* sponsorBtn; + ButtonControl* pairSponsorBtn; + SunnylinkClient* sunnylink_client; bool is_onroad = false; bool is_backup = false; bool is_restore = false; + bool is_sunnylink_enabled = false; + ParamWatcher *param_watcher; + QString sunnylinkBtnDescription; + void stopSunnylink() const; + void startSunnylink() const; }; diff --git a/selfdrive/ui/qt/request_repeater.cc b/selfdrive/ui/qt/request_repeater.cc index a560f6aad6..8212d3db94 100644 --- a/selfdrive/ui/qt/request_repeater.cc +++ b/selfdrive/ui/qt/request_repeater.cc @@ -1,15 +1,14 @@ #include "selfdrive/ui/qt/request_repeater.h" +#include "common/swaglog.h" + RequestRepeater::RequestRepeater(QObject *parent, const QString &requestURL, const QString &cacheKey, - int period, bool while_onroad, bool sunnylink) : HttpRequest(parent, true, 20000, sunnylink) { + int period, bool whileOnroad, bool sunnylink) : HttpRequest(parent, true, 20000, sunnylink) { + request_url = requestURL; + while_onroad = whileOnroad; timer = new QTimer(this); timer->setTimerType(Qt::VeryCoarseTimer); - QObject::connect(timer, &QTimer::timeout, [=]() { - if ((!uiState()->scene.started || while_onroad) && device()->isAwake() && !active()) { - sendRequest(requestURL); - } - }); - + connect(timer, &QTimer::timeout, [=]() { this->timerTick(); }); timer->start(period * 1000); if (!cacheKey.isEmpty()) { @@ -17,11 +16,21 @@ RequestRepeater::RequestRepeater(QObject *parent, const QString &requestURL, con if (!prevResp.isEmpty()) { QTimer::singleShot(500, [=]() { emit requestDone(prevResp, true, QNetworkReply::NoError); }); } - QObject::connect(this, &HttpRequest::requestDone, [=](const QString &resp, bool success) { + connect(this, &HttpRequest::requestDone, [=](const QString &resp, bool success) { if (success && resp != prevResp) { params.put(cacheKey.toStdString(), resp.toStdString()); prevResp = resp; } }); } + + // Don't wait for the timer to fire to send the first request + ForceUpdate(); +} + +void RequestRepeater::timerTick() { + if ((!uiState()->scene.started || while_onroad) && device()->isAwake() && !active()) { + LOGD("Sending request for %s", qPrintable(request_url)); + sendRequest(request_url); + } } diff --git a/selfdrive/ui/qt/request_repeater.h b/selfdrive/ui/qt/request_repeater.h index 0c866ba70b..45cb643b13 100644 --- a/selfdrive/ui/qt/request_repeater.h +++ b/selfdrive/ui/qt/request_repeater.h @@ -1,15 +1,24 @@ #pragma once +#include "common/swaglog.h" #include "common/util.h" #include "selfdrive/ui/qt/api.h" #include "selfdrive/ui/ui.h" class RequestRepeater : public HttpRequest { -public: - RequestRepeater(QObject *parent, const QString &requestURL, const QString &cacheKey = "", int period = 0, bool while_onroad=false, bool sunnylink = false); private: Params params; QTimer *timer; QString prevResp; + QString request_url; + bool while_onroad; + void timerTick(); + +public: + RequestRepeater(QObject *parent, const QString &requestURL, const QString &cacheKey = "", int period = 0, bool whileOnroad=false, bool sunnylink = false); + void ForceUpdate() { + LOGD("Forcing update for %s", qPrintable(request_url)); + timerTick(); + } }; diff --git a/selfdrive/ui/qt/widgets/controls.h b/selfdrive/ui/qt/widgets/controls.h index 583d7e2d79..2eeeb93093 100644 --- a/selfdrive/ui/qt/widgets/controls.h +++ b/selfdrive/ui/qt/widgets/controls.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -23,6 +24,10 @@ public: explicit ElidedLabel(QWidget *parent = 0); explicit ElidedLabel(const QString &text, QWidget *parent = 0); + void setColor(const QString &color) { + setStyleSheet("QLabel { color : " + color + "; }"); + } + signals: void clicked(); @@ -50,8 +55,11 @@ public: title_label->setText(title); } - void setValue(const QString &val) { + void setValue(const QString &val, std::optional color = std::nullopt) { value->setText(val); + if (color.has_value()) { + value->setColor(color.value()); + } } const QString getDescription() { diff --git a/selfdrive/ui/ui.cc b/selfdrive/ui/ui.cc index a278d8506a..9a6f355cc7 100644 --- a/selfdrive/ui/ui.cc +++ b/selfdrive/ui/ui.cc @@ -11,6 +11,7 @@ #include "common/swaglog.h" #include "common/util.h" #include "common/watchdog.h" +#include "qt/network/sunnylink/models/role_model.h" #include "system/hardware/hw.h" #define BACKLIGHT_DT 0.05 @@ -431,18 +432,14 @@ void UIState::setPrimeType(PrimeType type) { } } -void UIState::setSunnylinkRoleType(SunnylinkRoleType type) { - if (type != role_type) { - bool prev_subscriber = isSubscriber(); +void UIState::setSunnylinkRoles(const std::vector& roles) { + sunnylinkRoles = roles; + emit sunnylinkRolesChanged(roles); +} - role_type = type; - emit sunnylinkRoleTypeChanged(role_type); - - bool subscriber = isSubscriber(); - if (prev_subscriber != subscriber) { - emit sunnylinkRoleChanged(subscriber); - } - } +void UIState::setSunnylinkDeviceUsers(const std::vector& users) { + sunnylinkUsers = users; + emit sunnylinkDeviceUsersChanged(users); } Device::Device(QObject *parent) : brightness_filter(BACKLIGHT_OFFROAD, BACKLIGHT_TS, BACKLIGHT_DT), QObject(parent) { diff --git a/selfdrive/ui/ui.h b/selfdrive/ui/ui.h index 08749a17c7..37554b4d84 100644 --- a/selfdrive/ui/ui.h +++ b/selfdrive/ui/ui.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -15,6 +16,9 @@ #include "common/mat.h" #include "common/params.h" #include "common/timing.h" +#include "qt/network/sunnylink/models/role_model.h" +#include "qt/network/sunnylink/models/sponsor_role_model.h" +#include "qt/network/sunnylink/models/user_model.h" #include "system/hardware/hw.h" const int UI_BORDER_SIZE = 30; @@ -135,27 +139,6 @@ enum PrimeType { PURPLE = 5, }; -enum SunnylinkRoleType { - SL_UNKNOWN = -1, - SL_READ_ONLY = 0, - SL_SPONSOR = 1, - SL_ADMIN = 2, // Internal use only -}; - -inline const QMap sunnylinkRoleTypeConverted() { - QMap map; - map["Unknown"] = SunnylinkRoleType::SL_UNKNOWN; - map["ReadOnly"] = SunnylinkRoleType::SL_READ_ONLY; - map["Sponsor"] = SunnylinkRoleType::SL_SPONSOR; - map["Admin"] = SunnylinkRoleType::SL_ADMIN; // Internal use only - return map; -} - -inline const int sunnylinkRoleTypeValue(const QString &roleType) { - static QMap map = sunnylinkRoleTypeConverted(); - return map.value(roleType, SunnylinkRoleType::SL_UNKNOWN); // Default to UNKNOWN if not found -} - const QColor bg_colors [] = { [STATUS_DISENGAGED] = QColor(0x17, 0x33, 0x49, 0xc8), [STATUS_OVERRIDE] = QColor(0x91, 0x9b, 0x95, 0xf1), @@ -294,9 +277,41 @@ public: inline PrimeType primeType() const { return prime_type; } inline bool hasPrime() const { return prime_type != PrimeType::UNKNOWN && prime_type != PrimeType::NONE; } - void setSunnylinkRoleType(SunnylinkRoleType type); - inline SunnylinkRoleType sunnylinkRoleType() const { return role_type; } - inline bool isSubscriber() const { return role_type != SunnylinkRoleType::SL_UNKNOWN && role_type != SunnylinkRoleType::SL_READ_ONLY; } + void setSunnylinkRoles(const std::vector &roles); + void setSunnylinkDeviceUsers(const std::vector &users); + + inline std::vector sunnylinkDeviceRoles() const { return sunnylinkRoles; } + inline bool isSunnylinkAdmin() const { + return std::any_of(sunnylinkRoles.begin(), sunnylinkRoles.end(), [](const RoleModel &role) { + return role.roleType == RoleType::Admin; + }); + } + inline bool isSunnylinkSponsor() const { + return std::any_of(sunnylinkRoles.begin(), sunnylinkRoles.end(), [](const RoleModel &role) { + return role.roleType == RoleType::Sponsor && role.as().roleTier != SponsorTier::Free; + }); + } + inline SponsorRoleModel sunnylinkSponsorRole() const { + std::optional sponsorRoleWithHighestTier = std::nullopt; + for (const auto &role : sunnylinkRoles) { + if(role.roleType != RoleType::Sponsor) + continue; + + if (auto sponsorRole = role.as(); !sponsorRoleWithHighestTier.has_value() || sponsorRoleWithHighestTier->roleTier < sponsorRole.roleTier) { + sponsorRoleWithHighestTier = sponsorRole; + } + } + return sponsorRoleWithHighestTier.value_or(SponsorRoleModel(RoleType::Sponsor, SponsorTier::Free)); + } + inline SponsorTier sunnylinkSponsorTier() const { + return sunnylinkSponsorRole().roleTier; + } + inline std::vector sunnylinkDeviceUsers() const { return sunnylinkUsers; } + inline bool isSunnylinkPaired() const { + return std::any_of(sunnylinkUsers.begin(), sunnylinkUsers.end(), [](const UserModel &user) { + return user.user_id.toLower() != "unregisteredsponsor" && user.user_id.toLower() != "temporarysponsor"; + }); + } int fb_w = 0, fb_h = 0; @@ -316,7 +331,8 @@ signals: void primeTypeChanged(PrimeType prime_type); void sunnylinkRoleChanged(bool subscriber); - void sunnylinkRoleTypeChanged(SunnylinkRoleType role_type); + void sunnylinkRolesChanged(std::vector roles); + void sunnylinkDeviceUsersChanged(std::vector users); private slots: void update(); @@ -325,7 +341,8 @@ private: QTimer *timer; bool started_prev = false; PrimeType prime_type = PrimeType::UNKNOWN; - SunnylinkRoleType role_type = SunnylinkRoleType::SL_UNKNOWN; + std::vector sunnylinkRoles = {}; + std::vector sunnylinkUsers = {}; bool last_mads_enabled = false; bool mads_path_state = false;