Merge branch 'upstream/openpilot/master' into sync-20260317

# Conflicts:
#	.github/workflows/auto_pr_review.yaml
#	.gitignore
#	opendbc_repo
#	panda
#	selfdrive/ui/mici/layouts/home.py
#	selfdrive/ui/mici/layouts/onboarding.py
#	selfdrive/ui/mici/layouts/settings/device.py
#	selfdrive/ui/tests/diff/replay.py
#	selfdrive/ui/translations/app_fr.po
#	system/ui/mici_setup.py
Sync: `commaai/opendbc:master` → `sunnypilot/opendbc:master`
Sync: `commaai/panda:master` → `sunnypilot/panda:master`
This commit is contained in:
Jason Wen
2026-03-17 17:11:48 -04:00
162 changed files with 5452 additions and 7254 deletions
+4
View File
@@ -25,6 +25,7 @@ struct CameraConfig {
uint32_t phy;
bool vignetting_correction;
SpectraOutputType output_type;
bool staggered_sof; // SOF is staggered (half-period offset) from other cameras
};
// NOTE: to be able to disable road and wide road, we still have to configure the sensor over i2c
@@ -39,6 +40,7 @@ const CameraConfig WIDE_ROAD_CAMERA_CONFIG = {
.phy = CAM_ISP_IFE_IN_RES_PHY_0,
.vignetting_correction = false,
.output_type = ISP_IFE_PROCESSED,
.staggered_sof = false,
};
const CameraConfig ROAD_CAMERA_CONFIG = {
@@ -51,6 +53,7 @@ const CameraConfig ROAD_CAMERA_CONFIG = {
.phy = CAM_ISP_IFE_IN_RES_PHY_1,
.vignetting_correction = true,
.output_type = ISP_IFE_PROCESSED,
.staggered_sof = false,
};
const CameraConfig DRIVER_CAMERA_CONFIG = {
@@ -63,6 +66,7 @@ const CameraConfig DRIVER_CAMERA_CONFIG = {
.phy = CAM_ISP_IFE_IN_RES_PHY_2,
.vignetting_correction = false,
.output_type = ISP_BPS_PROCESSED,
.staggered_sof = true,
};
const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, ROAD_CAMERA_CONFIG, DRIVER_CAMERA_CONFIG};
+14 -6
View File
@@ -1436,7 +1436,7 @@ bool SpectraCamera::waitForFrameReady(uint64_t request_id) {
}
bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp) {
if (!syncFirstFrame(cc.camera_num, request_id, frame_id_raw, timestamp)) {
if (!syncFirstFrame(cc.camera_num, request_id, frame_id_raw, timestamp, cc.staggered_sof)) {
return false;
}
@@ -1455,23 +1455,31 @@ bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t fram
return true;
}
bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp) {
bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered) {
if (first_frame_synced) return true;
// Store the frame data for this camera
camera_sync_data[camera_id] = SyncData{timestamp, raw_id + 1};
camera_sync_data[camera_id] = SyncData{timestamp, raw_id + 1, staggered};
// Ensure all cameras are up
int enabled_camera_count = std::count_if(std::begin(ALL_CAMERA_CONFIGS), std::end(ALL_CAMERA_CONFIGS),
[](const auto &config) { return config.enabled; });
bool all_cams_up = camera_sync_data.size() == enabled_camera_count;
// Wait until the timestamps line up
// Check that camera timestamps are properly aligned:
// - non-staggered cameras should be within 0.2ms of each other
// - staggered cameras should be within 0.2ms of a 25ms offset from non-staggered cameras
const uint64_t half_period_ns = 25 * 1000000ULL; // 25ms
const uint64_t tolerance_ns = 200000ULL; // 0.2ms
bool all_cams_synced = true;
for (const auto &[_, sync_data] : camera_sync_data) {
for (const auto &[cam, sync_data] : camera_sync_data) {
if (cam == camera_id) continue;
uint64_t diff = std::max(timestamp, sync_data.timestamp) -
std::min(timestamp, sync_data.timestamp);
if (diff > 0.2*1e6) { // milliseconds
bool pair_staggered = staggered != sync_data.staggered;
uint64_t expected_offset = pair_staggered ? half_period_ns : 0;
uint64_t error = (diff > expected_offset) ? diff - expected_offset : expected_offset - diff;
if (error > tolerance_ns) {
all_cams_synced = false;
}
}
+2 -1
View File
@@ -194,10 +194,11 @@ private:
bool validateEvent(uint64_t request_id, uint64_t frame_id_raw);
bool waitForFrameReady(uint64_t request_id);
bool processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp);
static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp);
static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered);
struct SyncData {
uint64_t timestamp;
uint64_t frame_id_offset = 0;
bool staggered = false;
};
inline static std::map<int, SyncData> camera_sync_data;
inline static bool first_frame_synced = false;
+9 -3
View File
@@ -105,17 +105,23 @@ class TestCamerad:
assert set(np.diff(logs[c]['frameId'])) == {1, }, f"{c} has frame skips"
def test_frame_sync(self, logs):
SYNCED_CAMS = ('roadCameraState', 'wideRoadCameraState')
n = range(len(logs['roadCameraState']['t'][:-10]))
frame_ids = {i: [logs[cam]['frameId'][i] for cam in CAMERAS] for i in n}
assert all(len(set(v)) == 1 for v in frame_ids.values()), "frame IDs not aligned"
frame_times = {i: [logs[cam]['timestampSof'][i] for cam in CAMERAS] for i in n}
diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in frame_times.items()}
# road and wide cameras should be synced within 1.1ms
synced_times = {i: [logs[cam]['timestampSof'][i] for cam in SYNCED_CAMS] for i in n}
diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in synced_times.items()}
laggy_frames = {k: v for k, v in diffs.items() if v > 1.1}
assert len(laggy_frames) == 0, f"Frames not synced properly: {laggy_frames=}"
# driver camera should be staggered ~25ms from road camera
for i in n:
offset_ms = abs(logs['driverCameraState']['timestampSof'][i] - logs['roadCameraState']['timestampSof'][i]) / 1e6
assert 20 < offset_ms < 30, f"driver camera stagger out of range at frame {i}: {offset_ms:.1f}ms (expected ~25ms)"
def test_sanity_checks(self, logs):
self._sanity_checks(logs)
+11 -11
View File
@@ -56,28 +56,28 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756.img.xz",
"hash": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"hash_raw": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"size": 17496064,
"url": "https://commadist.azureedge.net/agnosupdate/boot-d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51.img.xz",
"hash": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51",
"hash_raw": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51",
"size": 17500160,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "0ee1ab104bb46d0f72e7d0b7d3e94629a7644a368896c6d4c558554fb955a08a"
"ondevice_hash": "2454108de1161289bc4a75449ad6421f1772b13b3e5cba68a84fca7530557699"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img.xz",
"hash": "7c58308be461126677ba02e9c9739556520ee02958934733867d86ecfe2e58e9",
"hash_raw": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img.xz",
"hash": "5f319030ad05942267b77f1a4686c4ca24cc09b2c2a4688e57342ffc9720fd49",
"hash_raw": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "826790516410c325aa30265846946d06a556f0a7b23c957f65fd11c055a663da",
"ondevice_hash": "c12f1b7d790a418aea17424accf4cd59c575e5745cad82bdc9452f384483648c",
"alt": {
"hash": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img",
"hash": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5",
"url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img",
"size": 4718592000
}
}
+19 -30
View File
@@ -339,62 +339,51 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756.img.xz",
"hash": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"hash_raw": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756",
"size": 17496064,
"url": "https://commadist.azureedge.net/agnosupdate/boot-d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51.img.xz",
"hash": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51",
"hash_raw": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51",
"size": 17500160,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "0ee1ab104bb46d0f72e7d0b7d3e94629a7644a368896c6d4c558554fb955a08a"
"ondevice_hash": "2454108de1161289bc4a75449ad6421f1772b13b3e5cba68a84fca7530557699"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img.xz",
"hash": "7c58308be461126677ba02e9c9739556520ee02958934733867d86ecfe2e58e9",
"hash_raw": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img.xz",
"hash": "5f319030ad05942267b77f1a4686c4ca24cc09b2c2a4688e57342ffc9720fd49",
"hash_raw": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "826790516410c325aa30265846946d06a556f0a7b23c957f65fd11c055a663da",
"ondevice_hash": "c12f1b7d790a418aea17424accf4cd59c575e5745cad82bdc9452f384483648c",
"alt": {
"hash": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd",
"url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img",
"hash": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5",
"url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img",
"size": 4718592000
}
},
{
"name": "userdata_90",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb.img.xz",
"hash": "aa0f0fe32187493e6135aee9e984d3f9705fc58560d537b34687bb6b51a38428",
"hash_raw": "ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-a7b25ea29255f4fd3a2da99e037f40b4ca10bd4afd57dd96563353b8dfb0f634.img.xz",
"hash": "7ea9d7d4685ec36bbfdf06afe0b51650d567416c3092fef96bd97158ed322742",
"hash_raw": "a7b25ea29255f4fd3a2da99e037f40b4ca10bd4afd57dd96563353b8dfb0f634",
"size": 96636764160,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "9c916b7d05543d4608b0401bc867639f44ce9671639a1a6da83b6d58b4eaa1b4"
"ondevice_hash": "79ed653c1679d84b13ee23083a511b0e668454e4af9b0db99a3279072ed041c1"
},
{
"name": "userdata_89",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11.img.xz",
"hash": "fa83d4b7096857136820b0b0a8785c90677256b054c5c14039cd7b9b1065a90b",
"hash_raw": "7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-8e428632c967aa609cac184bff938a90240e53ffd3b4fca40bc94c33c81202ba.img.xz",
"hash": "7104cdb0384e4ecb1ebfa6136a2330251bc8aa829b9ec48c4b740f656252d382",
"hash_raw": "8e428632c967aa609cac184bff938a90240e53ffd3b4fca40bc94c33c81202ba",
"size": 95563022336,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "1699e38de769eb32c21dfa6a5ac21eb3ad620a362c7b8abf1a2c0afe0f717530"
},
{
"name": "userdata_30",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed.img.xz",
"hash": "890910f20b1ad88a728ee822a47b1234eb3d70cab28ca8a935679c8c2d33cbe9",
"hash_raw": "3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed",
"size": 32212254720,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8e7cb392dd6e49c7d59fa850be7d1f44901314c86ba9c88be5bb27a0cd1123c9"
"ondevice_hash": "fbede3b0831dbc4a4edd336e5f547f4978902b9421fb1484e86c416192c59165"
}
]
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d815a9140e69242d85e57f74a14c6372809eebefe8958be9152efa7874928ccc
size 71215510
oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc
size 24709205
+34 -9
View File
@@ -7,7 +7,7 @@ import math
import time
import requests
import shutil
import subprocess
from serial import Serial
import datetime
from multiprocessing import Process, Event
from typing import NoReturn
@@ -90,9 +90,24 @@ measurementStatusGlonassFields = {
def try_setup_logs(diag, logs):
return setup_logs(diag, logs)
@retry(attempts=3, delay=1.0)
def at_cmd(cmd: str) -> str | None:
return subprocess.check_output(f"mmcli -m any --timeout 30 --command='{cmd}'", shell=True, encoding='utf8')
AT_PORT = "/dev/modem_at0"
@retry(attempts=5, delay=1.0)
def at_cmd(cmd: str) -> str:
with Serial(AT_PORT, baudrate=115200, timeout=5) as ser:
ser.reset_input_buffer()
ser.write(f"{cmd}\r".encode())
lines = []
while True:
line = ser.readline()
if not line:
raise RuntimeError(f"AT command timeout: {cmd}")
line = line.decode('utf-8', errors='replace').strip()
if line in ("OK", "ERROR") or line.startswith("+CME ERROR"):
break
if line and line != cmd:
lines.append(line)
return '\n'.join(lines)
def gps_enabled() -> bool:
return "QGPS: 1" in at_cmd("AT+QGPS?")
@@ -131,6 +146,7 @@ def downloader_loop(event):
@retry(attempts=5, delay=0.2, ignore_failure=True)
def inject_assistance():
import subprocess
cmd = f"mmcli -m any --timeout 30 --location-inject-assistance-data={ASSIST_DATA_FILE}"
subprocess.check_output(cmd, stderr=subprocess.PIPE, shell=True)
cloudlog.info("successfully loaded assistance data")
@@ -207,13 +223,19 @@ def teardown_quectel(diag):
try_setup_logs(diag, [])
def wait_for_modem(cmd="AT+QGPS?"):
def wait_for_modem():
cloudlog.warning("waiting for modem to come up")
while not os.path.exists(AT_PORT):
time.sleep(0.5)
# wait until the modem GNSS subsystem responds
while True:
ret = subprocess.call(f"mmcli -m any --timeout 10 --command=\"{cmd}\"", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True)
if ret == 0:
return
time.sleep(0.1)
try:
resp = at_cmd("AT+QGPS?")
if "+QGPS:" in resp:
return
except Exception:
pass
time.sleep(0.5)
def main() -> NoReturn:
@@ -335,6 +357,9 @@ def main() -> NoReturn:
report = unpack_position(log_payload)
if report["u_PosSource"] != 2:
continue
# uint16_t max is an invalid sentinel value from the modem
if report['w_GpsWeekNumber'] >= 0xFFFF:
continue
vNED = [report["q_FltVelEnuMps[1]"], report["q_FltVelEnuMps[0]"], -report["q_FltVelEnuMps[2]"]]
vNEDsigma = [report["q_FltVelSigmaMps[1]"], report["q_FltVelSigmaMps[0]"], -report["q_FltVelSigmaMps[2]"]]
+11 -11
View File
@@ -1,9 +1,7 @@
import os
import pytest
import json
import time
import datetime
import subprocess
import cereal.messaging as messaging
from openpilot.system.qcomgpsd.qcomgpsd import at_cmd, wait_for_modem
@@ -44,15 +42,13 @@ class TestRawgpsd:
return self.sm.updated['qcomGnss']
def test_no_crash_double_command(self):
wait_for_modem()
at_cmd("AT+QGPSDEL=0")
at_cmd("AT+QGPSDEL=0")
def test_wait_for_modem(self):
os.system("sudo systemctl stop ModemManager")
managed_processes['qcomgpsd'].start()
assert not self._wait_for_output(5)
os.system("sudo systemctl restart ModemManager")
assert self._wait_for_output(30)
def test_startup_time(self, subtests):
@@ -61,7 +57,7 @@ class TestRawgpsd:
os.system("sudo systemctl stop systemd-resolved")
with subtests.test(internet=internet):
managed_processes['qcomgpsd'].start()
assert self._wait_for_output(7)
assert self._wait_for_output(30)
managed_processes['qcomgpsd'].stop()
def test_turns_off_gnss(self, subtests):
@@ -71,14 +67,15 @@ class TestRawgpsd:
time.sleep(s)
managed_processes['qcomgpsd'].stop()
ls = subprocess.check_output("mmcli -m any --location-status --output-json", shell=True, encoding='utf-8')
loc_status = json.loads(ls)
assert set(loc_status['modem']['location']['enabled']) <= {'3gpp-lac-ci'}
wait_for_modem()
resp = at_cmd("AT+QGPS?")
assert "+QGPS: 0" in resp
def check_assistance(self, should_be_loaded):
# after QGPSDEL: '+QGPSXTRADATA: 0,"1980/01/05,19:00:00"'
# after loading: '+QGPSXTRADATA: 10080,"2023/06/24,19:00:00"'
wait_for_modem()
out = at_cmd("AT+QGPSXTRADATA?")
out = out.split("+QGPSXTRADATA:")[1].split("'")[0].strip()
valid_duration, injected_time_str = out.split(",", 1)
@@ -92,20 +89,23 @@ class TestRawgpsd:
assert injected_time_str[:] == '1980/01/05,19:00:00'[:]
assert valid_duration == '0'
@pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17")
def test_assistance_loading(self):
managed_processes['qcomgpsd'].start()
assert self._wait_for_output(10)
assert self._wait_for_output(30)
managed_processes['qcomgpsd'].stop()
self.check_assistance(True)
@pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17")
def test_no_assistance_loading(self):
os.system("sudo systemctl stop systemd-resolved")
managed_processes['qcomgpsd'].start()
assert self._wait_for_output(10)
assert self._wait_for_output(30)
managed_processes['qcomgpsd'].stop()
self.check_assistance(False)
@pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17")
def test_late_assistance_loading(self):
os.system("sudo systemctl stop systemd-resolved")
+2 -2
View File
@@ -5,7 +5,7 @@ import time
from typing import NoReturn
import cereal.messaging as messaging
from openpilot.common.time_helpers import min_date, system_time_valid
from openpilot.common.time_helpers import min_date, MAX_DATE, system_time_valid
from openpilot.common.swaglog import cloudlog
from openpilot.common.params import Params
from openpilot.common.gps import get_gps_location_service
@@ -52,7 +52,7 @@ def main() -> NoReturn:
continue
if not gps.hasFix:
continue
if gps_time < min_date():
if gps_time < min_date() or gps_time > MAX_DATE:
continue
set_time(gps_time)
+44 -6
View File
@@ -1,5 +1,6 @@
import atexit
import cffi
import math
import os
import queue
import time
@@ -96,7 +97,6 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
BOLD = "Inter-Bold.fnt"
@@ -172,6 +172,10 @@ class MouseState:
self._rk.keep_time()
def _handle_mouse_event(self):
# TODO: read touch events from evdev directly to get real kernel timestamps.
# Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll
# velocity oscillate (alternating high/low). Real timestamps would also let us
# detect swipe-stop-lift via event gaps instead of the fragile decel heuristic.
for slot in range(MAX_TOUCH_SLOTS):
mouse_pos = rl.get_touch_position(slot)
x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x
@@ -283,7 +287,7 @@ class GuiApplication(GuiApplicationExt):
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
if needs_render_texture:
self._render_texture = rl.load_render_texture(self._width, self._height)
self._render_texture = rl.load_render_texture(self._scaled_width, self._scaled_height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if RECORD:
@@ -294,13 +298,13 @@ class GuiApplication(GuiApplicationExt):
'-nostats', # Suppress encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._width}x{self._height}', # Input resolution
'-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p
'-r', str(output_fps), # Output frame rate (for speed multiplier)
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-preset', 'veryfast',
'-crf', str(RECORD_QUALITY)
]
if RECORD_BITRATE:
@@ -324,6 +328,7 @@ class GuiApplication(GuiApplicationExt):
self._set_styles()
self._load_fonts()
self._patch_text_functions()
self._patch_scissor_mode()
if BURN_IN_MODE and self._burn_in_shader is None:
self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER)
@@ -459,6 +464,12 @@ class GuiApplication(GuiApplicationExt):
with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio, flip_x)
texture_obj = self._load_texture_from_image(image_obj)
# Set logical size so widget layout math stays at 1x coordinates
if self._scale != 1.0 and width is not None and height is not None:
texture_obj.width = width
texture_obj.height = height
self._textures[cache_key] = texture_obj
return texture_obj
@@ -470,6 +481,11 @@ class GuiApplication(GuiApplicationExt):
if alpha_premultiply:
rl.image_alpha_premultiply(image)
# Scale up load size for sharper rendering, capped at source resolution
if self._scale != 1.0 and width is not None and height is not None:
width = min(int(width * self._scale), image.width)
height = min(int(height * self._scale), image.height)
if width is not None and height is not None:
same_dimensions = image.width == width and image.height == height
@@ -593,6 +609,10 @@ class GuiApplication(GuiApplicationExt):
rl.begin_drawing()
rl.clear_background(rl.BLACK)
if self._scale != 1.0:
rl.rl_push_matrix()
rl.rl_scalef(self._scale, self._scale, 1.0)
# Allow a Widget to still run a function regardless of the stack depth
for tick in self._nav_stack_ticks:
tick()
@@ -603,11 +623,14 @@ class GuiApplication(GuiApplicationExt):
yield True
if self._scale != 1.0:
rl.rl_pop_matrix()
if self._render_texture:
rl.end_texture_mode()
rl.begin_drawing()
rl.clear_background(rl.BLACK)
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height))
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
texture = self._render_texture.texture
if texture:
@@ -664,7 +687,8 @@ class GuiApplication(GuiApplicationExt):
fnt_path = fspath / font_weight_file
font = rl.load_font(fnt_path.as_posix())
if font_weight_file != FontWeight.UNIFONT:
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.gen_texture_mipmaps(font.texture)
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
self._fonts[font_weight_file] = font
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
@@ -686,6 +710,20 @@ class GuiApplication(GuiApplicationExt):
rl.draw_text_ex = _draw_text_ex_scaled
def _patch_scissor_mode(self):
if self._scale == 1.0:
return
if not hasattr(rl, "_orig_begin_scissor_mode"):
rl._orig_begin_scissor_mode = rl.begin_scissor_mode
def _begin_scissor_mode_scaled(x, y, width, height):
return rl._orig_begin_scissor_mode(
int(x * self._scale), int(y * self._scale),
int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale)))
rl.begin_scissor_mode = _begin_scissor_mode_scaled
def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
+24 -1
View File
@@ -20,6 +20,21 @@ MAX_SPEED = 10000.0 # px/s
DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
# Weights older (steadier) velocity samples more heavily on release.
# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity.
# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team:
# https://github.com/flutter/flutter/pull/60501
# 3 samples ≈ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes.
def weighted_velocity(buffer: deque) -> float:
if len(buffer) >= 3:
return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05
elif len(buffer) == 2:
return buffer[-2] * 0.7 + buffer[-1] * 0.3
elif len(buffer) == 1:
return buffer[-1]
return 0.0
# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
class ScrollState(Enum):
STEADY = 0
@@ -151,7 +166,13 @@ class GuiScrollPanel2:
# Touch rejection: when releasing finger after swiping and stopping, panel
# reports a few erroneous touch events with high velocity, try to ignore.
# If velocity decelerates very quickly, assume user doesn't intend to auto scroll
# If velocity decelerates very quickly, assume user doesn't intend to auto scroll.
# Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer)
# 2) dirty finger lift where finger rotates/slides producing spurious velocity spike.
# TODO: this heuristic false-positives on fast swipes because 140Hz touch polling
# jitter causes velocity to oscillate (not real deceleration). Better approaches:
# - Use evdev kernel timestamps to eliminate velocity oscillation at the source
# - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift
high_decel = False
if len(self._velocity_buffer) > 2:
# We limit max to first half since final few velocities can surpass first few
@@ -166,6 +187,8 @@ class GuiScrollPanel2:
print('deceleration too high, going to STEADY')
high_decel = True
self._velocity = weighted_velocity(self._velocity_buffer)
# If final velocity is below some threshold, switch to steady state too
low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
+6 -2
View File
@@ -854,8 +854,12 @@ class WifiManager:
# NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed)
wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE)
wifi_props = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()).body[0]
ap_paths = wifi_props.get('AccessPoints', ('ao', []))[1]
wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all())
if wifi_props_reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}")
return
ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1]
aps: dict[str, list[AccessPoint]] = {}
+91 -91
View File
@@ -1,18 +1,17 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
import threading
from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.slider import SmallSlider
from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.mici_setup import GreyBigButton, FailedPage
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationCircleButton
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
@@ -21,35 +20,85 @@ TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
TAP_RESET = 2 # user initiated a factory reset by tapping the screen during boot
class ResetState(IntEnum):
NONE = 0
RESETTING = 1
FAILED = 2
class ResetFailedPage(FailedPage):
def __init__(self):
super().__init__(None, "reset failed", "reboot to try again", icon="icons_mici/setup/reset_failed.png")
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0 # not dismissable
def _back_enabled(self) -> bool:
return False
class Reset(Widget):
class ResettingPage(BigDialog):
DOT_STEP = 0.6
def __init__(self):
super().__init__("resetting device", "this may take up to\na minute...",
gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
self._show_time = 0.0
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0 # not dismissable
self._show_time = rl.get_time()
def _back_enabled(self) -> bool:
return False
def _render(self, _):
t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2)
dots = "." * min(int(t / (self.DOT_STEP / 4)), 3)
self._card.set_value(f"this may take up to\na minute{dots}")
super()._render(_)
class Reset(Scroller):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._previous_active_widget = None
self._reset_failed = False
self._timeout_st = time.monotonic()
self._cancel_button = SmallButton("cancel")
self._cancel_button.set_click_callback(gui_app.request_close)
self._resetting_page = ResettingPage()
self._reset_failed_page = ResetFailedPage()
self._reboot_button = FullRoundedButton("reboot")
self._reboot_button.set_click_callback(self._do_reboot)
self._reset_button = BigConfirmationCircleButton("reset &\nerase", gui_app.texture("icons_mici/settings/device/uninstall.png", 70, 70),
self._start_reset, exit_on_confirm=False, red=True)
self._cancel_button = BigConfirmationCircleButton("cancel", gui_app.texture("icons_mici/setup/cancel.png", 64, 64),
gui_app.request_close, exit_on_confirm=False)
self._reboot_button = BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
HARDWARE.reboot, exit_on_confirm=False)
self._confirm_slider = SmallSlider("reset", self._confirm)
# show reboot button if in recover mode
self._cancel_button.set_visible(mode != ResetMode.RECOVER)
self._reboot_button.set_visible(mode == ResetMode.RECOVER)
def _do_reboot(self):
if PC:
return
main_card = GreyBigButton("factory reset", "resetting erases\nall user content & data",
gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
self._scroller.add_widget(main_card)
os.system("sudo reboot")
if mode != ResetMode.USER_RESET:
self._scroller.add_widget(GreyBigButton("", "Resetting erases all user content & data."))
if mode == ResetMode.RECOVER:
main_card.set_value("user data partition\ncould not be mounted")
elif mode == ResetMode.TAP_RESET:
main_card.set_value("reset triggered by\ntapping the screen")
self._scroller.add_widgets([
GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"),
self._cancel_button,
self._reboot_button,
self._reset_button,
])
gui_app.add_nav_stack_tick(self._nav_stack_tick)
def _do_erase(self):
if PC:
@@ -63,87 +112,38 @@ class Reset(Widget):
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
self._reset_failed = True
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _start_reset(self):
def do_erase_thread():
threading.Thread(target=self._do_erase, daemon=True).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._resetting_page.set_shown_callback(do_erase_thread)
gui_app.push_widget(self._resetting_page)
def _nav_stack_tick(self):
if self._reset_failed:
self._reset_failed = False
gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._reset_failed_page))
active_widget = gui_app.get_active_widget()
if active_widget != self._previous_active_widget:
self._previous_active_widget = active_widget
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
elif self._mode != ResetMode.RECOVER and active_widget != self._resetting_page and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
if self._reset_state != ResetState.RESETTING:
# fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
if self._mode == ResetMode.RECOVER:
self._cancel_button.set_text("reboot")
self._cancel_button.set_click_callback(self._do_reboot)
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
if self._reset_state != ResetState.FAILED:
self._confirm_slider.render(rl.Rectangle(
rect.x + rect.width - self._confirm_slider.rect.width,
rect.y + rect.height - self._confirm_slider.rect.height,
self._confirm_slider.rect.width,
self._confirm_slider.rect.height))
else:
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height))
def _confirm(self):
self.start_reset()
def _get_body_text(self):
if self._reset_state == ResetState.RESETTING:
return "Resetting device... This may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. It may be corrupted."
return "All content and settings will be erased."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
elif sys.argv[1] == '--tap-reset':
mode = ResetMode.TAP_RESET
gui_app.init_window("System Reset")
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
gui_app.push_widget(reset)
for _ in gui_app.render():
+132 -178
View File
@@ -1,61 +1,52 @@
#!/usr/bin/env python3
import os
import re
import ssl
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
import shutil
from collections.abc import Callable
import pyray as rl
from cereal import log
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.filter_simple import BounceFilter
from openpilot.system.hardware import HARDWARE, TICI
from openpilot.common.realtime import config_realtime_process, set_core_affinity
from openpilot.common.swaglog import cloudlog
from openpilot.common.time_helpers import system_time_valid
from openpilot.common.utils import run_cmd
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.button import SmallButton
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import Scroller, NavScroller, ITEM_SPACING
from openpilot.system.ui.widgets.slider import LargerSlider, SmallSlider
from openpilot.system.ui.widgets.slider import LargerSlider
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationCircleButton
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton
NetworkType = log.DeviceState.NetworkType
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class NetworkConnectivityMonitor:
def __init__(self, should_check: Callable[[], bool] | None = None):
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self.recheck_event = threading.Event()
self._should_check = should_check or (lambda: True)
self._stop_event = threading.Event()
self._last_timesyncd_restart = 0.0
self._thread: threading.Thread | None = None
def start(self):
@@ -74,15 +65,32 @@ class NetworkConnectivityMonitor:
self.network_connected.clear()
self.wifi_connected.clear()
def invalidate(self):
self.recheck_event.set()
self.reset()
def _run(self):
while not self._stop_event.is_set():
if self._should_check():
try:
request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
urllib.request.urlopen(request, timeout=2.0)
# Discard stale result if invalidated during request
if self.recheck_event.is_set():
self.recheck_event.clear()
continue
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
except urllib.error.URLError as e:
if (isinstance(e.reason, ssl.SSLCertVerificationError) and
not system_time_valid() and
time.monotonic() - self._last_timesyncd_restart > 5):
self._last_timesyncd_restart = time.monotonic()
run_cmd(["sudo", "systemctl", "restart", "systemd-timesyncd"])
self.reset()
except Exception:
self.reset()
else:
@@ -102,7 +110,7 @@ class StartPage(Widget):
self._start_bg_txt = gui_app.texture("icons_mici/setup/start_button.png", 500, 224, keep_aspect_ratio=False)
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False)
self._scale_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
def _render(self, rect: rl.Rectangle):
@@ -122,9 +130,9 @@ class SoftwareSelectionPage(NavWidget):
use_custom_software_callback: Callable):
super().__init__()
self._openpilot_slider = LargerSlider("slide to install\nopenpilot", use_openpilot_callback)
self._openpilot_slider = self._child(LargerSlider("slide to install\nopenpilot", use_openpilot_callback))
self._openpilot_slider.set_enabled(lambda: self.enabled and not self.is_dismissing)
self._custom_software_slider = LargerSlider("slide to install\nother software", use_custom_software_callback, green=False)
self._custom_software_slider = self._child(LargerSlider("slide to install\ncustom software", use_custom_software_callback, green=False, shimmer_offset=0.4))
self._custom_software_slider.set_enabled(lambda: self.enabled and not self.is_dismissing)
def show_event(self):
@@ -170,18 +178,19 @@ class CustomSoftwareWarningPage(NavScroller):
self._continue_button.set_click_callback(continue_callback)
self._scroller.add_widgets([
GreyBigButton("use caution", "when installing\n3rd party software",
GreyBigButton("caution: installing\n3rd party software", "swipe down to go back",
gui_app.texture("icons_mici/setup/warning.png", 64, 58)),
GreyBigButton("", "• It has not been tested by comma"),
GreyBigButton("", "• It may not comply with relevant safety standards."),
GreyBigButton("", "• It may cause damage to your device and/or vehicle."),
GreyBigButton("", "• It has not been tested by comma."),
GreyBigButton("", "• It may not comply with safety standards."),
GreyBigButton("", "• It may damage your device and/or vehicle."),
GreyBigButton("how to restore to a\nfactory state later", "https://flash.comma.ai",
gui_app.texture("icons_mici/setup/restore.png", 64, 64)),
self._continue_button,
])
class DownloadingPage(Widget):
# TODO: unifi with updater's progress page
class DownloadingPage(NavWidget):
def __init__(self):
super().__init__()
@@ -191,8 +200,12 @@ class DownloadingPage(Widget):
font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._progress = 0
def _back_enabled(self) -> bool:
return False
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0 # not dismissable
self.set_progress(0)
def set_progress(self, progress: int):
@@ -216,97 +229,29 @@ class DownloadingPage(Widget):
))
class FailedPageBase(Widget):
def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
class FailedPage(NavScroller):
def __init__(self, retry_callback: Callable | None, title: str = "download failed",
description: str | None = None, icon: str = "icons_mici/setup/warning.png"):
super().__init__()
self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
font_weight=FontWeight.ROMAN)
self._reboot_slider = SmallSlider("reboot", reboot_callback)
self._reboot_slider.set_enabled(lambda: self.enabled) # for nav stack
self._retry_button = SmallButton("retry")
self._retry_button.set_click_callback(retry_callback)
self._retry_button.set_enabled(lambda: self.enabled) # for nav stack
def set_reason(self, reason: str):
self._reason_label.set_text(reason)
def show_event(self):
super().show_event()
self._reboot_slider.reset()
def _render(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10,
rect.width,
64,
))
self._reason_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10 + 64,
rect.width,
36,
))
self._retry_button.set_opacity(1 - self._reboot_slider.slider_percentage)
self._retry_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._retry_button.rect.height,
self._retry_button.rect.width,
self._retry_button.rect.height,
))
self._reboot_slider.render(rl.Rectangle(
self._rect.x + self._rect.width - self._reboot_slider.rect.width,
self._rect.y + self._rect.height - self._reboot_slider.rect.height,
self._reboot_slider.rect.width,
self._reboot_slider.rect.height,
))
class FailedPage(FailedPageBase, NavWidget):
def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
super().__init__(reboot_callback, retry_callback, title)
self.set_back_callback(retry_callback)
self._reason_card = GreyBigButton("", "")
self._reason_card.set_visible(False)
class GreyBigButton(BigButton):
"""Users should manage newlines with this class themselves"""
self._scroller.add_widgets([
GreyBigButton(title, description or "swipe down to go\nback and try again",
gui_app.texture(icon, 64, 58)),
self._reason_card,
BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
HARDWARE.reboot, exit_on_confirm=False),
])
LABEL_HORIZONTAL_PADDING = 30
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_touch_valid_callback(lambda: False)
self._rect.width = 476
self._label.set_font_size(36)
self._label.set_font_weight(FontWeight.BOLD)
self._label.set_line_height(1.0)
self._sub_label.set_font_size(36)
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._sub_label.set_line_height(0.95)
@property
def LABEL_VERTICAL_PADDING(self):
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _render(self, _):
rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15)))
self._draw_content(self._rect.y)
def set_reason(self, reason: str):
if reason:
self._reason_card.set_value(reason)
self._reason_card.set_visible(True)
else:
self._reason_card.set_visible(False)
class BigPillButton(BigButton):
@@ -354,7 +299,6 @@ class NetworkSetupPageBase(Scroller):
self._wifi_manager.set_active(True)
self._network_monitor = network_monitor
self._custom_software = False
self._prev_has_internet = False
self._wifi_ui = WifiUIMici(self._wifi_manager)
self._connect_button = GreyBigButton("connect to\ninternet", "swipe down to go back",
@@ -364,8 +308,9 @@ class NetworkSetupPageBase(Scroller):
self._wifi_button = WifiNetworkButton(self._wifi_manager)
self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
self._show_time = 0.0
self._pending_has_internet_scroll = False
self._prev_has_internet = False
self._prev_wifi_connected = False
self._pending_has_internet_scroll: float | None = None # stores time to use as delay
self._pending_continue_grow_animation = False
self._pending_wifi_grow_animation = False
@@ -375,7 +320,7 @@ class NetworkSetupPageBase(Scroller):
# trigger grow when wifi button in view
self._pending_wifi_grow_animation = True
self._waiting_button = BigPillButton("waiting for\ninternet...", disabled_background=True)
self._waiting_button = BigPillButton("connect to\ncontinue", disabled_background=True)
self._waiting_button.set_click_callback(on_waiting_click)
self._continue_button = BigPillButton("install openpilot", green=True)
self._continue_button.set_click_callback(lambda: continue_callback(self._custom_software))
@@ -391,45 +336,77 @@ class NetworkSetupPageBase(Scroller):
def show_event(self):
super().show_event()
self._show_time = rl.get_time()
self._prev_has_internet = False
self._pending_has_internet_scroll = False
# make sure we populate strength and ip immediately if already have wifi
self._wifi_manager.set_active(True)
self._prev_has_internet = self._has_internet
self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED
self._pending_has_internet_scroll = None
self._pending_continue_grow_animation = False
self._pending_wifi_grow_animation = False
if self._prev_has_internet or self._prev_wifi_connected:
self.set_shown_callback(lambda: self._scroll_to_end_and_grow())
@property
def _has_internet(self) -> bool:
network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING
if network_changing:
self._network_monitor.invalidate()
has_internet = (self._network_monitor.network_connected.is_set() and
not network_changing and
not self._network_monitor.recheck_event.is_set())
return has_internet
def _nav_stack_tick(self):
# Only run tick when this page or its WiFi UI is on the stack
if gui_app.get_active_widget() is not self and not gui_app.widget_in_stack(self._wifi_ui):
self._wifi_manager.process_callbacks()
return
# Check network state before processing callbacks so forgetting flag
# is still set on the frame the forgotten callback fires
has_internet = self._has_internet
wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED
self._continue_button.set_visible(has_internet)
self._waiting_button.set_visible(not has_internet)
# TODO: fire show/hide events on visibility changes
if not has_internet:
self._pending_continue_grow_animation = False
self._waiting_button.set_text("waiting for\ninternet..." if wifi_connected else "connect to\ncontinue")
self._wifi_manager.process_callbacks()
has_internet = self._network_monitor.network_connected.is_set()
if has_internet and not self._prev_has_internet:
self._pending_has_internet_scroll = True
# Dismiss WiFi UI and scroll on WiFi connect or internet gain
if (has_internet and not self._prev_has_internet) or (wifi_connected and not self._prev_wifi_connected):
# TODO: cancel if connect is transient
self._pending_has_internet_scroll = rl.get_time()
self._prev_has_internet = has_internet
self._prev_wifi_connected = wifi_connected
if self._pending_has_internet_scroll:
if self._pending_has_internet_scroll is not None:
# Scrolls over to continue button, then grows once in view
elapsed = rl.get_time() - self._show_time
if elapsed > 0.5:
self._pending_has_internet_scroll = False
def scroll_to_download():
self._scroller._layout()
end_offset = -(self._scroller.content_size - self._rect.width)
remaining = self._scroller.scroll_panel.get_offset() - end_offset
self._scroller.scroll_to(remaining, smooth=True, block_interaction=True)
self._pending_continue_grow_animation = True
elapsed = rl.get_time() - self._pending_has_internet_scroll
if elapsed > 0.7 or gui_app.get_active_widget() is self: # instant scroll + grow if not popping
# Animate WifiUi down first before scroll
gui_app.pop_widgets_to(self, scroll_to_download)
self._pending_has_internet_scroll = None
gui_app.pop_widgets_to(self, self._scroll_to_end_and_grow)
def _scroll_to_end_and_grow(self):
self._scroller._layout()
end_offset = -(self._scroller.content_size - self._rect.width)
remaining = self._scroller.scroll_panel.get_offset() - end_offset
self._scroller.scroll_to(remaining, smooth=True, block_interaction=True)
self._pending_continue_grow_animation = True
def set_custom_software(self, custom_software: bool):
self._custom_software = custom_software
self._continue_button.set_text("install openpilot" if not custom_software else "choose software")
self._continue_button.set_green(not custom_software)
def set_is_updater(self):
self._continue_button.set_text("download\n& install")
self._continue_button.set_green(False)
def _update_state(self):
super()._update_state()
@@ -444,13 +421,6 @@ class NetworkSetupPageBase(Scroller):
self._pending_wifi_grow_animation = False
self._wifi_button.trigger_grow_animation()
if self._network_monitor.network_connected.is_set():
self._continue_button.set_visible(True)
self._waiting_button.set_visible(False)
else:
self._continue_button.set_visible(False)
self._waiting_button.set_visible(True)
class NetworkSetupPage(NetworkSetupPageBase, NavScroller):
def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None],
@@ -471,7 +441,6 @@ class Setup(Widget):
self._network_monitor.start()
def getting_started_button_callback():
self._software_selection_page.reset()
gui_app.push_widget(self._software_selection_page)
self._start_page = StartPage()
@@ -480,9 +449,9 @@ class Setup(Widget):
self._network_setup_page = NetworkSetupPage(self._network_monitor, self._network_setup_continue_callback, self._pop_to_software_selection)
self._software_selection_page = SoftwareSelectionPage(self._use_openpilot, lambda: gui_app.push_widget(self._custom_software_warning_page))
self._software_selection_page = SoftwareSelectionPage(self._push_network_setup, lambda: gui_app.push_widget(self._custom_software_warning_page))
self._download_failed_page = FailedPage(HARDWARE.reboot, self._pop_to_software_selection)
self._download_failed_page = FailedPage(self._pop_to_software_selection, icon="icons_mici/setup/red_warning.png")
self._custom_software_warning_page = CustomSoftwareWarningPage(lambda: self._push_network_setup(True), self._pop_to_software_selection)
@@ -497,8 +466,7 @@ class Setup(Widget):
reason = self._download_failed_reason
self._download_failed_reason = None
self._download_failed_page.set_reason(reason)
gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders
gui_app.push_widget(self._download_failed_page)
gui_app.pop_widgets_to(self._software_selection_page, lambda: gui_app.push_widget(self._download_failed_page))
def _render(self, rect: rl.Rectangle):
self._start_page.render(rect)
@@ -510,35 +478,18 @@ class Setup(Widget):
# reset sliders after dismiss completes
gui_app.pop_widgets_to(self._software_selection_page, self._software_selection_page.reset)
def _use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self._push_network_setup()
def _push_network_setup(self, custom_software: bool = False):
# to fire the correct continue callback later
self._network_setup_page.set_custom_software(custom_software)
gui_app.pop_widgets_to(self._software_selection_page, lambda: gui_app.push_widget(self._network_setup_page))
gui_app.push_widget(self._network_setup_page)
def _network_setup_continue_callback(self, custom_software: bool):
if not custom_software:
gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders
self._download(OPENPILOT_URL)
else:
def handle_keyboard_result(text):
url = text.strip()
if url:
gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders
self._download(url)
keyboard = BigInputDialog("custom software URL...", confirm_callback=handle_keyboard_result, auto_return_to_letters="./")
@@ -553,10 +504,12 @@ class Setup(Widget):
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.download_progress = 0
gui_app.push_widget(self._downloading_page)
def start_download():
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
self._downloading_page.set_shown_callback(start_download)
gui_app.push_widget(self._downloading_page)
def _download_thread(self):
try:
@@ -591,26 +544,27 @@ class Setup(Widget):
is_elf = header == b'\x7fELF'
if not is_elf:
self._download_failed_reason = "No custom software found at this URL."
self._download_failed_reason = "No custom software found at this URL: " + self.download_url.replace("https://", "", 1)
return
# NOTE: currently unused, for future logging
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
self._download_failed_reason = "Incompatible sunnypilot version"
self._download_failed_reason = "Incompatible sunnypilot version."
except Exception:
self._download_failed_reason = "Invalid URL"
self._download_failed_reason = "Invalid URL: " + self.download_url.replace("https://", "", 1)
def main():
+89 -115
View File
@@ -3,57 +3,28 @@ import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.common.realtime import config_realtime_process, set_core_affinity
from openpilot.system.hardware import HARDWARE, TICI
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.button import FullRoundedButton
from openpilot.system.ui.mici_setup import NetworkSetupPageBase, FailedPageBase, NetworkConnectivityMonitor
from openpilot.system.ui.mici_setup import (NetworkSetupPage, FailedPage, NetworkConnectivityMonitor,
GreyBigButton, BigPillButton)
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
FAILED = 3
class UpdaterNetworkSetupPage(NetworkSetupPage):
def __init__(self, network_monitor, continue_callback):
super().__init__(network_monitor, continue_callback, back_callback=None)
self._continue_button.set_text("download\n& install")
self._continue_button.set_green(False)
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
class ProgressPage(NavWidget):
def __init__(self):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self.progress_value = 0
self.progress_text = "loading"
self.process = None
self.update_thread = None
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
# TODO: network page is rendered inline, not pushed on nav stack, so auto-dismiss on internet connect doesn't work
self._network_setup_page = NetworkSetupPageBase(self._network_monitor, self._network_setup_continue_callback,
disable_connect_hint=True)
self._network_setup_page.set_is_updater()
self._network_setup_page.set_enabled(lambda: self.enabled) # for nav stack
# Buttons
self._continue_button = FullRoundedButton("continue")
self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._update_failed_page = FailedPageBase(HARDWARE.reboot, self._update_failed_retry_callback,
title="update failed")
self._progress_title_label = UnifiedLabel("", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY, line_height=0.8)
@@ -61,38 +32,92 @@ class Updater(Widget):
font_weight=FontWeight.ROMAN,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
def _back_enabled(self) -> bool:
return False
def set_progress(self, text: str, value: int):
self._progress_title_label.set_text(text.replace("_", "_\n") + "...")
self._progress_percent_label.set_text(f"{value}%")
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0 # not dismissable
self.set_progress("downloading", 0)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, rl.BLACK)
self._progress_title_label.render(rl.Rectangle(
rect.x + 12,
rect.y + 2,
rect.width,
self._progress_title_label.get_content_height(int(rect.width - 20)),
))
self._progress_percent_label.render(rl.Rectangle(
rect.x + 12,
rect.y + 18,
rect.width,
rect.height,
))
class Updater(Scroller):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.progress_value = 0
self.progress_text = "loading"
self.process = None
self.update_thread = None
self._update_failed = False
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
self._network_setup_page = UpdaterNetworkSetupPage(self._network_monitor, self._network_setup_continue_callback)
self._progress_page = ProgressPage()
self._failed_page = FailedPage(self._retry, title="update failed")
self._continue_button = BigPillButton("next")
self._continue_button.set_click_callback(lambda: gui_app.push_widget(self._network_setup_page))
self._scroller.add_widgets([
GreyBigButton("update required", "the download size\nis approximately 1 GB",
gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 64, 64)),
self._continue_button,
])
gui_app.add_nav_stack_tick(self._nav_stack_tick)
def _network_setup_continue_callback(self, _):
self.install_update()
def _update_failed_retry_callback(self):
self.set_current_screen(Screen.PROMPT)
def _retry(self):
gui_app.pop_widgets_to(self)
def set_current_screen(self, screen: Screen):
if self.current_screen != screen:
if screen == Screen.PROGRESS:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.WIFI:
if self._network_setup_page:
self._network_setup_page.show_event()
elif screen == Screen.PROMPT:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.FAILED:
if self._network_setup_page:
self._network_setup_page.hide_event()
def _nav_stack_tick(self):
self._progress_page.set_progress(self.progress_text, self.progress_value)
self.current_screen = screen
if self._update_failed:
self._update_failed = False
self.show_event()
gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._failed_page))
def install_update(self):
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "downloading"
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def start_update():
self.update_thread = threading.Thread(target=self._run_update_process, daemon=True)
self.update_thread.start()
# Start the update process in a separate thread *after* show animation completes
self._progress_page.set_shown_callback(start_update)
gui_app.push_widget(self._progress_page)
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
@@ -101,7 +126,7 @@ class Updater(Widget):
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
except Exception:
self.set_current_screen(Screen.FAILED)
self._update_failed = True
return
if self.process.stdout is not None:
@@ -118,58 +143,7 @@ class Updater(Widget):
if exit_code == 0:
HARDWARE.reboot()
else:
self.set_current_screen(Screen.FAILED)
def render_prompt_screen(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y - 5,
rect.width,
48,
))
subtitle_width = rect.width - 16
subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
self._subtitle_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 48,
subtitle_width,
subtitle_height,
))
self._continue_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
def render_progress_screen(self, rect: rl.Rectangle):
self._progress_title_label.set_text(self.progress_text.replace("_", "_\n") + "...")
self._progress_title_label.render(rl.Rectangle(
rect.x + 12,
rect.y + 2,
rect.width,
self._progress_title_label.get_content_height(int(rect.width - 20)),
))
self._progress_percent_label.set_text(f"{self.progress_value}%")
self._progress_percent_label.render(rl.Rectangle(
rect.x + 12,
rect.y + 18,
rect.width,
rect.height,
))
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self._network_setup_page.render(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
elif self.current_screen == Screen.FAILED:
self._update_failed_page.render(rect)
self._update_failed = True
def close(self):
self._network_monitor.stop()
+2 -8
View File
@@ -20,7 +20,6 @@ TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
@@ -54,7 +53,7 @@ class Reset(Widget):
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
def _start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
@@ -92,7 +91,7 @@ class Reset(Widget):
def _confirm(self):
if self._reset_state == ResetState.CONFIRM:
self.start_reset()
self._start_reset()
else:
self._reset_state = ResetState.CONFIRM
@@ -113,15 +112,10 @@ def main():
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset", 20)
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
gui_app.push_widget(reset)
for _ in gui_app.render():
+4 -32
View File
@@ -7,12 +7,10 @@ import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
@@ -35,20 +33,9 @@ BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
@@ -176,7 +163,9 @@ class Setup(Widget):
def _software_selection_continue_button_callback(self):
if self._software_selection_openpilot_button.selected:
self.use_openpilot()
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
@@ -194,7 +183,7 @@ class Setup(Widget):
self.state = SetupState.CUSTOM_SOFTWARE
def render_low_voltage(self, rect: rl.Rectangle):
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
rl.draw_texture_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, rl.WHITE)
self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
@@ -342,23 +331,6 @@ class Setup(Widget):
self.keyboard.set_callback(handle_keyboard_result)
gui_app.push_widget(self.keyboard)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
+38 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import abc
import pyray as rl
from enum import IntEnum
from typing import TypeVar
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
@@ -13,6 +14,10 @@ except ImportError:
awake = True
device = Device()
W = TypeVar('W', bound='Widget')
DEBUG = False
class DialogResult(IntEnum):
CANCEL = 0
@@ -24,11 +29,14 @@ class Widget(abc.ABC):
def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle | None = None
self._children: list[Widget] = []
self._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
# if current mouse/touch down started within the widget's rectangle
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_delay: float | None = None # seconds to hold is_pressed after release
self._click_release_time: float | None = None
@@ -197,12 +205,37 @@ class Widget(abc.ABC):
"""Optionally handle mouse events. This is called before rendering."""
# Default implementation does nothing, can be overridden by subclasses
def _child(self, widget: W) -> W:
"""
Register a widget as a child. Lifecycle events (show/hide) propagate to registered children.
- If the widget is pushed onto the nav stack, do NOT register it (gui_app manages its lifecycle).
- If the widget is rendered inline in _render(), register it.
"""
assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}"
self._children.append(widget)
return widget
_show_hide_depth = 0
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
# TODO: iterate through all child objects, check for subclassing from Widget/Layout (Scroller)
"""Called when widget becomes visible. Propagates to registered children."""
if DEBUG:
print(f"{' ' * Widget._show_hide_depth}show_event: {type(self).__name__}")
Widget._show_hide_depth += 1
for child in self._children:
child.show_event()
if DEBUG:
Widget._show_hide_depth -= 1
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
"""Called when widget is hidden. Propagates to registered children."""
if DEBUG:
print(f"{' ' * Widget._show_hide_depth}hide_event: {type(self).__name__}")
Widget._show_hide_depth += 1
for child in self._children:
child.hide_event()
if DEBUG:
Widget._show_hide_depth -= 1
def dismiss(self, callback: Callable[[], None] | None = None):
"""Immediately dismiss the widget, firing the callback after."""
+4 -83
View File
@@ -5,7 +5,7 @@ import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import Label, UnifiedLabel
from openpilot.system.ui.widgets.label import Label
from openpilot.common.filter_simple import FirstOrderFilter
@@ -191,7 +191,7 @@ class IconButton(Widget):
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color)
class SmallCircleIconButton(Widget):
@@ -219,86 +219,7 @@ class SmallCircleIconButton(Widget):
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
icon_white = white
rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white)
rl.draw_texture_ex(bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, white)
icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2
icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2
rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), icon_white)
class SmallButton(Widget):
def __init__(self, text: str):
super().__init__()
self._click_delay = 0.075
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._load_assets()
self._label = UnifiedLabel(text, 36, font_weight=FontWeight.SEMI_BOLD,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._bg_disabled_txt = None
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
def set_text(self, text: str):
self._label.set_text(text)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
if not self.enabled and self._bg_disabled_txt is not None:
rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
elif self.is_pressed:
rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
else:
rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
opacity = 0.9 if self.enabled else 0.35
self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
self._label.render(self._rect)
class SmallRedPillButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
class SmallerRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 150, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
class WideRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
class WidishRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 250, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
class FullRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white)
+77 -163
View File
@@ -1,3 +1,4 @@
import math
from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
@@ -26,166 +27,6 @@ class ScrollState(IntEnum):
SCROLLING = 1
# TODO: merge anything new here to master
class MiciLabel(Widget):
def __init__(self,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
width: int | None = None,
color: rl.Color = DEFAULT_TEXT_COLOR,
font_weight: FontWeight = FontWeight.NORMAL,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
spacing: int = 0,
line_height: int | None = None,
elide_right: bool = True,
wrap_text: bool = False,
scroll: bool = False):
super().__init__()
self.text = text
self.wrapped_text: list[str] = []
self.font_size = font_size
self.width = width
self.color = color
self.font_weight = font_weight
self.alignment = alignment
self.alignment_vertical = alignment_vertical
self.spacing = spacing
self.line_height = line_height if line_height is not None else font_size
self.elide_right = elide_right
self.wrap_text = wrap_text
self._height = 0
# Scroll state
self.scroll = scroll
self._needs_scroll = False
self._scroll_offset = 0
self._scroll_pause_t: float | None = None
self._scroll_state: ScrollState = ScrollState.STARTING
assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
self.set_text(text)
@property
def text_height(self):
return self._height
def set_font_size(self, font_size: int):
self.font_size = font_size
self.set_text(self.text)
def set_width(self, width: int):
self.width = width
self._rect.width = width
self.set_text(self.text)
def set_text(self, txt: str):
self.text = txt
text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
if self.width is not None:
self._rect.width = self.width
else:
self._rect.width = text_size.x
if self.wrap_text:
self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
self._height = len(self.wrapped_text) * self.line_height
elif self.scroll:
self._needs_scroll = self.scroll and text_size.x > self._rect.width
self._rect.height = text_size.y
def set_color(self, color: rl.Color):
self.color = color
def set_font_weight(self, font_weight: FontWeight):
self.font_weight = font_weight
self.set_text(self.text)
def _render(self, rect: rl.Rectangle):
# Only scissor when we know there is a single scrolling line
if self._needs_scroll:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
font = gui_app.font(self.font_weight)
text_y_offset = 0
# Draw the text in the specified rectangle
lines = self.wrapped_text or [self.text]
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
lines = lines[::-1]
for display_text in lines:
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Elide text to fit within the rectangle
if self.elide_right and text_size.x > rect.width:
ellipsis = "..."
left, right = 0, len(display_text)
while left < right:
mid = (left + right) // 2
candidate = display_text[:mid] + ellipsis
candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Handle scroll state
elif self.scroll and self._needs_scroll:
if self._scroll_state == ScrollState.STARTING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 2.0
if rl.get_time() >= self._scroll_pause_t:
self._scroll_state = ScrollState.SCROLLING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
# don't fully hide
if self._scroll_offset <= -text_size.x - self._rect.width / 3:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
# Calculate horizontal position based on alignment
text_x = rect.x + {
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
}.get(self.alignment, 0) + self._scroll_offset
# Calculate vertical position based on alignment
text_y = rect.y + {
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
}.get(self.alignment_vertical, 0)
text_y += text_y_offset
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
# Draw 2nd instance for scrolling
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
text2_scroll_offset = text_size.x + self._rect.width / 3
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
text_y_offset -= self.line_height
else:
text_y_offset += self.line_height
if self._needs_scroll:
# draw black fade on left and right
fade_width = 20
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
if self._scroll_state != ScrollState.STARTING:
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
rl.end_scissor_mode()
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
@@ -392,7 +233,7 @@ class Label(Widget):
class UnifiedLabel(Widget):
"""
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
Unified label widget that combines functionality from gui_label, gui_text_box, and Label.
Supports:
- Emoji rendering
@@ -401,6 +242,13 @@ class UnifiedLabel(Widget):
- Proper multiline vertical alignment
- Height calculation for layout purposes
"""
# Shimmer constants
SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width
SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width
SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle
SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause)
SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0
def __init__(self,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
@@ -414,7 +262,8 @@ class UnifiedLabel(Widget):
wrap_text: bool = True,
scroll: bool = False,
line_height: float = 1.0,
letter_spacing: float = 0.0):
letter_spacing: float = 0.0,
shimmer: bool = False):
super().__init__()
self._text = text
self._font_size = font_size
@@ -432,6 +281,10 @@ class UnifiedLabel(Widget):
self._letter_spacing = letter_spacing # 0.1 = 10%
self._spacing_pixels = font_size * letter_spacing
# Shimmer state
self._shimmer = shimmer
self._shimmer_start_time = 0.0
# Scroll state
self._scroll = scroll
self._needs_scroll = False
@@ -467,6 +320,14 @@ class UnifiedLabel(Widget):
"""Get the current text content."""
return str(_resolve_value(self._text))
@property
def font_size(self) -> int:
return self._font_size
@property
def text_width(self) -> float:
return max((s.x for s in self._cached_line_sizes), default=0.0)
def set_text_color(self, color: rl.Color):
"""Update the text color."""
self._text_color = color
@@ -517,6 +378,15 @@ class UnifiedLabel(Widget):
self._scroll_pause_t = None
self._scroll_state = ScrollState.STARTING
def show_event(self):
super().show_event()
if self._shimmer:
self.reset_shimmer()
def reset_shimmer(self, offset: float = 0.0):
"""Reset shimmer animation timing."""
self._shimmer_start_time = rl.get_time() + offset
def set_max_width(self, max_width: int | None):
"""Set the maximum width constraint for wrapping/eliding."""
if self._max_width != max_width:
@@ -770,6 +640,24 @@ class UnifiedLabel(Widget):
rl.end_scissor_mode()
def _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float:
"""Compute shimmer opacity multiplier for a character at the given x position."""
sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS
if sigma <= 0:
return self.SHIMMER_LOW_OPACITY
elapsed = rl.get_time() - self._shimmer_start_time
t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD
t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0))
t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep
margin = shimmer_width * self.SHIMMER_BAND_WIDTH
center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin)
d = char_x - center
shimmer = math.exp(-0.5 * d * d / (sigma * sigma))
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
# Calculate horizontal position
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
@@ -782,7 +670,13 @@ class UnifiedLabel(Widget):
line_x = self._rect.x + self._text_padding
line_x += self._scroll_offset + x_offset
# Render line with emojis
if self._shimmer:
self._render_line_shimmer(line, line_x, current_y)
else:
# Render line with emojis
self._render_line_normal(line, emojis, line_x, current_y)
def _render_line_normal(self, line, emojis, line_x, current_y):
line_pos = rl.Vector2(line_x, current_y)
prev_index = 0
@@ -806,3 +700,23 @@ class UnifiedLabel(Widget):
text_after = line[prev_index:]
if text_after:
rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
def _render_line_shimmer(self, line, line_x, current_y):
# Shimmer range based on widest line so sweep is even across all lines
max_width = self.text_width
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
shimmer_left = self._rect.x + (self._rect.width - max_width) / 2
else:
shimmer_left = self._rect.x + self._text_padding
base_a = self._text_color.a / 255.0
cursor_x = line_x
for ch in line:
char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x
char_center_x = cursor_x + char_width / 2.0
alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a)
color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha)
rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color)
cursor_x += char_width + self._spacing_pixels
+4 -5
View File
@@ -21,7 +21,6 @@ class HBoxLayout(Widget):
def __init__(self, widgets: list[Widget] | None = None, spacing: int = 0,
alignment: Alignment = Alignment.LEFT | Alignment.V_CENTER):
super().__init__()
self._widgets: list[Widget] = []
self._spacing = spacing
self._alignment = alignment
@@ -31,13 +30,13 @@ class HBoxLayout(Widget):
@property
def widgets(self) -> list[Widget]:
return self._widgets
return self._children
def add_widget(self, widget: Widget) -> None:
self._widgets.append(widget)
self._child(widget)
def _render(self, _):
visible_widgets = [w for w in self._widgets if w.is_visible]
visible_widgets = [w for w in self._children if w.is_visible]
cur_offset_x = 0
@@ -55,6 +54,6 @@ class HBoxLayout(Widget):
y = self._rect.y + (self._rect.height - widget.rect.height) / 2
# Update widget position and render
widget.set_position(round(x), round(y))
widget.set_position(x, y)
widget.set_parent_rect(self._rect)
widget.render()
+2 -1
View File
@@ -293,6 +293,7 @@ class ListItem(Widget):
self._prev_description: str | None = self.description
def show_event(self):
super().show_event()
self._set_description_visible(False)
def set_description_opened_callback(self, callback: Callable) -> None:
@@ -354,7 +355,7 @@ class ListItem(Widget):
if self.title:
# Draw icon if present
if self.icon:
rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE)
rl.draw_texture_ex(self._icon_texture, rl.Vector2(content_x, self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) / 2), 0.0, 1.0, rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
+1 -1
View File
@@ -95,7 +95,7 @@ class Key(Widget):
self._size_filter.update(size)
def _get_font_size(self) -> int:
return int(round(self._size_filter.x))
return round(self._size_filter.x)
class SmallKey(Key):
+17 -7
View File
@@ -65,9 +65,11 @@ class NavWidget(Widget, abc.ABC):
self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation
self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss
# TODO: add this functionality to push_widget
self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes
# TODO: move this state into NavBar
self._nav_bar = NavBar()
self._nav_bar = self._child(NavBar())
self._nav_bar_show_time = 0.0
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
@@ -79,6 +81,9 @@ class NavWidget(Widget, abc.ABC):
def set_back_callback(self, callback: Callable[[], None]) -> None:
self._back_callback = callback
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
self._shown_callback = callback
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
@@ -143,9 +148,14 @@ class NavWidget(Widget, abc.ABC):
if self._playing_dismiss_animation:
new_y = self._rect.height + DISMISS_PUSH_OFFSET
new_y = round(self._y_pos_filter.update(new_y))
if abs(new_y) < 1 and self._y_pos_filter.velocity.x == 0.0:
new_y = self._y_pos_filter.update(new_y)
if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5:
new_y = self._y_pos_filter.x = 0.0
self._y_pos_filter.velocity.x = 0.0
if self._shown_callback is not None:
self._shown_callback()
self._shown_callback = None
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
gui_app.pop_widget()
@@ -166,10 +176,10 @@ class NavWidget(Widget, abc.ABC):
def _layout(self):
# Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct)
overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0
rl.draw_rectangle(0, 0, int(self._rect.width), int(self._rect.height), rl.Color(0, 0, 0, overlay_alpha))
rl.draw_rectangle_rec(rl.Rectangle(0, 0, self._rect.width, self._rect.height), rl.Color(0, 0, 0, overlay_alpha))
bounce_height = 20
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height + bounce_height), rl.BLACK)
rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK)
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
ret = super().render(rect)
@@ -186,7 +196,7 @@ class NavWidget(Widget, abc.ABC):
else:
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x)
self._nav_bar.render()
return ret
@@ -204,7 +214,6 @@ class NavWidget(Widget, abc.ABC):
def show_event(self):
super().show_event()
self._nav_bar.show_event()
# Reset state
self._drag_start_pos = None
@@ -214,6 +223,7 @@ class NavWidget(Widget, abc.ABC):
# Start NavWidget off-screen, no matter how tall it is
self._y_pos_filter.update_alpha(0.1)
self._y_pos_filter.x = gui_app.height
self._y_pos_filter.velocity.x = 0.0
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
self._nav_bar_show_time = rl.get_time()
+6 -7
View File
@@ -75,17 +75,14 @@ class NetworkUI(Widget):
super().__init__()
self._wifi_manager = wifi_manager
self._current_panel: PanelType = PanelType.WIFI
self._wifi_panel = WifiManagerUI(wifi_manager)
self._advanced_panel = AdvancedNetworkSettings(wifi_manager)
self._nav_button = NavButton(tr("Advanced"))
self._wifi_panel = self._child(WifiManagerUI(wifi_manager))
self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager))
self._nav_button = self._child(NavButton(tr("Advanced")))
self._nav_button.set_click_callback(self._cycle_panel)
def show_event(self):
super().show_event()
self._set_current_panel(PanelType.WIFI)
self._wifi_panel.show_event()
def hide_event(self):
self._wifi_panel.hide_event()
def _cycle_panel(self):
if self._current_panel == PanelType.WIFI:
@@ -305,10 +302,12 @@ class WifiManagerUI(Widget):
disconnected=self._on_disconnected)
def show_event(self):
super().show_event()
# start/stop scanning when widget is visible
self._wifi_manager.set_active(True)
def hide_event(self):
super().hide_event()
self._wifi_manager.set_active(False)
def _load_icons(self):
+4 -12
View File
@@ -47,7 +47,7 @@ class ScrollIndicator(Widget):
# position based on scroll ratio
slide_range = self._viewport.width - indicator_w
max_scroll = self._content_size - self._viewport.width
scroll_ratio = -self._scroll_offset / max_scroll
scroll_ratio = (-self._scroll_offset / abs(max_scroll)) if abs(max_scroll) > 1e-3 else 0.0
x = self._viewport.x + scroll_ratio * slide_range
# don't bounce up when NavWidget shows
y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2
@@ -190,7 +190,7 @@ class _Scroller(Widget):
self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1])
self.scroll_panel.update(self._rect, content_size)
if not self._snap_items:
return round(self.scroll_panel.get_offset())
return self.scroll_panel.get_offset()
# Snap closest item to center
center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
@@ -341,7 +341,7 @@ class _Scroller(Widget):
x, y = self._do_move_animation(item, x, y)
# Update item state
item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_position(x, y)
item.set_parent_rect(self._rect)
def _render_item(self, item: Widget):
@@ -422,18 +422,10 @@ class Scroller(Widget):
"""Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack."""
def __init__(self, **kwargs):
super().__init__()
self._scroller = _Scroller([], **kwargs)
self._scroller = self._child(_Scroller([], **kwargs))
# pass down enabled to child widget for nav stack
self._scroller.set_enabled(lambda: self.enabled)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
def _render(self, _):
self._scroller.render(self._rect)
+44 -31
View File
@@ -1,3 +1,4 @@
import abc
from collections.abc import Callable
import pyray as rl
@@ -5,19 +6,23 @@ import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
class SmallSlider(Widget):
class SliderBase(Widget, abc.ABC):
HORIZONTAL_PADDING = 8
CONFIRM_DELAY = 0.2
PRESSED_SCALE = 1.07
def __init__(self, title: str, confirm_callback: Callable | None = None):
# TODO: unify this with BigConfirmationDialogV2
_bg_txt: rl.Texture
_circle_bg_txt: rl.Texture
_circle_bg_pressed_txt: rl.Texture
_circle_arrow_txt: rl.Texture
def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0):
super().__init__()
self._confirm_callback = confirm_callback
self._font = gui_app.font(FontWeight.DISPLAY)
self._shimmer_offset = shimmer_offset
self._load_assets()
@@ -30,30 +35,34 @@ class SmallSlider(Widget):
self._start_x_circle = 0.0
self._scroll_x_circle = 0.0
self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._circle_scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._circle_press_time: float | None = None
self._is_dragging_circle = False
self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True))
@abc.abstractmethod
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
self._circle_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle_pressed.png", 100, 100)
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
...
@property
def confirmed(self) -> bool:
return self._confirmed_time > 0.0
def show_event(self):
super().show_event()
self.reset()
def reset(self):
# reset all slider state
self._is_dragging_circle = False
self._circle_press_time = None
self._confirmed_time = 0.0
self._confirm_callback_called = False
self._label.reset_shimmer(self._shimmer_offset)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
@@ -84,6 +93,7 @@ class SmallSlider(Widget):
if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
self._start_x_circle = mouse_event.pos.x
self._is_dragging_circle = True
self._circle_press_time = rl.get_time()
elif mouse_event.left_released:
# swiped to left
@@ -101,7 +111,7 @@ class SmallSlider(Widget):
activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
if self._confirmed_time > 0:
if self.confirmed:
# swiped left to confirm
self._scroll_x_circle_filter.update(activated_pos)
@@ -119,8 +129,6 @@ class SmallSlider(Widget):
self._scroll_x_circle_filter.x = self._scroll_x_circle
def _render(self, _):
# TODO: iOS text shimmering animation
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
@@ -130,8 +138,9 @@ class SmallSlider(Widget):
btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
if self._confirmed_time == 0.0 or self._scroll_x_circle > 0:
self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity_filter.x)))
label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x)
if label_alpha > 0:
self._label.set_text_color(rl.Color(255, 255, 255, label_alpha))
label_rect = rl.Rectangle(
self._rect.x + 20,
self._rect.y,
@@ -140,19 +149,23 @@ class SmallSlider(Widget):
)
self._label.render(label_rect)
# circle and arrow
circle_bg_txt = self._circle_bg_pressed_txt if self._is_dragging_circle or self._confirmed_time > 0 else self._circle_bg_txt
rl.draw_texture_ex(circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)
# circle and arrow with grow animation
circle_pressed = self._is_dragging_circle or self.confirmed or (self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075)
circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt
scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0)
scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2
scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2
rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white)
arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
arrow_y = scaled_btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white)
class LargerSlider(SmallSlider):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
class LargerSlider(SliderBase):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0):
self._green = green
super().__init__(title, confirm_callback=confirm_callback)
super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
@@ -164,13 +177,13 @@ class LargerSlider(SmallSlider):
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
class BigSlider(SmallSlider):
class BigSlider(SliderBase):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
self._icon = icon
super().__init__(title, confirm_callback=confirm_callback)
self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.875)
self._label.set_font_size(48)
self._label.set_font_weight(FontWeight.DISPLAY)
self._label.set_line_height(0.875)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))