mirror of
https://github.com/infiniteCable2/openpilot.git
synced 2026-09-30 19:43:41 +08:00
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:
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d815a9140e69242d85e57f74a14c6372809eebefe8958be9152efa7874928ccc
|
||||
size 71215510
|
||||
oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc
|
||||
size 24709205
|
||||
|
||||
@@ -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]"]]
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user