IQ.Pilot Release Commit @ f2a861c
This commit is contained in:
1
iqpilot/selfdrive/ui/.gitignore
vendored
Normal file
1
iqpilot/selfdrive/ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
installer/installers/*
|
||||
71
iqpilot/selfdrive/ui/SConscript
Normal file
71
iqpilot/selfdrive/ui/SConscript
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
Import('env', 'arch', 'common')
|
||||
|
||||
# build the fonts
|
||||
generator = File("#iqpilot/selfdrive/assets/fonts/process.py")
|
||||
source_files = Glob("#iqpilot/selfdrive/assets/fonts/*.ttf") + Glob("#iqpilot/selfdrive/assets/fonts/*.otf")
|
||||
output_files = [
|
||||
(f.abspath.split('.')[0] + ".fnt", f.abspath.split('.')[0] + ".png")
|
||||
for f in source_files
|
||||
if "NotoColor" not in f.name
|
||||
]
|
||||
env.Command(
|
||||
target=output_files,
|
||||
source=[generator, source_files],
|
||||
action=env.PrettyAction(f"python3 {generator}", 'FONTS'),
|
||||
)
|
||||
|
||||
# compile gettext .po -> .mo translations
|
||||
with open(File("translations/languages.json").abspath) as f:
|
||||
languages = json.loads(f.read())
|
||||
|
||||
po_sources = [f"#iqpilot/selfdrive/ui/translations/app_{l}.po" for l in languages.values()]
|
||||
po_sources = [src for src in po_sources if os.path.exists(File(src).abspath)]
|
||||
mo_targets = [src.replace(".po", ".mo") for src in po_sources]
|
||||
mo_build = []
|
||||
for src, tgt in zip(po_sources, mo_targets):
|
||||
mo_build.append(env.Command(tgt, src, env.PrettyAction("msgfmt -o $TARGET $SOURCE", 'MO')))
|
||||
mo_alias = env.Alias('mo', mo_build)
|
||||
env.AlwaysBuild(mo_alias)
|
||||
|
||||
if GetOption('extras'):
|
||||
# build installers
|
||||
if arch != "Darwin":
|
||||
raylib_env = env.Clone()
|
||||
raylib_env['LIBPATH'] += [f'#iqpilot/third_party/raylib/{arch}/']
|
||||
raylib_env['LINKFLAGS'].append('-Wl,-strip-debug')
|
||||
|
||||
raylib_libs = common + ["raylib"]
|
||||
if arch == "larch64":
|
||||
raylib_libs += ["GLESv2", "EGL", "gbm", "drm"]
|
||||
else:
|
||||
raylib_libs += ["GL"]
|
||||
|
||||
release = "release3"
|
||||
installers = [
|
||||
("openpilot", release),
|
||||
("openpilot_test", f"{release}-staging"),
|
||||
("openpilot_nightly", "nightly"),
|
||||
("openpilot_internal", "nightly-dev"),
|
||||
]
|
||||
|
||||
cont = raylib_env.Command("installer/continue_openpilot.o", "installer/continue_openpilot.sh",
|
||||
"ld -r -b binary -o $TARGET $SOURCE")
|
||||
inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf",
|
||||
"ld -r -b binary -o $TARGET $SOURCE")
|
||||
inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf",
|
||||
"ld -r -b binary -o $TARGET $SOURCE")
|
||||
inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf",
|
||||
"ld -r -b binary -o $TARGET $SOURCE")
|
||||
for name, branch in installers:
|
||||
d = {'BRANCH': f"'\"{branch}\"'"}
|
||||
if "internal" in name:
|
||||
d['INTERNAL'] = "1"
|
||||
|
||||
obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d)
|
||||
f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs)
|
||||
# keep installers small
|
||||
assert f[0].get_size() < 1900*1e3, f[0].get_size()
|
||||
1
iqpilot/selfdrive/ui/__init__.py
Normal file
1
iqpilot/selfdrive/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
UI_BORDER_SIZE = 30 #
|
||||
@@ -5,8 +5,8 @@ Sound-suppression policy for Soundd. The driver's "IQAlertSilence" param, when s
|
||||
mutes routine chimes and keeps only a named allow-list of safety-critical cues
|
||||
audible. The param is re-sampled on a fixed poll interval rather than every frame.
|
||||
"""
|
||||
from cereal import car
|
||||
from openpilot.common.params import Params
|
||||
from iqpilot.cereal import car
|
||||
from iqpilot.common.params import Params
|
||||
|
||||
# policy expressed as data: names resolved against the enum at construction so the
|
||||
# allow-list reads as configuration instead of a hardcoded wall of enum accesses
|
||||
|
||||
70
iqpilot/selfdrive/ui/feedback/feedbackd.py
Executable file
70
iqpilot/selfdrive/ui/feedback/feedbackd.py
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.cereal import car
|
||||
from iqpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER
|
||||
|
||||
FEEDBACK_MAX_DURATION = 10.0
|
||||
ButtonType = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def main():
|
||||
params = Params()
|
||||
pm = messaging.PubMaster(['userBookmark', 'audioFeedback'])
|
||||
sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton'])
|
||||
should_record_audio = False
|
||||
block_num = 0
|
||||
waiting_for_release = False
|
||||
early_stop_triggered = False
|
||||
|
||||
while True:
|
||||
sm.update()
|
||||
should_send_bookmark = False
|
||||
|
||||
if False and sm.updated['carState'] and sm['carState'].canValid and not sm['iqState'].aol.available:
|
||||
for be in sm['carState'].buttonEvents:
|
||||
if be.type == ButtonType.lkas:
|
||||
if be.pressed:
|
||||
if not should_record_audio:
|
||||
if params.get_bool("RecordAudioFeedback"):
|
||||
should_record_audio = True
|
||||
block_num = 0
|
||||
waiting_for_release = False
|
||||
early_stop_triggered = False
|
||||
cloudlog.info("LKAS button pressed - starting 10-second audio feedback")
|
||||
else:
|
||||
should_send_bookmark = True
|
||||
cloudlog.info("LKAS button pressed - bookmarking")
|
||||
elif should_record_audio and not waiting_for_release:
|
||||
waiting_for_release = True
|
||||
elif waiting_for_release:
|
||||
waiting_for_release = False
|
||||
early_stop_triggered = True
|
||||
cloudlog.info("LKAS button released - ending recording early")
|
||||
|
||||
if should_record_audio and sm.updated['rawAudioData']:
|
||||
raw_audio = sm['rawAudioData']
|
||||
msg = messaging.new_message('audioFeedback', valid=True)
|
||||
msg.audioFeedback.audio.data = raw_audio.data
|
||||
msg.audioFeedback.audio.sampleRate = raw_audio.sampleRate
|
||||
msg.audioFeedback.blockNum = block_num
|
||||
block_num += 1
|
||||
if (block_num * SAMPLE_BUFFER / SAMPLE_RATE) >= FEEDBACK_MAX_DURATION or early_stop_triggered:
|
||||
should_send_bookmark = True
|
||||
should_record_audio = False
|
||||
early_stop_triggered = False
|
||||
cloudlog.info("10-second recording completed or second button press - stopping audio feedback")
|
||||
pm.send('audioFeedback', msg)
|
||||
|
||||
if sm.updated['bookmarkButton']:
|
||||
cloudlog.info("Bookmark button pressed!")
|
||||
should_send_bookmark = True
|
||||
|
||||
if should_send_bookmark:
|
||||
msg = messaging.new_message('userBookmark', valid=True)
|
||||
pm.send('userBookmark', msg)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
4
iqpilot/selfdrive/ui/installer/continue_openpilot.sh
Executable file
4
iqpilot/selfdrive/ui/installer/continue_openpilot.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd /data/openpilot
|
||||
exec ./launch_openpilot.sh
|
||||
268
iqpilot/selfdrive/ui/installer/installer.cc
Normal file
268
iqpilot/selfdrive/ui/installer/installer.cc
Normal file
@@ -0,0 +1,268 @@
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/util.h"
|
||||
#include "system/hardware/hw.h"
|
||||
#include "third_party/raylib/include/raylib.h"
|
||||
|
||||
int freshClone();
|
||||
int cachedFetch(const std::string &cache);
|
||||
int executeGitCommand(const std::string &cmd);
|
||||
|
||||
std::string get_str(std::string const s) {
|
||||
std::string::size_type pos = s.find('?');
|
||||
assert(pos != std::string::npos);
|
||||
return s.substr(0, pos);
|
||||
}
|
||||
|
||||
// Leave some extra space for the fork installer
|
||||
const std::string GIT_URL = get_str("https://github.com/commaai/openpilot.git" "? ");
|
||||
const std::string BRANCH_STR = get_str(BRANCH "? ");
|
||||
|
||||
#define GIT_SSH_URL "git@github.com:commaai/openpilot.git"
|
||||
#define CONTINUE_PATH "/data/continue.sh"
|
||||
|
||||
const std::string INSTALL_PATH = "/data/openpilot";
|
||||
const std::string VALID_CACHE_PATH = "/data/.openpilot_cache";
|
||||
|
||||
#define TMP_INSTALL_PATH "/data/tmppilot"
|
||||
|
||||
const int FONT_SIZE = 120;
|
||||
|
||||
extern const uint8_t str_continue[] asm("_binary_iqpilot_selfdrive_ui_installer_continue_openpilot_sh_start");
|
||||
extern const uint8_t str_continue_end[] asm("_binary_iqpilot_selfdrive_ui_installer_continue_openpilot_sh_end");
|
||||
extern const uint8_t inter_ttf[] asm("_binary_iqpilot_selfdrive_ui_installer_inter_ascii_ttf_start");
|
||||
extern const uint8_t inter_ttf_end[] asm("_binary_iqpilot_selfdrive_ui_installer_inter_ascii_ttf_end");
|
||||
extern const uint8_t inter_light_ttf[] asm("_binary_iqpilot_selfdrive_assets_fonts_Inter_Light_ttf_start");
|
||||
extern const uint8_t inter_light_ttf_end[] asm("_binary_iqpilot_selfdrive_assets_fonts_Inter_Light_ttf_end");
|
||||
extern const uint8_t inter_bold_ttf[] asm("_binary_iqpilot_selfdrive_assets_fonts_Inter_Bold_ttf_start");
|
||||
extern const uint8_t inter_bold_ttf_end[] asm("_binary_iqpilot_selfdrive_assets_fonts_Inter_Bold_ttf_end");
|
||||
|
||||
Font font_inter;
|
||||
Font font_roman;
|
||||
Font font_display;
|
||||
|
||||
const bool tici_device = Hardware::get_device_type() == cereal::InitData::DeviceType::TICI ||
|
||||
Hardware::get_device_type() == cereal::InitData::DeviceType::TIZI;
|
||||
|
||||
std::vector<std::string> tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"};
|
||||
std::string migrated_branch;
|
||||
|
||||
void branchMigration() {
|
||||
migrated_branch = BRANCH_STR;
|
||||
cereal::InitData::DeviceType device_type = Hardware::get_device_type();
|
||||
if (device_type == cereal::InitData::DeviceType::TICI) {
|
||||
if (std::find(tici_prebuilt_branches.begin(), tici_prebuilt_branches.end(), BRANCH_STR) != tici_prebuilt_branches.end()) {
|
||||
migrated_branch = "release-tici";
|
||||
} else if (BRANCH_STR == "master") {
|
||||
migrated_branch = "master-tici";
|
||||
}
|
||||
} else if (device_type == cereal::InitData::DeviceType::TIZI) {
|
||||
if (BRANCH_STR == "release3") {
|
||||
migrated_branch = "release-tizi";
|
||||
} else if (BRANCH_STR == "release3-staging") {
|
||||
migrated_branch = "release-tizi-staging";
|
||||
}
|
||||
} else if (device_type == cereal::InitData::DeviceType::MICI) {
|
||||
if (BRANCH_STR == "release3") {
|
||||
migrated_branch = "release-mici";
|
||||
} else if (BRANCH_STR == "release3-staging") {
|
||||
migrated_branch = "release-mici-staging";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run(const char* cmd) {
|
||||
int err = std::system(cmd);
|
||||
assert(err == 0);
|
||||
}
|
||||
|
||||
void finishInstall() {
|
||||
BeginDrawing();
|
||||
ClearBackground(BLACK);
|
||||
if (tici_device) {
|
||||
const char *m = "Finishing install...";
|
||||
int text_width = MeasureText(m, FONT_SIZE);
|
||||
DrawTextEx(font_display, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE);
|
||||
} else {
|
||||
DrawTextEx(font_display, "finishing setup", (Vector2){8, 10}, 82, 0, WHITE);
|
||||
}
|
||||
EndDrawing();
|
||||
util::sleep_for(60 * 1000);
|
||||
}
|
||||
|
||||
void renderProgress(int progress) {
|
||||
BeginDrawing();
|
||||
ClearBackground(BLACK);
|
||||
if (tici_device) {
|
||||
DrawTextEx(font_inter, "Installing...", (Vector2){150, 290}, 110, 0, WHITE);
|
||||
Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72};
|
||||
DrawRectangleRec(bar, (Color){41, 41, 41, 255});
|
||||
progress = std::clamp(progress, 0, 100);
|
||||
bar.width *= progress / 100.0f;
|
||||
DrawRectangleRec(bar, (Color){70, 91, 234, 255});
|
||||
DrawTextEx(font_inter, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE);
|
||||
} else {
|
||||
DrawTextEx(font_display, "installing", (Vector2){8, 10}, 82, 0, WHITE);
|
||||
const std::string percent_str = std::to_string(progress) + "%";
|
||||
DrawTextEx(font_roman, percent_str.c_str(), (Vector2){6, (float)(GetScreenHeight() - 128 + 18)}, 128, 0,
|
||||
(Color){255, 255, 255, (unsigned char)(255 * 0.9 * 0.35)});
|
||||
}
|
||||
|
||||
EndDrawing();
|
||||
}
|
||||
|
||||
int doInstall() {
|
||||
// wait for valid time
|
||||
while (!util::system_time_valid()) {
|
||||
util::sleep_for(500);
|
||||
LOGD("Waiting for valid time");
|
||||
}
|
||||
|
||||
// cleanup previous install attempts
|
||||
run("rm -rf " TMP_INSTALL_PATH);
|
||||
|
||||
// do the install
|
||||
if (util::file_exists(INSTALL_PATH) && util::file_exists(VALID_CACHE_PATH)) {
|
||||
return cachedFetch(INSTALL_PATH);
|
||||
} else {
|
||||
return freshClone();
|
||||
}
|
||||
}
|
||||
|
||||
int freshClone() {
|
||||
LOGD("Doing fresh clone");
|
||||
std::string cmd = util::string_format("git clone --progress %s -b %s --depth=1 --recurse-submodules %s 2>&1",
|
||||
GIT_URL.c_str(), migrated_branch.c_str(), TMP_INSTALL_PATH);
|
||||
return executeGitCommand(cmd);
|
||||
}
|
||||
|
||||
int cachedFetch(const std::string &cache) {
|
||||
LOGD("Fetching with cache: %s", cache.c_str());
|
||||
|
||||
run(util::string_format("cp -rp %s %s", cache.c_str(), TMP_INSTALL_PATH).c_str());
|
||||
run(util::string_format("cd %s && git remote set-branches --add origin %s", TMP_INSTALL_PATH, migrated_branch.c_str()).c_str());
|
||||
|
||||
renderProgress(10);
|
||||
|
||||
return executeGitCommand(util::string_format("cd %s && git fetch --progress origin %s 2>&1", TMP_INSTALL_PATH, migrated_branch.c_str()));
|
||||
}
|
||||
|
||||
int executeGitCommand(const std::string &cmd) {
|
||||
static const std::array stages = {
|
||||
// prefix, weight in percentage
|
||||
std::pair{"Receiving objects: ", 91},
|
||||
std::pair{"Resolving deltas: ", 2},
|
||||
std::pair{"Updating files: ", 7},
|
||||
};
|
||||
|
||||
FILE *pipe = popen(cmd.c_str(), "r");
|
||||
if (!pipe) return -1;
|
||||
|
||||
char buffer[512];
|
||||
while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
|
||||
std::string line(buffer);
|
||||
int base = 0;
|
||||
for (const auto &[text, weight] : stages) {
|
||||
if (line.find(text) != std::string::npos) {
|
||||
size_t percentPos = line.find("%");
|
||||
if (percentPos != std::string::npos && percentPos >= 3) {
|
||||
int percent = std::stoi(line.substr(percentPos - 3, 3));
|
||||
int progress = base + int(percent / 100. * weight);
|
||||
renderProgress(progress);
|
||||
}
|
||||
break;
|
||||
}
|
||||
base += weight;
|
||||
}
|
||||
}
|
||||
return pclose(pipe);
|
||||
}
|
||||
|
||||
void cloneFinished(int exitCode) {
|
||||
LOGD("git finished with %d", exitCode);
|
||||
assert(exitCode == 0);
|
||||
|
||||
renderProgress(100);
|
||||
|
||||
// ensure correct branch is checked out
|
||||
int err = chdir(TMP_INSTALL_PATH);
|
||||
assert(err == 0);
|
||||
run(("git checkout " + migrated_branch).c_str());
|
||||
run(("git reset --hard origin/" + migrated_branch).c_str());
|
||||
|
||||
// move into place
|
||||
run(("rm -f " + VALID_CACHE_PATH).c_str());
|
||||
run(("rm -rf " + INSTALL_PATH).c_str());
|
||||
run(util::string_format("mv %s %s", TMP_INSTALL_PATH, INSTALL_PATH.c_str()).c_str());
|
||||
|
||||
#ifdef INTERNAL
|
||||
run("mkdir -p /data/params/d/");
|
||||
|
||||
// https://github.com/commaci2.keys
|
||||
const std::string ssh_keys = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMX2kU8eBZyEWmbq0tjMPxksWWVuIV/5l64GabcYbdpI";
|
||||
std::map<std::string, std::string> params = {
|
||||
{"SshEnabled", "1"},
|
||||
{"RecordFrontLock", "1"},
|
||||
{"GithubSshKeys", ssh_keys},
|
||||
};
|
||||
for (const auto& [key, value] : params) {
|
||||
std::ofstream param;
|
||||
param.open("/data/params/d/" + key);
|
||||
param << value;
|
||||
param.close();
|
||||
}
|
||||
run(("cd " + INSTALL_PATH + " && "
|
||||
"git remote set-url origin --push " GIT_SSH_URL " && "
|
||||
"git config --replace-all remote.origin.fetch \"+refs/heads/*:refs/remotes/origin/*\"").c_str());
|
||||
#endif
|
||||
|
||||
// write continue.sh
|
||||
FILE *of = fopen("/data/continue.sh.new", "wb");
|
||||
assert(of != NULL);
|
||||
|
||||
size_t num = str_continue_end - str_continue;
|
||||
size_t num_written = fwrite(str_continue, 1, num, of);
|
||||
assert(num == num_written);
|
||||
fclose(of);
|
||||
|
||||
run("chmod +x /data/continue.sh.new");
|
||||
run("mv /data/continue.sh.new " CONTINUE_PATH);
|
||||
|
||||
// wait for the installed software's UI to take over
|
||||
finishInstall();
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (tici_device) {
|
||||
InitWindow(2160, 1080, "Installer");
|
||||
} else {
|
||||
InitWindow(536, 240, "Installer");
|
||||
}
|
||||
|
||||
font_inter = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0);
|
||||
font_roman = LoadFontFromMemory(".ttf", inter_light_ttf, inter_light_ttf_end - inter_light_ttf, FONT_SIZE, NULL, 0);
|
||||
font_display = LoadFontFromMemory(".ttf", inter_bold_ttf, inter_bold_ttf_end - inter_bold_ttf, FONT_SIZE, NULL, 0);
|
||||
SetTextureFilter(font_inter.texture, TEXTURE_FILTER_BILINEAR);
|
||||
SetTextureFilter(font_roman.texture, TEXTURE_FILTER_BILINEAR);
|
||||
SetTextureFilter(font_display.texture, TEXTURE_FILTER_BILINEAR);
|
||||
|
||||
branchMigration();
|
||||
|
||||
if (util::file_exists(CONTINUE_PATH)) {
|
||||
finishInstall();
|
||||
} else {
|
||||
renderProgress(0);
|
||||
int result = doInstall();
|
||||
cloneFinished(result);
|
||||
}
|
||||
|
||||
CloseWindow();
|
||||
UnloadFont(font_inter);
|
||||
UnloadFont(font_roman);
|
||||
UnloadFont(font_display);
|
||||
return 0;
|
||||
}
|
||||
BIN
iqpilot/selfdrive/ui/installer/inter-ascii.ttf
Normal file
BIN
iqpilot/selfdrive/ui/installer/inter-ascii.ttf
Normal file
Binary file not shown.
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal file
@@ -0,0 +1,957 @@
|
||||
import time
|
||||
import os
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from enum import IntEnum
|
||||
from iqpilot.cereal import log
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
|
||||
from iqpilot.selfdrive.ui.widgets.setup import SetupWidget
|
||||
from iqpilot.selfdrive.ui.widgets.inspire_widget import InspireWidget
|
||||
from iqpilot.selfdrive.ui.widgets.map_panel_widget import MapPanelWidget
|
||||
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
|
||||
from iqpilot.selfdrive.ui.layouts.sidebar import NETWORK_TYPES
|
||||
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr, trn
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.widgets.label import gui_label, UnifiedLabel
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
|
||||
STATUS_BAR_HEIGHT = 120
|
||||
HEAD_BUTTON_FONT_SIZE = 40
|
||||
CONTENT_MARGIN = 40
|
||||
SPACING = 25
|
||||
TILE_GAP = 24
|
||||
STATS_PANEL_VERTICAL_INSET = TILE_GAP
|
||||
REFRESH_INTERVAL = 10.0
|
||||
CHANGELOG_REFRESH_INTERVAL = 15.0
|
||||
HOLD_THRESHOLD = 0.6 # seconds to trigger the panel picker
|
||||
|
||||
PANEL_KEY = "HomePanelWidget"
|
||||
PANEL_CHANGELOG = "changelog"
|
||||
PANEL_STATS = "stats"
|
||||
PANEL_MAP = "map"
|
||||
PANEL_INSPIRE = "inspire"
|
||||
|
||||
PICKER_BG = rl.Color(20, 21, 26, 230)
|
||||
PICKER_CARD = rl.Color(34, 36, 44, 255)
|
||||
PICKER_CARD_HOVER = rl.Color(48, 51, 60, 255)
|
||||
PICKER_BORDER = rl.Color(255, 255, 255, 30)
|
||||
PICKER_TEAL = rl.Color(16, 185, 169, 255)
|
||||
PICKER_SEL_BORDER = rl.Color(16, 185, 169, 200)
|
||||
|
||||
ThermalStatus = log.DeviceState.ThermalStatus
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
# Status-light severity colors
|
||||
STATUS_GOOD = rl.Color(16, 185, 169, 255) # teal
|
||||
STATUS_WARN = rl.Color(245, 166, 35, 255) # orange
|
||||
STATUS_DANGER = rl.Color(226, 72, 58, 255) # red
|
||||
|
||||
|
||||
class ChangelogWidget(Widget):
|
||||
PANEL_BG_COLOR = rl.Color(34, 36, 42, 255)
|
||||
PANEL_BORDER = rl.Color(255, 255, 255, 26)
|
||||
BODY_COLOR = rl.Color(235, 235, 235, 255)
|
||||
HEADING_COLOR = rl.Color(255, 255, 255, 255)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._show_all = False
|
||||
self._latest_text = ""
|
||||
self._all_text = ""
|
||||
self._render_latest: list[dict] = []
|
||||
self._render_all: list[dict] = []
|
||||
self._wrap_width = 0
|
||||
self._last_load = 0.0
|
||||
self._scroll_px = 0.0
|
||||
self._max_scroll = 0.0
|
||||
self._is_dragging = False
|
||||
self._drag_last_y = 0.0
|
||||
self._text_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._btn_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._latest_btn = Button("Latest", self._show_latest, button_style=ButtonStyle.PRIMARY, font_size=28)
|
||||
self._all_btn = Button("All", self._show_all_logs, button_style=ButtonStyle.NORMAL, font_size=28)
|
||||
self._load_changelog(force=True)
|
||||
|
||||
def show_event(self):
|
||||
self._load_changelog(force=True)
|
||||
|
||||
def _show_latest(self) -> None:
|
||||
self._show_all = False
|
||||
self._scroll_px = 0.0
|
||||
self._is_dragging = False
|
||||
|
||||
def _show_all_logs(self) -> None:
|
||||
self._show_all = True
|
||||
self._scroll_px = 0.0
|
||||
self._is_dragging = False
|
||||
|
||||
def _load_changelog(self, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_load) < CHANGELOG_REFRESH_INTERVAL:
|
||||
return
|
||||
self._last_load = now
|
||||
|
||||
paths = [os.path.join(BASEDIR, "iqpilot", "docs", "CHANGELOG.md")]
|
||||
content = ""
|
||||
for p in paths:
|
||||
try:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not content:
|
||||
content = "No changelog found.\n\nAdd iqpilot/docs/CHANGELOG.md."
|
||||
|
||||
ordered = self._reorder_sections_newest_first(content)
|
||||
self._latest_text = self._build_latest_text(ordered)
|
||||
self._all_text = ordered
|
||||
self._render_latest = []
|
||||
self._render_all = []
|
||||
self._wrap_width = 0
|
||||
|
||||
def _reorder_sections_newest_first(self, content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
intro: list[str] = []
|
||||
sections: list[list[str]] = []
|
||||
current: list[str] | None = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("## "):
|
||||
if current is not None:
|
||||
sections.append(current)
|
||||
current = [line]
|
||||
else:
|
||||
if current is None:
|
||||
intro.append(line)
|
||||
else:
|
||||
current.append(line)
|
||||
|
||||
if current is not None:
|
||||
sections.append(current)
|
||||
|
||||
out: list[str] = []
|
||||
if intro:
|
||||
out.extend(intro)
|
||||
out.append("")
|
||||
|
||||
for i, section in enumerate(reversed(sections)):
|
||||
out.extend(section)
|
||||
if i != len(sections) - 1:
|
||||
out.append("")
|
||||
|
||||
return "\n".join(out).strip()
|
||||
|
||||
def _build_latest_text(self, content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
if not lines:
|
||||
return "No updates available."
|
||||
|
||||
out: list[str] = []
|
||||
section_count = 0
|
||||
for line in lines:
|
||||
if line.startswith("## "):
|
||||
section_count += 1
|
||||
if section_count > 2:
|
||||
break
|
||||
out.append(line)
|
||||
return "\n".join(out).strip() or content
|
||||
|
||||
@staticmethod
|
||||
def _clean_inline_markdown(text: str) -> str:
|
||||
return text.replace("**", "").replace("`", "").strip()
|
||||
|
||||
def _build_render_lines(self, text: str, width: int) -> list[dict]:
|
||||
lines: list[dict] = []
|
||||
for raw in text.splitlines():
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
lines.append({"text": "", "font_size": 16, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": 18})
|
||||
continue
|
||||
|
||||
font_size = 34
|
||||
font_weight = FontWeight.NORMAL
|
||||
indent = 0
|
||||
color = self.BODY_COLOR
|
||||
text_line = stripped
|
||||
extra_spacing = 0
|
||||
|
||||
if stripped.startswith("### "):
|
||||
text_line = self._clean_inline_markdown(stripped[4:])
|
||||
font_size = 34
|
||||
font_weight = FontWeight.BOLD
|
||||
color = self.HEADING_COLOR
|
||||
extra_spacing = 8
|
||||
elif stripped.startswith("## "):
|
||||
text_line = self._clean_inline_markdown(stripped[3:])
|
||||
font_size = 38
|
||||
font_weight = FontWeight.BOLD
|
||||
color = self.HEADING_COLOR
|
||||
extra_spacing = 10
|
||||
elif stripped.startswith("# "):
|
||||
text_line = self._clean_inline_markdown(stripped[2:])
|
||||
font_size = 42
|
||||
font_weight = FontWeight.BOLD
|
||||
color = self.HEADING_COLOR
|
||||
extra_spacing = 12
|
||||
elif stripped.startswith(("- ", "* ")):
|
||||
text_line = "• " + self._clean_inline_markdown(stripped[2:])
|
||||
font_size = 34
|
||||
indent = 8
|
||||
else:
|
||||
text_line = self._clean_inline_markdown(stripped)
|
||||
|
||||
font = gui_app.font(font_weight)
|
||||
wrapped = wrap_text(font, text_line, font_size, max(50, width - indent))
|
||||
if not wrapped:
|
||||
wrapped = [text_line]
|
||||
|
||||
for i, w in enumerate(wrapped):
|
||||
line_indent = indent if i == 0 else indent + 18
|
||||
line_h = int(font_size * 1.15)
|
||||
lines.append({
|
||||
"text": w,
|
||||
"font_size": font_size,
|
||||
"font_weight": font_weight,
|
||||
"indent": line_indent,
|
||||
"color": color,
|
||||
"height": line_h,
|
||||
})
|
||||
|
||||
if extra_spacing > 0:
|
||||
lines.append({"text": "", "font_size": extra_spacing, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": extra_spacing})
|
||||
|
||||
return lines
|
||||
|
||||
def _ensure_wrapped(self, text_w: int):
|
||||
if text_w <= 0:
|
||||
return
|
||||
if self._wrap_width == text_w and self._render_latest and self._render_all:
|
||||
return
|
||||
self._wrap_width = text_w
|
||||
self._render_latest = self._build_render_lines(self._latest_text, text_w)
|
||||
self._render_all = self._build_render_lines(self._all_text, text_w)
|
||||
|
||||
@staticmethod
|
||||
def _total_height(lines: list[dict]) -> int:
|
||||
return sum(line["height"] for line in lines)
|
||||
|
||||
def _clamp_scroll(self):
|
||||
self._scroll_px = max(0.0, min(self._max_scroll, self._scroll_px))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
if rl.check_collision_point_rec(mouse_pos, self._text_rect):
|
||||
self._is_dragging = True
|
||||
self._drag_last_y = mouse_pos.y
|
||||
|
||||
def _handle_mouse_event(self, mouse_event):
|
||||
if not self._is_dragging or not mouse_event.left_down:
|
||||
return
|
||||
dy = mouse_event.pos.y - self._drag_last_y
|
||||
self._drag_last_y = mouse_event.pos.y
|
||||
self._scroll_px -= dy
|
||||
self._clamp_scroll()
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
self._is_dragging = False
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._load_changelog()
|
||||
rl.draw_rectangle_rounded(rect, 0.06, 24, self.PANEL_BG_COLOR)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.06, 24, 2, self.PANEL_BORDER)
|
||||
|
||||
title_rect = rl.Rectangle(rect.x + 36, rect.y + 24, rect.width - 72, 50)
|
||||
gui_label(title_rect, "Latest Updates", 44, rl.WHITE, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
|
||||
btn_w = 150
|
||||
btn_h = 54
|
||||
btn_gap = 12
|
||||
self._btn_rect = rl.Rectangle(rect.x + rect.width - (btn_w * 2) - btn_gap - 36, rect.y + 84, (btn_w * 2) + btn_gap, btn_h)
|
||||
latest_rect = rl.Rectangle(self._btn_rect.x, self._btn_rect.y, btn_w, btn_h)
|
||||
all_rect = rl.Rectangle(self._btn_rect.x + btn_w + btn_gap, self._btn_rect.y, btn_w, btn_h)
|
||||
|
||||
self._latest_btn.set_button_style(ButtonStyle.PRIMARY if not self._show_all else ButtonStyle.NORMAL)
|
||||
self._all_btn.set_button_style(ButtonStyle.PRIMARY if self._show_all else ButtonStyle.NORMAL)
|
||||
self._latest_btn.render(latest_rect)
|
||||
self._all_btn.render(all_rect)
|
||||
|
||||
self._text_rect = rl.Rectangle(rect.x + 36, rect.y + 154, rect.width - 72, rect.height - 182)
|
||||
self._ensure_wrapped(int(self._text_rect.width))
|
||||
|
||||
lines = self._render_all if self._show_all else self._render_latest
|
||||
total_h = self._total_height(lines)
|
||||
self._max_scroll = max(0.0, total_h - self._text_rect.height)
|
||||
self._clamp_scroll()
|
||||
|
||||
# Clip content region and draw formatted lines
|
||||
rl.begin_scissor_mode(int(self._text_rect.x), int(self._text_rect.y), int(self._text_rect.width), int(self._text_rect.height))
|
||||
y = self._text_rect.y - self._scroll_px
|
||||
for line in lines:
|
||||
line_h = line["height"]
|
||||
if line["text"] and (y + line_h) >= self._text_rect.y and y <= (self._text_rect.y + self._text_rect.height):
|
||||
font = gui_app.font(line["font_weight"])
|
||||
x = self._text_rect.x + line["indent"]
|
||||
rl.draw_text_ex(font, line["text"], rl.Vector2(x, y), line["font_size"], 0, line["color"])
|
||||
y += line_h
|
||||
rl.end_scissor_mode()
|
||||
|
||||
if self._max_scroll > 0.0:
|
||||
hint = "Drag to scroll"
|
||||
hint_size = measure_text_cached(gui_app.font(FontWeight.NORMAL), hint, 24)
|
||||
hint_x = self._text_rect.x + self._text_rect.width - hint_size.x
|
||||
hint_y = rect.y + rect.height - 12 - hint_size.y
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), hint, rl.Vector2(hint_x, hint_y), 24, 0, rl.Color(180, 180, 180, 220))
|
||||
|
||||
|
||||
def _format_updater_description(description: str | None) -> str:
|
||||
brand = "IQ.Pilot"
|
||||
if not description:
|
||||
return brand
|
||||
|
||||
cleaned = description.strip()
|
||||
lower = cleaned.lower()
|
||||
if lower.startswith("iqpilot"):
|
||||
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
|
||||
|
||||
if cleaned.lower().startswith(brand.lower()):
|
||||
return cleaned
|
||||
return f"{brand} {cleaned}" if cleaned else brand
|
||||
|
||||
|
||||
class LauncherTile(Widget):
|
||||
"""A large offroad launcher tile: teal-gradient icon over a dark rounded card, label beneath."""
|
||||
|
||||
BG = rl.Color(34, 36, 42, 255)
|
||||
BG_PRESSED = rl.Color(50, 53, 61, 255)
|
||||
BORDER = rl.Color(255, 255, 255, 26)
|
||||
|
||||
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None] | None = None):
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._icon_path = icon_path
|
||||
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
|
||||
if on_click is not None:
|
||||
self.set_click_callback(on_click)
|
||||
|
||||
def set_label(self, label: str | Callable[[], str]) -> None:
|
||||
self._label = label
|
||||
|
||||
def set_icon_path(self, icon_path: str) -> None:
|
||||
if icon_path != self._icon_path:
|
||||
self._icon_path = icon_path
|
||||
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
pressed = self.is_pressed
|
||||
rl.draw_rectangle_rounded(rect, 0.16, 24, self.BG_PRESSED if pressed else self.BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.16, 24, 2, self.BORDER)
|
||||
|
||||
# Icon centered in the upper portion of the tile
|
||||
icon_size = min(rect.width, rect.height) * 0.44
|
||||
cx = rect.x + rect.width / 2
|
||||
cy = rect.y + rect.height * 0.40
|
||||
icon_dst = rl.Rectangle(cx - icon_size / 2, cy - icon_size / 2, icon_size, icon_size)
|
||||
src = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
|
||||
rl.draw_texture_pro(self._icon, src, icon_dst, rl.Vector2(0, 0), 0, rl.WHITE)
|
||||
|
||||
# Label, centered beneath the icon
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
label_size = 48
|
||||
label = self._label() if callable(self._label) else self._label
|
||||
ts = measure_text_cached(font, label, label_size)
|
||||
label_x = rect.x + (rect.width - ts.x) / 2
|
||||
label_y = rect.y + rect.height * 0.75
|
||||
rl.draw_text_ex(font, label, rl.Vector2(int(label_x), int(label_y)), label_size, 0, rl.WHITE)
|
||||
|
||||
|
||||
class HomeLayoutState(IntEnum):
|
||||
HOME = 0
|
||||
UPDATE = 1
|
||||
ALERTS = 2
|
||||
|
||||
|
||||
class HomeLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.params = Params()
|
||||
|
||||
self.update_alert = UpdateAlert()
|
||||
self.offroad_alert = OffroadAlert()
|
||||
|
||||
self._layout_widgets = {HomeLayoutState.UPDATE: self.update_alert, HomeLayoutState.ALERTS: self.offroad_alert}
|
||||
|
||||
self.current_state = HomeLayoutState.HOME
|
||||
self.last_refresh = 0
|
||||
self.settings_callback: Callable[[], None] | None = None
|
||||
self.stats_callback: Callable[[], None] | None = None
|
||||
self.nav_callback: Callable[[], None] | None = None
|
||||
self.routes_callback: Callable[[], None] | None = None
|
||||
|
||||
self.update_available = False
|
||||
self.alert_count = 0
|
||||
self._version_text = ""
|
||||
self._version_commit_text = ""
|
||||
self._status_version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
text_color=rl.Color(185, 185, 190, 255),
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
wrap_text=False, scroll=True)
|
||||
self._prev_update_available = False
|
||||
self._prev_alerts_present = False
|
||||
|
||||
# Status-bar state
|
||||
self._status_color = STATUS_GOOD
|
||||
self._status_word = tr("READY")
|
||||
self._expanded = False
|
||||
self._expanded_metrics: list[tuple[str, str, rl.Color]] = []
|
||||
self._net_type = NETWORK_TYPES.get(NetworkType.none)
|
||||
self._on_wifi = False
|
||||
self._battery_pct: int | None = None
|
||||
self._battery_charging = False
|
||||
|
||||
self.status_bar_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self.content_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self.left_column_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self.right_column_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._tile_rects: list[rl.Rectangle] = []
|
||||
|
||||
self.update_notif_rect = rl.Rectangle(0, 0, 200, 60)
|
||||
self.alert_notif_rect = rl.Rectangle(0, 0, 220, 60)
|
||||
|
||||
self._setup_widget = SetupWidget()
|
||||
self._changelog_widget = ChangelogWidget()
|
||||
self._inspire_widget = InspireWidget()
|
||||
self._map_panel_widget = MapPanelWidget()
|
||||
self._stats_panel_widget = TripsLayout()
|
||||
|
||||
# Right-panel widget selection
|
||||
saved = self.params.get(PANEL_KEY) or ""
|
||||
self._panel_widget = saved if saved in (PANEL_CHANGELOG, PANEL_STATS, PANEL_MAP, PANEL_INSPIRE) else PANEL_CHANGELOG
|
||||
|
||||
# Hold-to-pick
|
||||
self._press_start: float | None = None
|
||||
self._press_origin: tuple[float, float] = (0.0, 0.0)
|
||||
self._press_scrolled = False # True once the current press moved — don't restart timer
|
||||
self._show_picker = False
|
||||
self._picker_hover: str | None = None
|
||||
self._picker_ignore_release = False # swallow the release that opened the picker
|
||||
|
||||
# Status-bar icons (white, tinted at draw time)
|
||||
self._icon_wifi = gui_app.texture("icons/iq/wifi.png", 64, 64, keep_aspect_ratio=True)
|
||||
self._icon_battery = gui_app.texture("icons/iq/battery.png", 72, 72, keep_aspect_ratio=True)
|
||||
|
||||
_net_base = "icons_mici/settings/network/"
|
||||
self._cell_strength_icons = [
|
||||
gui_app.texture(f"{_net_base}cell_strength_none.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_medium.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_high.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_full.png", 64, 64, keep_aspect_ratio=True),
|
||||
]
|
||||
# wifi has no "high" variant: none/low/medium/full only
|
||||
self._wifi_strength_icons = [
|
||||
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
|
||||
]
|
||||
self._net_strength = 0
|
||||
|
||||
# Launcher tiles
|
||||
self._tiles = [
|
||||
LauncherTile("icons/iq/tile_settings.png", lambda: tr("Settings"), self._on_settings_tile),
|
||||
LauncherTile("icons/iq/tile_stats.png", lambda: tr("Stats"), self._on_stats_tile),
|
||||
LauncherTile("icons/iq/tile_nav.png", lambda: tr("Navigation"), self._on_nav_tile),
|
||||
LauncherTile("icons/iq/tile_routes.png", lambda: tr("Routes"), self._on_routes_tile),
|
||||
]
|
||||
|
||||
self._setup_callbacks()
|
||||
|
||||
def show_event(self):
|
||||
self._changelog_widget.show_event()
|
||||
self.last_refresh = time.monotonic()
|
||||
self._refresh()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
|
||||
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
|
||||
|
||||
def set_settings_callback(self, callback: Callable):
|
||||
self.settings_callback = callback
|
||||
|
||||
def set_stats_callback(self, callback: Callable):
|
||||
self.stats_callback = callback
|
||||
|
||||
def set_nav_callback(self, callback: Callable):
|
||||
self.nav_callback = callback
|
||||
|
||||
def set_routes_callback(self, callback: Callable):
|
||||
self.routes_callback = callback
|
||||
|
||||
# Tile actions
|
||||
def _on_settings_tile(self):
|
||||
if self.settings_callback:
|
||||
self.settings_callback()
|
||||
|
||||
def _on_stats_tile(self):
|
||||
if self.stats_callback:
|
||||
self.stats_callback()
|
||||
|
||||
def _on_nav_tile(self):
|
||||
if self.nav_callback:
|
||||
self.nav_callback()
|
||||
|
||||
def _on_routes_tile(self):
|
||||
if self.routes_callback:
|
||||
self.routes_callback()
|
||||
|
||||
def _set_state(self, state: HomeLayoutState):
|
||||
# propagate show/hide events
|
||||
if state != self.current_state:
|
||||
if state in self._layout_widgets:
|
||||
self._layout_widgets[state].show_event()
|
||||
if self.current_state in self._layout_widgets:
|
||||
self._layout_widgets[self.current_state].hide_event()
|
||||
|
||||
self.current_state = state
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
current_time = time.monotonic()
|
||||
if current_time - self.last_refresh >= REFRESH_INTERVAL:
|
||||
self._refresh()
|
||||
self.last_refresh = current_time
|
||||
|
||||
self._render_status_bar()
|
||||
|
||||
if self.current_state == HomeLayoutState.HOME:
|
||||
self._render_home_content()
|
||||
elif self.current_state == HomeLayoutState.UPDATE:
|
||||
self.update_alert.render(self.content_rect)
|
||||
elif self.current_state == HomeLayoutState.ALERTS:
|
||||
self.offroad_alert.render(self.content_rect)
|
||||
|
||||
def _update_state(self):
|
||||
self.status_bar_rect = rl.Rectangle(
|
||||
self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN,
|
||||
self._rect.width - 2 * CONTENT_MARGIN, STATUS_BAR_HEIGHT
|
||||
)
|
||||
|
||||
content_y = self._rect.y + CONTENT_MARGIN + STATUS_BAR_HEIGHT + SPACING
|
||||
content_height = self._rect.height - CONTENT_MARGIN - STATUS_BAR_HEIGHT - SPACING - CONTENT_MARGIN
|
||||
self.content_rect = rl.Rectangle(
|
||||
self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height
|
||||
)
|
||||
|
||||
right_width = min(820, self.content_rect.width * 0.46)
|
||||
left_width = self.content_rect.width - right_width - SPACING
|
||||
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
|
||||
self.right_column_rect = rl.Rectangle(
|
||||
self.content_rect.x + left_width + SPACING, self.content_rect.y, right_width, self.content_rect.height
|
||||
)
|
||||
|
||||
# 2x2 tile grid
|
||||
tile_w = (self.left_column_rect.width - TILE_GAP) / 2
|
||||
tile_h = (self.left_column_rect.height - TILE_GAP) / 2
|
||||
self._tile_rects = []
|
||||
for i in range(4):
|
||||
col = i % 2
|
||||
row = i // 2
|
||||
tx = self.left_column_rect.x + col * (tile_w + TILE_GAP)
|
||||
ty = self.left_column_rect.y + row * (tile_h + TILE_GAP)
|
||||
self._tile_rects.append(rl.Rectangle(tx, ty, tile_w, tile_h))
|
||||
|
||||
self._update_status_info()
|
||||
self._update_hold()
|
||||
|
||||
def _clear_panel_hold(self):
|
||||
self._press_start = None
|
||||
self._press_scrolled = False
|
||||
|
||||
def _update_hold(self):
|
||||
if not ui_state.prime_state.is_paired() or self._show_picker:
|
||||
self._clear_panel_hold()
|
||||
return
|
||||
if self._press_start is None or self._press_scrolled:
|
||||
return
|
||||
if time.monotonic() - self._press_start >= HOLD_THRESHOLD:
|
||||
self._show_picker = True
|
||||
self._press_start = None
|
||||
self._picker_ignore_release = True
|
||||
|
||||
def _update_status_info(self):
|
||||
# Read the expanded-status toggle every frame (not gated by deviceState updates)
|
||||
self._expanded = self.params.get_bool("IQExpandedStatus")
|
||||
|
||||
sm = ui_state.sm
|
||||
# Network + battery — updated from cached state every frame so values are never stale
|
||||
_ds_cached = sm['deviceState']
|
||||
self._net_type = NETWORK_TYPES.get(_ds_cached.networkType.raw, self._net_type)
|
||||
self._on_wifi = _ds_cached.networkType.raw in (NetworkType.wifi, NetworkType.ethernet)
|
||||
_strength = _ds_cached.networkStrength
|
||||
self._net_strength = max(0, min(5, _strength.raw + 1)) if _strength.raw > 0 else 0
|
||||
if sm.updated['deviceState']:
|
||||
ds = sm['deviceState']
|
||||
# Battery (not present on all hardware/messages)
|
||||
try:
|
||||
self._battery_pct = int(ds.batteryPercent)
|
||||
self._battery_charging = bool(ds.batteryStatus == "Charging")
|
||||
except (AttributeError, ValueError):
|
||||
self._battery_pct = None
|
||||
|
||||
# Status pill — computed every frame from cached state so it's always current.
|
||||
# severity: 1 = warning (orange), 2 = error (red). No issues -> READY (teal).
|
||||
_ds = sm['deviceState']
|
||||
issues: list[tuple[int, str]] = []
|
||||
|
||||
_ts = _ds.thermalStatus
|
||||
if _ts == ThermalStatus.red:
|
||||
issues.append((2, tr("TEMP HIGH")))
|
||||
elif _ts == ThermalStatus.yellow:
|
||||
issues.append((1, tr("TEMP OK")))
|
||||
|
||||
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
||||
issues.append((2, tr("UNAVAILABLE")))
|
||||
|
||||
_last_ping = _ds.lastAthenaPingTime
|
||||
if _last_ping == 0:
|
||||
issues.append((1, tr("KONN3KT OFFLINE")))
|
||||
elif time.monotonic_ns() - _last_ping >= 80_000_000_000:
|
||||
issues.append((2, tr("KONN3KT ERROR")))
|
||||
|
||||
if issues:
|
||||
severity, word = max(issues, key=lambda i: i[0])
|
||||
else:
|
||||
severity, word = 0, tr("READY")
|
||||
|
||||
self._status_color = (STATUS_GOOD, STATUS_WARN, STATUS_DANGER)[severity]
|
||||
self._status_word = word
|
||||
|
||||
# Expanded status (classic-UI style) — rebuilt every frame from cached state
|
||||
if self._expanded:
|
||||
ds = sm['deviceState']
|
||||
ts = ds.thermalStatus
|
||||
if ts == ThermalStatus.green:
|
||||
temp = (tr("TEMP"), tr("GOOD"), STATUS_GOOD)
|
||||
elif ts == ThermalStatus.yellow:
|
||||
temp = (tr("TEMP"), tr("OK"), STATUS_WARN)
|
||||
else:
|
||||
temp = (tr("TEMP"), tr("HIGH"), STATUS_DANGER)
|
||||
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
||||
veh = (tr("VEHICLE"), tr("NO PANDA"), STATUS_DANGER)
|
||||
else:
|
||||
veh = (tr("VEHICLE"), tr("ONLINE"), STATUS_GOOD)
|
||||
last_ping = ds.lastAthenaPingTime
|
||||
if last_ping == 0:
|
||||
kon = (tr("KONN3KT"), tr("OFFLINE"), STATUS_WARN)
|
||||
elif time.monotonic_ns() - last_ping < 80_000_000_000:
|
||||
kon = (tr("KONN3KT"), tr("ONLINE"), STATUS_GOOD)
|
||||
else:
|
||||
kon = (tr("KONN3KT"), tr("ERROR"), STATUS_DANGER)
|
||||
self._expanded_metrics = [temp, veh, kon]
|
||||
else:
|
||||
self._expanded_metrics = []
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
if (self.current_state == HomeLayoutState.HOME and ui_state.prime_state.is_paired()
|
||||
and not self._show_picker and rl.check_collision_point_rec(mouse_pos, self.right_column_rect)):
|
||||
self._press_start = time.monotonic()
|
||||
self._press_origin = (mouse_pos.x, mouse_pos.y)
|
||||
self._press_scrolled = False
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
if self._press_start is None or self._press_scrolled or not mouse_event.left_down:
|
||||
return
|
||||
|
||||
dx = mouse_event.pos.x - self._press_origin[0]
|
||||
dy = mouse_event.pos.y - self._press_origin[1]
|
||||
if dx * dx + dy * dy > 18 * 18:
|
||||
self._press_start = None
|
||||
self._press_scrolled = True
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
self._clear_panel_hold()
|
||||
|
||||
if self._show_picker:
|
||||
if self._picker_ignore_release:
|
||||
self._picker_ignore_release = False
|
||||
return
|
||||
if self._picker_hover is not None:
|
||||
self._panel_widget = self._picker_hover
|
||||
self.params.put(PANEL_KEY, self._panel_widget)
|
||||
if self._panel_widget == PANEL_INSPIRE:
|
||||
self._inspire_widget.show_event()
|
||||
elif self._panel_widget == PANEL_CHANGELOG:
|
||||
self._changelog_widget.show_event()
|
||||
self._show_picker = False
|
||||
self._picker_hover = None
|
||||
return
|
||||
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
|
||||
self._set_state(HomeLayoutState.UPDATE)
|
||||
elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
|
||||
self._set_state(HomeLayoutState.ALERTS)
|
||||
|
||||
def _render_status_bar(self):
|
||||
rect = self.status_bar_rect
|
||||
cy = rect.y + rect.height / 2
|
||||
font_bold = gui_app.font(FontWeight.BOLD)
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
word_fs = 50
|
||||
text_fs = 44
|
||||
pad = 36
|
||||
gap = 26
|
||||
light_d = 54 # status light region width
|
||||
|
||||
# --- measure the left cluster so we can wrap it in a rounded pill ---
|
||||
# The summary word (READY / KONN3KT ERROR / ...) duplicates the expanded TEMP/VEHICLE/KONN3KT
|
||||
# chips, so drop it when expanded — the color-coded status dot still conveys severity.
|
||||
show_word = not (self._expanded and self._expanded_metrics)
|
||||
word_w = measure_text_cached(font_bold, self._status_word, word_fs).x if show_word else 0
|
||||
net_text = current_ssid(self._on_wifi) or tr(self._net_type)
|
||||
net_w = measure_text_cached(font, net_text, text_fs).x
|
||||
_sig_icons = self._wifi_strength_icons if self._on_wifi else self._cell_strength_icons
|
||||
_icon_signal = _sig_icons[min(self._net_strength, len(_sig_icons) - 1)]
|
||||
cluster_w = pad + light_d + gap + (word_w + gap if show_word else 0) + _icon_signal.width + 14 + net_w
|
||||
batt_text = None
|
||||
if self._battery_pct is not None:
|
||||
batt_text = f"{self._battery_pct}%"
|
||||
cluster_w += gap + self._icon_battery.width + 10 + measure_text_cached(font, batt_text, text_fs).x
|
||||
cluster_w += pad
|
||||
|
||||
pill = rl.Rectangle(rect.x, rect.y, cluster_w, rect.height)
|
||||
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(28, 30, 36, 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, rl.Color(255, 255, 255, 28))
|
||||
|
||||
# status light dot with a faint halo
|
||||
x = rect.x + pad
|
||||
halo = rl.Color(self._status_color.r, self._status_color.g, self._status_color.b, 70)
|
||||
rl.draw_circle(int(x + light_d / 2), int(cy), 32, halo)
|
||||
rl.draw_circle(int(x + light_d / 2), int(cy), 22, self._status_color)
|
||||
x += light_d + gap
|
||||
|
||||
# status word (summary) — hidden when the expanded chips already show the breakdown
|
||||
if show_word:
|
||||
word_size = measure_text_cached(font_bold, self._status_word, word_fs)
|
||||
rl.draw_text_ex(font_bold, self._status_word, rl.Vector2(int(x), int(cy - word_size.y / 2)), word_fs, 0, rl.WHITE)
|
||||
x += word_w + gap
|
||||
|
||||
# network: signal icon (strength-aware) + type
|
||||
rl.draw_texture(_icon_signal, int(x), int(cy - _icon_signal.height / 2), rl.WHITE)
|
||||
x += _icon_signal.width + 14
|
||||
net_size = measure_text_cached(font, net_text, text_fs)
|
||||
rl.draw_text_ex(font, net_text, rl.Vector2(int(x), int(cy - net_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
|
||||
x += net_w + gap
|
||||
|
||||
# battery, if available
|
||||
if batt_text is not None:
|
||||
rl.draw_texture(self._icon_battery, int(x), int(cy - self._icon_battery.height / 2), rl.WHITE)
|
||||
x += self._icon_battery.width + 10
|
||||
batt_size = measure_text_cached(font, batt_text, text_fs)
|
||||
rl.draw_text_ex(font, batt_text, rl.Vector2(int(x), int(cy - batt_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
|
||||
|
||||
right_x = rect.x + rect.width
|
||||
version_fs = 44
|
||||
version_text = self._version_commit_text if self._expanded else self._version_text
|
||||
version_size = measure_text_cached(font, version_text, version_fs)
|
||||
version_available = max(1.0, right_x - x - gap)
|
||||
version_w = min(version_size.x, version_available)
|
||||
version_rect = rl.Rectangle(right_x - version_w, rect.y, version_w, rect.height)
|
||||
|
||||
# --- expanded status chips (classic-UI style), gated by the Visuals toggle ---
|
||||
if self._expanded and self._expanded_metrics:
|
||||
chip_x = rect.x + cluster_w + gap
|
||||
chip_h = rect.height - 16
|
||||
chip_pad = 22
|
||||
dot_r = 11
|
||||
label_fs = 30
|
||||
max_x = version_rect.x - 30
|
||||
for label, value, color in self._expanded_metrics:
|
||||
ctext = f"{label} {value}"
|
||||
tw = measure_text_cached(font, ctext, label_fs).x
|
||||
chip_w = chip_pad + dot_r * 2 + 14 + tw + chip_pad
|
||||
if chip_x + chip_w > max_x:
|
||||
break
|
||||
chip = rl.Rectangle(chip_x, rect.y + 8, chip_w, chip_h)
|
||||
rl.draw_rectangle_rounded(chip, 0.5, 16, rl.Color(28, 30, 36, 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(chip, 0.5, 16, 2, rl.Color(255, 255, 255, 22))
|
||||
ccx = chip.x + chip_pad + dot_r
|
||||
rl.draw_circle(int(ccx), int(cy), dot_r, color)
|
||||
rl.draw_text_ex(font, ctext, rl.Vector2(int(ccx + dot_r + 14), int(cy - label_fs / 2 - 3)), label_fs, 0, rl.WHITE)
|
||||
chip_x += chip_w + 16
|
||||
|
||||
# --- right cluster: version (small), then notification chips to its left ---
|
||||
if version_size.x <= version_rect.width:
|
||||
rl.draw_text_ex(font, version_text, rl.Vector2(int(right_x - version_size.x), int(cy - version_size.y / 2)),
|
||||
version_fs, 0, rl.Color(185, 185, 190, 255))
|
||||
else:
|
||||
self._status_version_label.set_text(version_text)
|
||||
self._status_version_label.render(version_rect)
|
||||
chip_x = version_rect.x - 30
|
||||
|
||||
if self.alert_count > 0:
|
||||
self.alert_notif_rect = rl.Rectangle(chip_x - self.alert_notif_rect.width, cy - 30, self.alert_notif_rect.width, 60)
|
||||
self._draw_chip(self.alert_notif_rect, trn("{} ALERT", "{} ALERTS", self.alert_count).format(self.alert_count),
|
||||
rl.Color(226, 72, 58, 255) if self.current_state != HomeLayoutState.ALERTS else rl.Color(245, 92, 78, 255))
|
||||
chip_x = self.alert_notif_rect.x - 16
|
||||
|
||||
if self.update_available:
|
||||
self.update_notif_rect = rl.Rectangle(chip_x - self.update_notif_rect.width, cy - 30, self.update_notif_rect.width, 60)
|
||||
self._draw_chip(self.update_notif_rect, tr("UPDATE"),
|
||||
rl.Color(54, 77, 239, 255) if self.current_state != HomeLayoutState.UPDATE else rl.Color(75, 95, 255, 255))
|
||||
|
||||
def _draw_chip(self, chip_rect: rl.Rectangle, text: str, color: rl.Color):
|
||||
rl.draw_rectangle_rounded(chip_rect, 0.4, 10, color)
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
text_size = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE)
|
||||
text_x = chip_rect.x + (chip_rect.width - text_size.x) / 2
|
||||
text_y = chip_rect.y + (chip_rect.height - text_size.y) / 2
|
||||
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
|
||||
|
||||
def _render_home_content(self):
|
||||
# left: 2x2 launcher tiles
|
||||
for tile, tile_rect in zip(self._tiles, self._tile_rects, strict=False):
|
||||
tile.render(tile_rect)
|
||||
|
||||
# right: setup until paired, then the user-chosen widget
|
||||
if not ui_state.prime_state.is_paired():
|
||||
self._setup_widget.render(self.right_column_rect)
|
||||
return
|
||||
|
||||
self._render_right_panel()
|
||||
|
||||
if self._show_picker:
|
||||
self._render_picker()
|
||||
|
||||
def _render_right_panel(self):
|
||||
rect = self.right_column_rect
|
||||
if self._panel_widget == PANEL_STATS:
|
||||
stats_rect = rl.Rectangle(
|
||||
rect.x,
|
||||
rect.y + STATS_PANEL_VERTICAL_INSET,
|
||||
rect.width,
|
||||
max(1, rect.height - STATS_PANEL_VERTICAL_INSET * 2),
|
||||
)
|
||||
self._stats_panel_widget.render(stats_rect)
|
||||
elif self._panel_widget == PANEL_MAP:
|
||||
self._map_panel_widget.render(rect)
|
||||
elif self._panel_widget == PANEL_INSPIRE:
|
||||
self._inspire_widget.render(rect)
|
||||
else:
|
||||
self._changelog_widget.render(rect)
|
||||
|
||||
# Hold-progress ring drawn over the panel while user is holding
|
||||
if self._press_start is not None and not self._show_picker:
|
||||
held = time.monotonic() - self._press_start
|
||||
progress = min(1.0, held / HOLD_THRESHOLD)
|
||||
cx = int(rect.x + rect.width / 2)
|
||||
cy = int(rect.y + rect.height / 2)
|
||||
rl.draw_ring(rl.Vector2(cx, cy), 34, 42, -90, -90 + 360 * progress, 40, rl.Color(16, 185, 169, 180))
|
||||
|
||||
def _render_picker(self):
|
||||
rect = self.right_column_rect
|
||||
rl.draw_rectangle_rounded(rect, 0.06, 24, PICKER_BG)
|
||||
|
||||
options = [
|
||||
(PANEL_CHANGELOG, "Changelog", "Latest updates"),
|
||||
(PANEL_STATS, "Stats", "Your drive history"),
|
||||
(PANEL_MAP, "Map", "Last known location"),
|
||||
(PANEL_INSPIRE, "Inspiration", "Daily message"),
|
||||
]
|
||||
|
||||
TITLE_FS = 58
|
||||
SUB_FS = 38
|
||||
CARD_PAD_V = 36
|
||||
CARD_PAD_H = 32
|
||||
DOT_MARGIN = 24
|
||||
card_h = TITLE_FS + 12 + SUB_FS + CARD_PAD_V * 2
|
||||
outer_pad = 20
|
||||
gap = 12
|
||||
n = len(options)
|
||||
total_cards_h = n * card_h + (n - 1) * gap
|
||||
start_y = rect.y + (rect.height - total_cards_h) / 2
|
||||
|
||||
mp = rl.get_mouse_position()
|
||||
self._picker_hover = None
|
||||
|
||||
font_title = gui_app.font(FontWeight.BOLD)
|
||||
font_sub = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
for i, (key, title, sub) in enumerate(options):
|
||||
cy_card = start_y + i * (card_h + gap)
|
||||
card = rl.Rectangle(rect.x + outer_pad, cy_card, rect.width - outer_pad * 2, card_h)
|
||||
hovered = rl.check_collision_point_rec(mp, card)
|
||||
if hovered:
|
||||
self._picker_hover = key
|
||||
is_sel = key == self._panel_widget
|
||||
|
||||
bg = PICKER_CARD_HOVER if hovered else PICKER_CARD
|
||||
rl.draw_rectangle_rounded(card, 0.14, 16, bg)
|
||||
border = PICKER_SEL_BORDER if is_sel else PICKER_BORDER
|
||||
rl.draw_rectangle_rounded_lines_ex(card, 0.14, 16, 2 if is_sel else 1, border)
|
||||
|
||||
dot_cx = int(card.x + CARD_PAD_H)
|
||||
dot_cy = int(card.y + card_h / 2)
|
||||
if is_sel:
|
||||
rl.draw_circle(dot_cx, dot_cy, 8, PICKER_TEAL)
|
||||
else:
|
||||
rl.draw_circle(dot_cx, dot_cy, 8, rl.Color(255, 255, 255, 35))
|
||||
rl.draw_ring(rl.Vector2(dot_cx, dot_cy), 5.5, 8, 0, 360, 24, rl.Color(255, 255, 255, 55))
|
||||
|
||||
tx = int(card.x + CARD_PAD_H + DOT_MARGIN + 6)
|
||||
title_y = int(card.y + CARD_PAD_V)
|
||||
sub_y = int(title_y + TITLE_FS + 8)
|
||||
title_col = rl.WHITE if is_sel else rl.Color(210, 210, 215, 255)
|
||||
sub_col = PICKER_TEAL if is_sel else rl.Color(130, 130, 138, 255)
|
||||
rl.draw_text_ex(font_title, title, rl.Vector2(tx, title_y), TITLE_FS, 0, title_col)
|
||||
rl.draw_text_ex(font_sub, sub, rl.Vector2(tx, sub_y), SUB_FS, 0, sub_col)
|
||||
|
||||
# Hint centered below the cards
|
||||
hint = "Tap a widget to switch • tap outside to dismiss"
|
||||
font_hint = gui_app.font(FontWeight.MEDIUM)
|
||||
hw = measure_text_cached(font_hint, hint, 24).x
|
||||
hint_y = int(start_y + total_cards_h + 18)
|
||||
rl.draw_text_ex(font_hint, hint,
|
||||
rl.Vector2(int(rect.x + (rect.width - hw) / 2), hint_y),
|
||||
24, 0, rl.Color(110, 110, 118, 220))
|
||||
|
||||
def _refresh(self):
|
||||
self._version_text, self._version_commit_text = self._get_version_texts()
|
||||
update_available = self.update_alert.refresh()
|
||||
alert_count = self.offroad_alert.refresh()
|
||||
alerts_present = alert_count > 0
|
||||
|
||||
# Show panels on transition from no alert/update to any alerts/update
|
||||
if not update_available and not alerts_present:
|
||||
self._set_state(HomeLayoutState.HOME)
|
||||
elif update_available and ((not self._prev_update_available) or (not alerts_present and self.current_state == HomeLayoutState.ALERTS)):
|
||||
self._set_state(HomeLayoutState.UPDATE)
|
||||
elif alerts_present and ((not self._prev_alerts_present) or (not update_available and self.current_state == HomeLayoutState.UPDATE)):
|
||||
self._set_state(HomeLayoutState.ALERTS)
|
||||
|
||||
self.update_available = update_available
|
||||
self.alert_count = alert_count
|
||||
self._prev_update_available = update_available
|
||||
self._prev_alerts_present = alerts_present
|
||||
|
||||
def _get_version_texts(self) -> tuple[str, str]:
|
||||
description = self.params.get("UpdaterCurrentDescription")
|
||||
version_text = _format_updater_description(description)
|
||||
if description:
|
||||
parts = [part.strip() for part in description.split(" / ")]
|
||||
if len(parts) >= 3 and parts[2]:
|
||||
return version_text, parts[2]
|
||||
return version_text, version_text
|
||||
484
iqpilot/selfdrive/ui/layouts/main.py
Normal file
484
iqpilot/selfdrive/ui/layouts/main.py
Normal file
@@ -0,0 +1,484 @@
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, IntEnum, auto
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
|
||||
from iqpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
|
||||
from iqpilot.selfdrive.ui.layouts.home import HomeLayout
|
||||
from iqpilot.selfdrive.ui.layouts.stats import StatsLayout
|
||||
from iqpilot.selfdrive.ui.layouts.nav import NavLayout
|
||||
from iqpilot.selfdrive.ui.layouts.routes import RoutesLayout
|
||||
from iqpilot.selfdrive.ui.layouts.video_player import VideoPlayerLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings_hub import SettingsHubLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings.settings import PanelType
|
||||
from iqpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
|
||||
from iqpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
|
||||
|
||||
|
||||
class MainState(IntEnum):
|
||||
HOME = 0
|
||||
SETTINGS = 1
|
||||
ONROAD = 2
|
||||
STATS = 3
|
||||
NAV = 4
|
||||
ROUTES = 5
|
||||
VIDEO = 6
|
||||
|
||||
|
||||
OFFROAD_TRANSITION_SECONDS = 0.30
|
||||
TRANSITION_SURFACE_OVERSCAN = 4
|
||||
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
|
||||
|
||||
# iOS-style swipe-from-the-left-edge-to-go-back, generalized across the whole offroad UI (settings
|
||||
# hub still owns its own panel->grid swipe; this covers the top-level pages).
|
||||
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back must start within
|
||||
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to a swipe
|
||||
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (under arm distance) that cancels it
|
||||
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of width dragged to complete the pop on release
|
||||
SWIPE_SETTLE_SECONDS = 0.16 # animate from the release point to done/cancelled (no snap)
|
||||
|
||||
|
||||
class MenuTransitionState(Enum):
|
||||
IDLE = auto()
|
||||
PUSHING = auto()
|
||||
POPPING = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuTransition:
|
||||
state: MenuTransitionState = MenuTransitionState.IDLE
|
||||
t: float = 0.0
|
||||
duration: float = OFFROAD_TRANSITION_SECONDS
|
||||
from_screen: object | None = None
|
||||
to_screen: object | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state != MenuTransitionState.IDLE
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def _ease_out_cubic(x: float) -> float:
|
||||
x = _clamp(x)
|
||||
return 1.0 - pow(1.0 - x, 3.0)
|
||||
|
||||
|
||||
def _ease_emphasized(x: float) -> float:
|
||||
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
|
||||
x = _clamp(x)
|
||||
if x < 0.5:
|
||||
return 4.0 * x * x * x
|
||||
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
|
||||
|
||||
|
||||
# Depth cues for the page push/pop (iOS-style): the underneath page parallaxes a fraction of the
|
||||
# way, dims into the background, and the top card casts a soft shadow off its leading edge.
|
||||
TRANSITION_PARALLAX = 0.28
|
||||
TRANSITION_MAX_DIM = 0.5
|
||||
TRANSITION_SHADOW_W = 32
|
||||
|
||||
|
||||
class MainLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._pm = messaging.PubMaster(['bookmarkButton'])
|
||||
|
||||
self._sidebar = Sidebar()
|
||||
# The offroad launcher owns the full screen; the sidebar is reserved for onroad.
|
||||
self._sidebar.set_visible(False)
|
||||
self._current_mode = MainState.HOME
|
||||
self._prev_onroad = False
|
||||
|
||||
# Initialize layouts
|
||||
self._layouts = {
|
||||
MainState.HOME: HomeLayout(),
|
||||
MainState.SETTINGS: SettingsHubLayout(),
|
||||
MainState.ONROAD: AugmentedRoadView(),
|
||||
MainState.STATS: StatsLayout(),
|
||||
MainState.NAV: NavLayout(),
|
||||
MainState.ROUTES: RoutesLayout(),
|
||||
MainState.VIDEO: VideoPlayerLayout(),
|
||||
}
|
||||
|
||||
self._sidebar_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._content_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._transition = MenuTransition()
|
||||
|
||||
# Edge-swipe-back drag state
|
||||
self._swipe_start_pos = None
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
# Release-settle animation (glide to done/cancelled instead of snapping)
|
||||
self._swipe_settling = False
|
||||
self._swipe_settle_from = 0.0
|
||||
self._swipe_settle_to = 0.0
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_render_target: MainState | None = None
|
||||
self._swipe_completing = False
|
||||
# Set callbacks
|
||||
self._setup_callbacks()
|
||||
|
||||
self._onboarding_window = OnboardingWindow()
|
||||
if not self._onboarding_window.completed:
|
||||
gui_app.set_modal_overlay(self._onboarding_window)
|
||||
|
||||
def _render(self, _):
|
||||
self._handle_onroad_transition()
|
||||
self._render_main_content()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
|
||||
on_flag=self._on_bookmark_clicked,
|
||||
open_settings=lambda: self.open_settings(PanelType.TOGGLES))
|
||||
self._layouts[MainState.HOME].set_settings_callback(self.open_settings)
|
||||
self._layouts[MainState.HOME].set_stats_callback(self.open_stats)
|
||||
self._layouts[MainState.HOME].set_nav_callback(self.open_nav)
|
||||
self._layouts[MainState.HOME].set_routes_callback(self.open_routes)
|
||||
self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state)
|
||||
self._layouts[MainState.STATS].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.NAV].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.ROUTES].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.ROUTES].set_on_play(self.open_video)
|
||||
self._layouts[MainState.VIDEO].set_on_back(self.open_routes)
|
||||
self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked)
|
||||
device.add_interactive_timeout_callback(self._on_interactive_timeout)
|
||||
|
||||
def _update_layout_rects(self):
|
||||
self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
|
||||
|
||||
x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
|
||||
self._content_rect = rl.Rectangle(self._rect.x + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height)
|
||||
|
||||
def _handle_onroad_transition(self):
|
||||
if ui_state.started != self._prev_onroad:
|
||||
self._prev_onroad = ui_state.started
|
||||
|
||||
self._set_mode_for_state()
|
||||
|
||||
def _car_stationary(self) -> bool:
|
||||
if not ui_state.sm.valid["carState"]:
|
||||
return False
|
||||
return ui_state.sm["carState"].vEgo < 0.1
|
||||
|
||||
def _on_interactive_timeout(self):
|
||||
# The idle timeout normally returns the UI to the road view. Don't yank the user out of Settings
|
||||
# while the car is stationary - e.g. a hybrid parked with the engine running to charge reads as
|
||||
# onroad (ignition tracks the ICE), so this would otherwise make Settings unusable while parked.
|
||||
# A moving car still returns to the road view.
|
||||
if self._current_mode == MainState.SETTINGS and ui_state.started and self._car_stationary():
|
||||
return
|
||||
self._set_mode_for_state()
|
||||
|
||||
def _set_mode_for_state(self):
|
||||
if ui_state.started:
|
||||
# Don't hide sidebar from interactive timeout
|
||||
if self._current_mode != MainState.ONROAD:
|
||||
self._set_sidebar_visible(False)
|
||||
self._set_current_layout(MainState.ONROAD)
|
||||
else:
|
||||
# Offroad launcher owns the full screen; the sidebar is reserved for onroad.
|
||||
self._set_current_layout(MainState.HOME)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def _set_current_layout(self, layout: MainState):
|
||||
if self._transition.active:
|
||||
if layout == MainState.ONROAD or ui_state.started:
|
||||
self._cancel_transition()
|
||||
elif layout == self._current_mode:
|
||||
self._cancel_transition()
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
if layout == self._current_mode:
|
||||
return
|
||||
|
||||
old_mode = self._current_mode
|
||||
if self._should_animate_transition(old_mode, layout):
|
||||
self._start_transition(old_mode, layout)
|
||||
return
|
||||
|
||||
self._layouts[old_mode].hide_event()
|
||||
self._current_mode = layout
|
||||
self._layouts[self._current_mode].show_event()
|
||||
|
||||
def _should_animate_transition(self, old_mode: MainState, new_mode: MainState) -> bool:
|
||||
return (
|
||||
not ui_state.started
|
||||
and old_mode != MainState.ONROAD
|
||||
and new_mode != MainState.ONROAD
|
||||
and (old_mode == MainState.HOME or new_mode == MainState.HOME)
|
||||
)
|
||||
|
||||
def _start_transition(self, old_mode: MainState, new_mode: MainState):
|
||||
state = MenuTransitionState.PUSHING if old_mode == MainState.HOME else MenuTransitionState.POPPING
|
||||
self._transition = MenuTransition(
|
||||
state=state,
|
||||
t=0.0,
|
||||
duration=OFFROAD_TRANSITION_SECONDS,
|
||||
from_screen=old_mode,
|
||||
to_screen=new_mode,
|
||||
)
|
||||
self._layouts[new_mode].show_event()
|
||||
|
||||
def _cancel_transition(self):
|
||||
pending_mode = self._transition.to_screen
|
||||
if pending_mode is not None and pending_mode != self._current_mode:
|
||||
self._layouts[pending_mode].hide_event()
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def _finish_transition(self):
|
||||
old_mode = self._transition.from_screen
|
||||
new_mode = self._transition.to_screen
|
||||
if old_mode is not None:
|
||||
self._layouts[old_mode].hide_event()
|
||||
if new_mode is not None:
|
||||
self._current_mode = new_mode
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def _render_layout(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
|
||||
if interactive:
|
||||
layout.render(rect)
|
||||
return
|
||||
|
||||
enabled = layout._enabled
|
||||
layout.set_enabled(False)
|
||||
try:
|
||||
layout.render(rect)
|
||||
finally:
|
||||
layout.set_enabled(enabled)
|
||||
|
||||
def _render_layout_surface(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
|
||||
bg_rect = rl.Rectangle(
|
||||
rect.x - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.y - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
)
|
||||
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout(layout, rect, interactive)
|
||||
|
||||
def _render_layout_surface_translated(self, layout: Widget, rect: rl.Rectangle, x_offset: float):
|
||||
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
|
||||
self._render_layout_surface(layout, translated_rect, interactive=False)
|
||||
|
||||
def _render_transition(self, content_rect: rl.Rectangle) -> bool:
|
||||
if not self._transition.active:
|
||||
return False
|
||||
|
||||
from_mode = self._transition.from_screen
|
||||
to_mode = self._transition.to_screen
|
||||
if from_mode is None or to_mode is None:
|
||||
self._finish_transition()
|
||||
return False
|
||||
|
||||
self._transition.t += rl.get_frame_time()
|
||||
if self._transition.t >= self._transition.duration:
|
||||
self._finish_transition()
|
||||
return False
|
||||
|
||||
p = _clamp(self._transition.t / self._transition.duration)
|
||||
e = _ease_emphasized(p)
|
||||
w = content_rect.width
|
||||
pushing = self._transition.state == MenuTransitionState.PUSHING
|
||||
|
||||
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
|
||||
if pushing:
|
||||
top_mode, back_mode = to_mode, from_mode
|
||||
top_x = _lerp(w, 0.0, e)
|
||||
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
|
||||
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
|
||||
else:
|
||||
top_mode, back_mode = from_mode, to_mode
|
||||
top_x = _lerp(0.0, w, e)
|
||||
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
|
||||
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
|
||||
|
||||
back_rect = rl.Rectangle(round(content_rect.x + back_x), content_rect.y, content_rect.width, content_rect.height)
|
||||
|
||||
rl.draw_rectangle_rec(content_rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout_surface_translated(self._layouts[back_mode], content_rect, back_x)
|
||||
if back_dim > 0:
|
||||
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
|
||||
# soft shadow cast by the top card's leading edge onto the page behind
|
||||
edge_x = round(content_rect.x + top_x)
|
||||
if edge_x > content_rect.x:
|
||||
sh = int(min(TRANSITION_SHADOW_W, edge_x - content_rect.x))
|
||||
rl.draw_rectangle_gradient_h(edge_x - sh, int(content_rect.y), sh, int(content_rect.height),
|
||||
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
|
||||
self._render_layout_surface_translated(self._layouts[top_mode], content_rect, top_x)
|
||||
return True
|
||||
|
||||
def open_settings(self, panel_type: PanelType | None = None):
|
||||
self._set_current_layout(MainState.SETTINGS)
|
||||
if panel_type is None:
|
||||
self._layouts[MainState.SETTINGS].show_grid()
|
||||
else:
|
||||
self._layouts[MainState.SETTINGS].set_current_panel(panel_type)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_stats(self):
|
||||
self._set_current_layout(MainState.STATS)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_nav(self):
|
||||
self._set_current_layout(MainState.NAV)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_routes(self):
|
||||
self._set_current_layout(MainState.ROUTES)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_video(self, route: str):
|
||||
self._layouts[MainState.VIDEO].set_route(route)
|
||||
self._set_current_layout(MainState.VIDEO)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def _on_settings_clicked(self):
|
||||
self.open_settings()
|
||||
|
||||
def _on_bookmark_clicked(self):
|
||||
user_bookmark = messaging.new_message('bookmarkButton')
|
||||
user_bookmark.valid = True
|
||||
self._pm.send('bookmarkButton', user_bookmark)
|
||||
|
||||
def _set_sidebar_visible(self, visible: bool):
|
||||
self._sidebar.set_visible(visible)
|
||||
self._update_layout_rects()
|
||||
|
||||
def _on_onroad_clicked(self):
|
||||
self._set_sidebar_visible(not self._sidebar.is_visible)
|
||||
|
||||
def _back_target(self) -> MainState | None:
|
||||
"""The page an edge-swipe-back should return to, or None if there's nowhere to go back."""
|
||||
if ui_state.started:
|
||||
return None
|
||||
mode = self._current_mode
|
||||
if mode == MainState.VIDEO:
|
||||
return MainState.ROUTES
|
||||
if mode in (MainState.SETTINGS, MainState.STATS, MainState.NAV, MainState.ROUTES):
|
||||
# The settings hub owns its own panel->grid swipe; only take over once it's back at the grid.
|
||||
if mode == MainState.SETTINGS and getattr(self._layouts[MainState.SETTINGS], "_mode", "grid") == "panel":
|
||||
return None
|
||||
return MainState.HOME
|
||||
return None
|
||||
|
||||
def _reset_swipe(self):
|
||||
self._swipe_start_pos = None
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
if self._swipe_settling:
|
||||
return # let the release animation finish before accepting a new gesture
|
||||
|
||||
if mouse_event.slot != 0 or self._transition.active or self._back_target() is None:
|
||||
self._reset_swipe()
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
|
||||
self._swipe_start_pos = mouse_event.pos
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
else:
|
||||
self._reset_swipe()
|
||||
|
||||
elif self._swipe_start_pos is not None:
|
||||
if mouse_event.left_down:
|
||||
dx = mouse_event.pos.x - self._swipe_start_pos.x
|
||||
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
|
||||
if not self._swipe_active and not self._swipe_blocked:
|
||||
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
|
||||
self._swipe_blocked = True # user is scrolling, not swiping back
|
||||
elif dx > EDGE_SWIPE_ARM_DISTANCE:
|
||||
self._swipe_active = True
|
||||
if self._swipe_active:
|
||||
self._swipe_dx = max(0.0, dx)
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._swipe_active:
|
||||
target = self._back_target()
|
||||
complete = target is not None and self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
|
||||
self._begin_swipe_settle(target, complete)
|
||||
self._reset_swipe()
|
||||
|
||||
def _complete_back(self, target: MainState):
|
||||
# The finger already dragged the page most of the way across, so switch instantly (matching the
|
||||
# settings hub's swipe) rather than replaying the eased transition.
|
||||
old_mode = self._current_mode
|
||||
if old_mode == target:
|
||||
return
|
||||
self._layouts[old_mode].hide_event()
|
||||
self._current_mode = target
|
||||
self._layouts[target].show_event()
|
||||
|
||||
def _begin_swipe_settle(self, target: MainState | None, complete: bool):
|
||||
# On release, glide from where the finger let go to fully-open (complete) or closed (cancel)
|
||||
# instead of snapping. Needs a back page to slide behind; if there's none, just drop the drag.
|
||||
if target is None:
|
||||
return
|
||||
self._swipe_settle_from = self._swipe_dx
|
||||
self._swipe_settle_to = self._rect.width if complete else 0.0
|
||||
self._swipe_render_target = target
|
||||
self._swipe_completing = complete
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_settling = True
|
||||
|
||||
def _update_swipe_settle(self) -> bool:
|
||||
"""Advance the release animation. Returns True while still animating."""
|
||||
self._swipe_settle_t += rl.get_frame_time()
|
||||
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
|
||||
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
|
||||
if p < 1.0:
|
||||
return True
|
||||
self._swipe_settling = False
|
||||
target, completing = self._swipe_render_target, self._swipe_completing
|
||||
self._swipe_render_target = None
|
||||
self._swipe_completing = False
|
||||
self._swipe_dx = 0.0
|
||||
if completing and target is not None:
|
||||
self._complete_back(target)
|
||||
return False
|
||||
|
||||
def _render_swipe_drag(self, rect: rl.Rectangle, target: MainState):
|
||||
# Live finger-following pop: the destination sits behind, the current page slides out right.
|
||||
dx = min(self._swipe_dx, rect.width)
|
||||
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout_surface_translated(self._layouts[target], rect, dx - rect.width)
|
||||
self._render_layout_surface_translated(self._layouts[self._current_mode], rect, dx)
|
||||
|
||||
def _render_main_content(self):
|
||||
# Render sidebar (onroad only)
|
||||
if self._sidebar.is_visible:
|
||||
self._sidebar.render(self._sidebar_rect)
|
||||
|
||||
content_rect = self._content_rect if self._sidebar.is_visible else self._rect
|
||||
if self._render_transition(content_rect):
|
||||
return
|
||||
if self._swipe_settling:
|
||||
if self._update_swipe_settle():
|
||||
self._render_swipe_drag(content_rect, self._swipe_render_target)
|
||||
return
|
||||
# settled this frame; fall through to render the (possibly switched) current page
|
||||
elif self._swipe_active:
|
||||
target = self._back_target()
|
||||
if target is not None:
|
||||
self._render_swipe_drag(content_rect, target)
|
||||
return
|
||||
self._layouts[self._current_mode].render(content_rect)
|
||||
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal file
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal file
@@ -0,0 +1,562 @@
|
||||
import threading
|
||||
import time
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import (has_mapbox_token, resolve_mapbox_token,
|
||||
current_or_last_gps_position)
|
||||
from iqpilot.ui.onroad.nav_map_panel import NavMapPanel
|
||||
from iqpilot.ui.onroad.nav_map_utils import build_mapbox_static_url
|
||||
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, GL_VERSION
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.keyboard import Keyboard
|
||||
from iqpilot.selfdrive.ui.lib import nav_search
|
||||
from iqpilot.selfdrive.ui.lib.nav_search import NavSearch, SearchResult
|
||||
from iqpilot.selfdrive.ui.widgets.interactive_map import InteractiveNavMap
|
||||
|
||||
MARGIN = 40
|
||||
SPACING = 25
|
||||
SEARCH_HEIGHT = 120
|
||||
PILL_HEIGHT = 120
|
||||
MAP_ZOOM = 15.0
|
||||
MAP_RETRY_INTERVAL = 5.0
|
||||
|
||||
PANEL_BG = rl.Color(38, 40, 46, 255)
|
||||
PANEL_BORDER = rl.Color(255, 255, 255, 38)
|
||||
MUTED = rl.Color(165, 165, 170, 255)
|
||||
|
||||
ROUNDED_TEXTURE_VERTEX_SHADER = GL_VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
uniform mat4 mvp;
|
||||
out vec2 fragTexCoord;
|
||||
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
ROUNDED_TEXTURE_FRAGMENT_SHADER = GL_VERSION + """
|
||||
in vec2 fragTexCoord;
|
||||
uniform sampler2D texture0;
|
||||
uniform vec4 clipRect;
|
||||
uniform float cornerRadius;
|
||||
uniform float viewportHeight;
|
||||
out vec4 fragColor;
|
||||
|
||||
float roundedRectDistance(vec2 p, vec2 center, vec2 halfSize, float radius) {
|
||||
vec2 d = abs(p - center) - (halfSize - vec2(radius));
|
||||
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = vec2(gl_FragCoord.x, viewportHeight - gl_FragCoord.y);
|
||||
vec2 center = clipRect.xy + clipRect.zw * 0.5;
|
||||
vec2 halfSize = clipRect.zw * 0.5;
|
||||
float radius = min(cornerRadius, min(halfSize.x, halfSize.y));
|
||||
float dist = roundedRectDistance(p, center, halfSize, radius);
|
||||
float alpha = 1.0 - smoothstep(0.0, 1.25, dist);
|
||||
vec4 sampled = texture(texture0, fragTexCoord);
|
||||
fragColor = vec4(sampled.rgb, sampled.a * alpha);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class _MapPreview:
|
||||
"""Fetches a Mapbox static map (background thread) and caches it as a texture."""
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._session = requests.Session()
|
||||
self._texture: rl.Texture | None = None
|
||||
self._pending: tuple[tuple, bytes] | None = None
|
||||
self._fetching = False
|
||||
self._key: tuple | None = None
|
||||
self._status = "idle"
|
||||
self._last_attempt = 0.0
|
||||
self._rounded_shader = None
|
||||
self._rounded_shader_locs: dict[str, int] = {}
|
||||
self._clip_rect = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
|
||||
self._corner_radius = rl.ffi.new("float[]", [0.0])
|
||||
self._viewport_height = rl.ffi.new("float[]", [0.0])
|
||||
|
||||
def has_token(self) -> bool:
|
||||
return has_mapbox_token(self._params)
|
||||
|
||||
def _fetch(self, url: str, token: str, key: tuple):
|
||||
try:
|
||||
r = self._session.get(url, params={"access_token": token}, timeout=4.0)
|
||||
if r.status_code == 200 and r.content:
|
||||
self._pending = (key, r.content)
|
||||
self._status = "ready"
|
||||
else:
|
||||
self._key = None
|
||||
self._status = "error"
|
||||
except requests.RequestException:
|
||||
self._key = None
|
||||
self._status = "error"
|
||||
finally:
|
||||
self._fetching = False
|
||||
|
||||
def request(self, lat: float, lon: float, bearing: float, w: float, h: float):
|
||||
token = resolve_mapbox_token(self._params)
|
||||
if not token or w < 20 or h < 20:
|
||||
self._status = "token_missing" if not token else "idle"
|
||||
return
|
||||
key = (round(lat, 4), round(lon, 4))
|
||||
if self._fetching or key == self._key:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if self._status == "error" and now - self._last_attempt < MAP_RETRY_INTERVAL:
|
||||
return
|
||||
self._last_attempt = now
|
||||
self._key = key
|
||||
self._fetching = True
|
||||
self._status = "loading"
|
||||
scale = min(1.0, 1000.0 / max(w, h))
|
||||
url = build_mapbox_static_url(lat, lon, MAP_ZOOM, bearing, max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
threading.Thread(target=self._fetch, args=(url, token, key), daemon=True).start()
|
||||
|
||||
def _consume(self):
|
||||
if self._pending is None:
|
||||
return
|
||||
_key, data = self._pending
|
||||
self._pending = None
|
||||
try:
|
||||
ext = ".png" if data[:4] == b"\x89PNG" else ".jpg"
|
||||
img = rl.load_image_from_memory(ext, data, len(data))
|
||||
tex = rl.load_texture_from_image(img)
|
||||
rl.unload_image(img)
|
||||
rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
if self._texture is not None:
|
||||
rl.unload_texture(self._texture)
|
||||
self._texture = tex
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_rounded_shader(self):
|
||||
if self._rounded_shader is not None:
|
||||
return
|
||||
self._rounded_shader = rl.load_shader_from_memory(ROUNDED_TEXTURE_VERTEX_SHADER, ROUNDED_TEXTURE_FRAGMENT_SHADER)
|
||||
self._rounded_shader_locs = {
|
||||
"clipRect": rl.get_shader_location(self._rounded_shader, "clipRect"),
|
||||
"cornerRadius": rl.get_shader_location(self._rounded_shader, "cornerRadius"),
|
||||
"viewportHeight": rl.get_shader_location(self._rounded_shader, "viewportHeight"),
|
||||
}
|
||||
|
||||
def _draw_texture(self, src: rl.Rectangle, rect: rl.Rectangle, roundness: float):
|
||||
if roundness <= 0:
|
||||
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
|
||||
return
|
||||
|
||||
self._ensure_rounded_shader()
|
||||
self._clip_rect[0:4] = [rect.x, rect.y, rect.width, rect.height]
|
||||
self._corner_radius[0] = max(0.0, min(rect.width, rect.height) * roundness * 0.5)
|
||||
self._viewport_height[0] = gui_app.height
|
||||
rl.set_shader_value(
|
||||
self._rounded_shader,
|
||||
self._rounded_shader_locs["clipRect"],
|
||||
self._clip_rect,
|
||||
rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4,
|
||||
)
|
||||
rl.set_shader_value(
|
||||
self._rounded_shader,
|
||||
self._rounded_shader_locs["cornerRadius"],
|
||||
self._corner_radius,
|
||||
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
|
||||
)
|
||||
rl.set_shader_value(
|
||||
self._rounded_shader,
|
||||
self._rounded_shader_locs["viewportHeight"],
|
||||
self._viewport_height,
|
||||
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
|
||||
)
|
||||
|
||||
rl.begin_shader_mode(self._rounded_shader)
|
||||
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def draw(self, rect: rl.Rectangle, roundness: float = 0.0) -> bool:
|
||||
self._consume()
|
||||
if self._texture is None or self._texture.id == 0:
|
||||
return False
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
src = rl.Rectangle(0, 0, self._texture.width, self._texture.height)
|
||||
self._draw_texture(src, rect, roundness)
|
||||
rl.end_scissor_mode()
|
||||
return True
|
||||
|
||||
def status(self) -> str:
|
||||
if self._fetching:
|
||||
return "loading"
|
||||
return self._status
|
||||
|
||||
|
||||
class _Pill(Widget):
|
||||
"""A rounded destination shortcut: icon + label (Home / Work / Recent)."""
|
||||
|
||||
BG = rl.Color(38, 40, 46, 255)
|
||||
BG_PRESSED = rl.Color(54, 57, 65, 255)
|
||||
|
||||
def __init__(self, icon_path: str, label: str, on_click: Callable[[], None] | None = None):
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._icon = gui_app.texture(icon_path, 56, 56, keep_aspect_ratio=True)
|
||||
if on_click is not None:
|
||||
self.set_click_callback(on_click)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rounded(rect, 0.5, 20, self.BG_PRESSED if self.is_pressed else self.BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.5, 20, 2, PANEL_BORDER)
|
||||
cy = rect.y + rect.height / 2
|
||||
x = rect.x + 32
|
||||
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
|
||||
x += self._icon.width + 20
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
ts = measure_text_cached(font, self._label, 40)
|
||||
rl.draw_text_ex(font, self._label, rl.Vector2(int(x), int(cy - ts.y / 2)), 40, 0, rl.WHITE)
|
||||
|
||||
|
||||
ROW_HEIGHT = 116
|
||||
ROW_GAP = 16
|
||||
RESULT_NAME = rl.Color(240, 240, 244, 255)
|
||||
SEARCH_DEBOUNCE = 0.25
|
||||
|
||||
|
||||
class NavLayout(Widget):
|
||||
"""Offroad Navigate screen: destination search with live Mapbox autocomplete, Home/Work/Recent
|
||||
shortcuts, and a location map preview. Picking a place writes NavigationDestination (navd routes)."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._header = self._child(ScreenHeader(lambda: tr("Navigation")))
|
||||
self._search_icon = gui_app.texture("icons/iq/search.png", 52, 52, keep_aspect_ratio=True)
|
||||
self._pin_icon = gui_app.texture("icons/iq/pin.png", 90, 90, keep_aspect_ratio=True)
|
||||
self._home_icon = gui_app.texture("icons/iq/home.png", 52, 52, keep_aspect_ratio=True)
|
||||
self._work_icon = gui_app.texture("icons/iq/work.png", 52, 52, keep_aspect_ratio=True)
|
||||
self._recent_icon = gui_app.texture("icons/iq/recent.png", 52, 52, keep_aspect_ratio=True)
|
||||
|
||||
self._keyboard = Keyboard(max_text_size=128, min_text_size=0)
|
||||
self._map = _MapPreview()
|
||||
self._imap = self._child(InteractiveNavMap())
|
||||
self._search = NavSearch()
|
||||
|
||||
self._on_back_cb: Callable[[], None] | None = None
|
||||
self._mode = "browse" # "browse" | "results"
|
||||
self._purpose = "navigate" # "navigate" | "set_home" | "set_work"
|
||||
self._query = ""
|
||||
self._selecting = False
|
||||
self._status_ts = 0.0
|
||||
self._pending_exit = False
|
||||
self._status_msg = ""
|
||||
|
||||
self._home: SearchResult | None = None
|
||||
self._work: SearchResult | None = None
|
||||
self._recents: list[SearchResult] = []
|
||||
|
||||
self._tap_targets: list[tuple[rl.Rectangle, Callable[[], None]]] = []
|
||||
self._reload_favorites()
|
||||
|
||||
def _reload_favorites(self):
|
||||
self._home = nav_search.get_home()
|
||||
self._work = nav_search.get_work()
|
||||
self._recents = nav_search.get_recents()
|
||||
|
||||
def set_on_back(self, cb: Callable[[], None]) -> None:
|
||||
self._on_back_cb = cb
|
||||
self._header.set_on_back(self._handle_back)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._mode = "browse"
|
||||
self._status_msg = ""
|
||||
self._reload_favorites()
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
# Free the tile cache's GPU memory while the screen is away.
|
||||
self._imap.release()
|
||||
|
||||
def _handle_back(self):
|
||||
if self._mode == "results":
|
||||
self._exit_search()
|
||||
elif self._on_back_cb is not None:
|
||||
self._on_back_cb()
|
||||
|
||||
# --- search lifecycle -------------------------------------------------------
|
||||
def _open_search(self, purpose: str = "navigate"):
|
||||
# Full-screen modal keyboard (same as before) — search runs when you hit Done.
|
||||
self._purpose = purpose
|
||||
self._search.new_session()
|
||||
self._keyboard.reset(min_text_size=1)
|
||||
title = {"set_home": tr("Set Home"), "set_work": tr("Set Work")}.get(purpose, tr("Search"))
|
||||
self._keyboard.set_title(title, tr("Enter an address or place"))
|
||||
self._keyboard.set_text(self._query if purpose == "navigate" else "")
|
||||
gui_app.set_modal_overlay(self._keyboard, callback=self._on_search_done)
|
||||
|
||||
def _on_search_done(self, result: int):
|
||||
if result != 1:
|
||||
return
|
||||
self._query = self._keyboard.text.strip()
|
||||
if not self._query:
|
||||
return
|
||||
self._search.search(self._query)
|
||||
self._mode = "results"
|
||||
self._status_msg = ""
|
||||
self._selecting = False
|
||||
|
||||
def _exit_search(self):
|
||||
self._mode = "browse"
|
||||
self._selecting = False
|
||||
self._status_msg = ""
|
||||
self._reload_favorites()
|
||||
|
||||
# --- selection (threaded: retrieve coords, then persist) --------------------
|
||||
def _select_result(self, r: SearchResult):
|
||||
if self._selecting:
|
||||
return
|
||||
self._selecting = True
|
||||
self._status_msg = tr("Locating...")
|
||||
threading.Thread(target=self._finish_select, args=(r, self._purpose), daemon=True).start()
|
||||
|
||||
def _finish_select(self, r: SearchResult, purpose: str):
|
||||
full = self._search.retrieve(r)
|
||||
if full is None or not full.has_coords:
|
||||
self._status_msg = tr("Couldn't locate that place")
|
||||
self._selecting = False
|
||||
return
|
||||
if purpose == "set_home":
|
||||
nav_search.save_home(full)
|
||||
elif purpose == "set_work":
|
||||
nav_search.save_work(full)
|
||||
else:
|
||||
nav_search.set_destination(full.lat, full.lon, full.name)
|
||||
nav_search.add_recent(full)
|
||||
self._selecting = False
|
||||
self._pending_exit = True
|
||||
|
||||
def _navigate_place(self, place: SearchResult):
|
||||
if place is not None and place.has_coords:
|
||||
nav_search.set_destination(place.lat, place.lon, place.name)
|
||||
nav_search.add_recent(place)
|
||||
self._reload_favorites()
|
||||
self._dest_check_time = 0.0
|
||||
self._set_status(tr("Destination set"))
|
||||
|
||||
def _set_status(self, msg: str):
|
||||
self._status_msg = msg
|
||||
self._status_ts = time.monotonic()
|
||||
|
||||
def _cancel_nav(self):
|
||||
nav_search.cancel_navigation()
|
||||
self._dest_check_time = 0.0
|
||||
self._set_status(tr("Navigation canceled"))
|
||||
|
||||
def _remove_home(self):
|
||||
nav_search.remove_home()
|
||||
self._reload_favorites()
|
||||
|
||||
def _remove_work(self):
|
||||
nav_search.remove_work()
|
||||
self._reload_favorites()
|
||||
|
||||
def _remove_recent(self, r: SearchResult):
|
||||
nav_search.remove_recent(r)
|
||||
self._reload_favorites()
|
||||
|
||||
def _on_home(self):
|
||||
self._navigate_place(self._home) if self._home is not None else self._open_search("set_home")
|
||||
|
||||
def _on_work(self):
|
||||
self._navigate_place(self._work) if self._work is not None else self._open_search("set_work")
|
||||
|
||||
# --- render -----------------------------------------------------------------
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._tap_targets = []
|
||||
if self._pending_exit:
|
||||
self._pending_exit = False
|
||||
self._exit_search()
|
||||
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
|
||||
self._header.render(header_rect)
|
||||
body = rl.Rectangle(rect.x + MARGIN, header_rect.y + HEADER_HEIGHT + SPACING,
|
||||
rect.width - 2 * MARGIN, rect.y + rect.height - (header_rect.y + HEADER_HEIGHT + SPACING) - MARGIN)
|
||||
if self._mode == "results":
|
||||
self._render_results(body)
|
||||
else:
|
||||
self._render_browse(body)
|
||||
|
||||
def _row(self, rect: rl.Rectangle, icon, title: str, subtitle: str, on_tap, on_delete=None, pressed_hint=True):
|
||||
hit = rl.check_collision_point_rec(rl.get_mouse_position(), rect)
|
||||
bg = rl.Color(54, 57, 65, 255) if (hit and pressed_hint and rl.is_mouse_button_down(0)) else PANEL_BG
|
||||
rl.draw_rectangle_rounded(rect, 0.35, 20, bg)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 20, 2, PANEL_BORDER)
|
||||
x = rect.x + 32
|
||||
if icon is not None:
|
||||
rl.draw_texture(icon, int(x), int(rect.y + rect.height / 2 - icon.height / 2), rl.WHITE)
|
||||
x += icon.width + 24
|
||||
text_w = rect.width - (x - rect.x) - (110 if on_delete is not None else 32)
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
if subtitle:
|
||||
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + 22)), 40, 0, RESULT_NAME)
|
||||
sub = self._ellipsize(font, subtitle, 30, text_w)
|
||||
rl.draw_text_ex(font, sub, rl.Vector2(int(x), int(rect.y + 66)), 30, 0, MUTED)
|
||||
else:
|
||||
ts = measure_text_cached(font, title, 42)
|
||||
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + rect.height / 2 - ts.y / 2)), 42, 0, RESULT_NAME)
|
||||
# Delete (×) button — appended first so a tap on it wins over the row's navigate tap.
|
||||
if on_delete is not None:
|
||||
cx, cy = rect.x + rect.width - 60, rect.y + rect.height / 2
|
||||
del_r = rl.Rectangle(cx - 34, cy - 34, 68, 68)
|
||||
dhit = rl.check_collision_point_rec(rl.get_mouse_position(), del_r)
|
||||
rl.draw_circle(int(cx), int(cy), 30, rl.Color(90, 62, 66, 255) if dhit else rl.Color(60, 62, 70, 255))
|
||||
rl.draw_line_ex(rl.Vector2(cx - 13, cy - 13), rl.Vector2(cx + 13, cy + 13), 4, rl.Color(230, 120, 120, 255))
|
||||
rl.draw_line_ex(rl.Vector2(cx - 13, cy + 13), rl.Vector2(cx + 13, cy - 13), 4, rl.Color(230, 120, 120, 255))
|
||||
self._tap_targets.append((del_r, on_delete))
|
||||
if on_tap is not None:
|
||||
self._tap_targets.append((rect, on_tap))
|
||||
|
||||
@staticmethod
|
||||
def _ellipsize(font, text: str, size: int, max_w: float) -> str:
|
||||
if measure_text_cached(font, text, size).x <= max_w:
|
||||
return text
|
||||
while text and measure_text_cached(font, text + "…", size).x > max_w:
|
||||
text = text[:-1]
|
||||
return text + "…"
|
||||
|
||||
def _render_browse(self, rect: rl.Rectangle):
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
x, w = rect.x, rect.width
|
||||
y = rect.y
|
||||
# Search bar
|
||||
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
|
||||
cy = bar.y + bar.height / 2
|
||||
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
|
||||
ph = tr("Search address or place")
|
||||
rl.draw_text_ex(font, ph, rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24),
|
||||
int(cy - 22)), 44, 0, MUTED)
|
||||
self._tap_targets.append((bar, lambda: self._open_search("navigate")))
|
||||
y += SEARCH_HEIGHT + SPACING
|
||||
|
||||
# Cancel active route (param read throttled)
|
||||
now = time.monotonic()
|
||||
if now - getattr(self, "_dest_check_time", 0.0) > 1.0:
|
||||
self._has_dest = nav_search.has_active_destination()
|
||||
self._dest_check_time = now
|
||||
if getattr(self, "_has_dest", False):
|
||||
cr = rl.Rectangle(x, y, w, ROW_HEIGHT)
|
||||
chit = rl.check_collision_point_rec(rl.get_mouse_position(), cr)
|
||||
rl.draw_rectangle_rounded(cr, 0.35, 20, rl.Color(96, 46, 48, 255) if chit else rl.Color(74, 40, 42, 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(cr, 0.35, 20, 2, rl.Color(210, 90, 90, 120))
|
||||
label = tr("Cancel navigation")
|
||||
ls = measure_text_cached(font, label, 42)
|
||||
rl.draw_text_ex(font, label, rl.Vector2(int(x + 32), int(cr.y + cr.height / 2 - ls.y / 2)), 42, 0,
|
||||
rl.Color(240, 180, 180, 255))
|
||||
self._tap_targets.append((cr, self._cancel_nav))
|
||||
y += ROW_HEIGHT + SPACING
|
||||
|
||||
# Home / Work
|
||||
hw_gap = ROW_GAP
|
||||
hw_w = (w - hw_gap) / 2
|
||||
self._row(rl.Rectangle(x, y, hw_w, ROW_HEIGHT), self._home_icon, tr("Home"),
|
||||
self._home.address if self._home else tr("Set home address"), self._on_home,
|
||||
on_delete=(self._remove_home if self._home else None))
|
||||
self._row(rl.Rectangle(x + hw_w + hw_gap, y, hw_w, ROW_HEIGHT), self._work_icon, tr("Work"),
|
||||
self._work.address if self._work else tr("Set work address"), self._on_work,
|
||||
on_delete=(self._remove_work if self._work else None))
|
||||
y += ROW_HEIGHT + SPACING
|
||||
|
||||
# Recents (fit as many as room allows, leaving space for the map)
|
||||
map_min = 300
|
||||
for r in self._recents:
|
||||
if y + ROW_HEIGHT > rect.y + rect.height - map_min - SPACING:
|
||||
break
|
||||
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._recent_icon, r.name, r.address,
|
||||
(lambda r=r: self._navigate_place(r)), on_delete=(lambda r=r: self._remove_recent(r)))
|
||||
y += ROW_HEIGHT + ROW_GAP
|
||||
|
||||
# Map preview of current location
|
||||
map_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
|
||||
if map_rect.height > 120:
|
||||
self._imap.render(map_rect)
|
||||
if self._status_msg and time.monotonic() - self._status_ts < 2.5:
|
||||
self._draw_toast(map_rect, self._status_msg)
|
||||
|
||||
def _draw_toast(self, area: rl.Rectangle, text: str):
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
ts = measure_text_cached(font, text, 36)
|
||||
pad = 32
|
||||
pw = ts.x + pad * 2
|
||||
pill = rl.Rectangle(area.x + (area.width - pw) / 2, area.y + 24, pw, 66)
|
||||
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(20, 22, 26, 235))
|
||||
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, PANEL_BORDER)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(int(pill.x + pad), int(pill.y + 33 - ts.y / 2)), 36, 0, rl.WHITE)
|
||||
|
||||
def _render_map(self, rect: rl.Rectangle):
|
||||
lat, lon, bearing, fix = current_or_last_gps_position()
|
||||
if fix and self._map.has_token:
|
||||
self._map.request(lat, lon, 0.0, rect.width, rect.height)
|
||||
if self._map.draw(rect, roundness=0.03):
|
||||
# The static map is centered on the fix, so the current location is the panel center.
|
||||
cx, cy = int(rect.x + rect.width / 2), int(rect.y + rect.height / 2)
|
||||
rl.draw_circle(cx, cy, 26, rl.Color(255, 255, 255, 235))
|
||||
rl.draw_circle(cx, cy, 18, rl.Color(23, 134, 246, 255)) # blue location dot
|
||||
return
|
||||
rl.draw_rectangle_rounded(rect, 0.03, 20, rl.Color(18, 18, 20, 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.03, 20, 2, PANEL_BORDER)
|
||||
pin_x = int(rect.x + (rect.width - self._pin_icon.width) / 2)
|
||||
rl.draw_texture(self._pin_icon, pin_x, int(rect.y + rect.height / 2 - self._pin_icon.height), MUTED)
|
||||
self._draw_center_note(rect, tr("Waiting for GPS fix..."), dy=16)
|
||||
|
||||
def _draw_center_note(self, rect: rl.Rectangle, text: str, dy: float = 0):
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
ns = measure_text_cached(font, text, 40)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2),
|
||||
int(rect.y + rect.height / 2 + dy)), 40, 0, MUTED)
|
||||
|
||||
def _render_results(self, rect: rl.Rectangle):
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
x, w = rect.x, rect.width
|
||||
y = rect.y
|
||||
# Query bar — tap to reopen the keyboard and refine the search.
|
||||
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
|
||||
cy = bar.y + bar.height / 2
|
||||
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
|
||||
rl.draw_text_ex(font, self._query or tr("Search"),
|
||||
rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24), int(cy - 22)), 44, 0, rl.WHITE)
|
||||
self._tap_targets.append((bar, lambda: self._open_search(self._purpose)))
|
||||
y += SEARCH_HEIGHT + SPACING
|
||||
|
||||
list_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
|
||||
results = self._search.results()
|
||||
if self._selecting:
|
||||
self._draw_center_note(list_rect, self._status_msg or tr("Locating..."))
|
||||
return
|
||||
if not results:
|
||||
note = tr("Searching...") if self._search.searching else (self._status_msg or tr("No results"))
|
||||
self._draw_center_note(list_rect, note)
|
||||
return
|
||||
for r in results:
|
||||
if y + ROW_HEIGHT > list_rect.y + list_rect.height:
|
||||
break
|
||||
dist = f"{r.distance_m / 1609.34:.1f} mi" if r.distance_m else ""
|
||||
sub = f"{r.address} · {dist}" if dist else r.address
|
||||
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._pin_icon, r.name, sub,
|
||||
(lambda r=r: self._select_result(r)))
|
||||
y += ROW_HEIGHT + ROW_GAP
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
for rect, cb in self._tap_targets:
|
||||
if rl.check_collision_point_rec(mouse_pos, rect):
|
||||
cb()
|
||||
return
|
||||
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal file
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from enum import IntEnum
|
||||
|
||||
import pyray as rl
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from iqpilot.system.ui.widgets.label import Label
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.version import terms_version
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class OnboardingState(IntEnum):
|
||||
TERMS = 0
|
||||
DECLINE = 1
|
||||
|
||||
|
||||
class TermsPage(Widget):
|
||||
def __init__(self, on_accept=None, on_decline=None):
|
||||
super().__init__()
|
||||
self._on_accept = on_accept
|
||||
self._on_decline = on_decline
|
||||
|
||||
self._title = Label(tr("Welcome to IQ.Pilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
self._desc = Label(tr("You must accept the Terms of Service to use IQ.Pilot. Read the latest terms before continuing at https://iqlvbs.com/tos."),
|
||||
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
|
||||
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
|
||||
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
|
||||
|
||||
def _render(self, _):
|
||||
welcome_x = self._rect.x + 95
|
||||
welcome_y = self._rect.y + 165
|
||||
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
|
||||
self._title.render(welcome_rect)
|
||||
|
||||
desc_x = welcome_x
|
||||
# TODO: Label doesn't top align when wrapping
|
||||
desc_y = welcome_y - 100
|
||||
desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250)
|
||||
self._desc.render(desc_rect)
|
||||
|
||||
btn_y = self._rect.y + self._rect.height - 160 - 45
|
||||
btn_width = (self._rect.width - 45 * 3) / 2
|
||||
self._decline_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
|
||||
self._accept_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(welcome_rect, 3, rl.RED)
|
||||
rl.draw_rectangle_lines_ex(desc_rect, 3, rl.RED)
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
class DeclinePage(Widget):
|
||||
def __init__(self, back_callback=None):
|
||||
super().__init__()
|
||||
self._text = Label(tr("You must accept the Terms of Service in order to use IQ.Pilot."),
|
||||
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
self._back_btn = Button(tr("Back"), click_callback=back_callback)
|
||||
self._uninstall_btn = Button(tr("Decline, uninstall IQ.Pilot"), button_style=ButtonStyle.DANGER,
|
||||
click_callback=self._on_uninstall_clicked)
|
||||
|
||||
def _on_uninstall_clicked(self):
|
||||
ui_state.params.put_bool("DoUninstall", True)
|
||||
gui_app.request_close()
|
||||
|
||||
def _render(self, _):
|
||||
btn_y = self._rect.y + self._rect.height - 160 - 45
|
||||
btn_width = (self._rect.width - 45 * 3) / 2
|
||||
self._back_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
|
||||
self._uninstall_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
|
||||
|
||||
# text rect in middle of top and button
|
||||
text_height = btn_y - (200 + 45)
|
||||
text_rect = rl.Rectangle(self._rect.x + 165, self._rect.y + (btn_y - text_height) / 2 + 10, self._rect.width - (165 * 2), text_height)
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_rect, 3, rl.RED)
|
||||
self._text.render(text_rect)
|
||||
|
||||
|
||||
class OnboardingWindow(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
|
||||
self._state = OnboardingState.TERMS
|
||||
|
||||
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
|
||||
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self._accepted_terms
|
||||
|
||||
def _on_terms_declined(self):
|
||||
self._state = OnboardingState.DECLINE
|
||||
|
||||
def _on_decline_back(self):
|
||||
self._state = OnboardingState.TERMS
|
||||
|
||||
def _on_terms_accepted(self):
|
||||
ui_state.params.put("HasAcceptedTerms", terms_version)
|
||||
self._accepted_terms = True
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def _render(self, _):
|
||||
if self._state == OnboardingState.TERMS:
|
||||
self._terms.render(self._rect)
|
||||
elif self._state == OnboardingState.DECLINE:
|
||||
self._decline_page.render(self._rect)
|
||||
return -1
|
||||
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal file
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import threading
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
|
||||
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes, CAMERA_LABELS, format_local_time
|
||||
from iqpilot.selfdrive.ui.lib import cloud_routes_shim as cloud
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
MARGIN = 40
|
||||
SPACING = 25
|
||||
ROW_HEIGHT = 156
|
||||
ROW_GAP = 22
|
||||
BTN_SIZE = 120
|
||||
|
||||
ROW_BG = rl.Color(38, 40, 46, 255)
|
||||
ROW_BORDER = rl.Color(255, 255, 255, 26)
|
||||
PLAY_BG = rl.Color(16, 185, 169, 255)
|
||||
SUBTEXT = rl.Color(158, 162, 170, 255)
|
||||
|
||||
# Upload-status badge colors.
|
||||
BADGE_UPLOADED = rl.Color(16, 185, 129, 255)
|
||||
BADGE_UPLOADING = rl.Color(234, 179, 8, 255)
|
||||
BADGE_CLOUD = rl.Color(96, 132, 214, 255)
|
||||
BADGE_LOCAL = rl.Color(90, 96, 108, 255)
|
||||
BADGE_TEXT = rl.Color(10, 14, 18, 255)
|
||||
|
||||
|
||||
class RoutesLayout(Widget):
|
||||
"""Offroad Routes screen: local recorded routes + konn3kt cloud upload status / cloud-only routes."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._header = self._child(ScreenHeader(lambda: tr("Routes")))
|
||||
self._play_icon = gui_app.texture("icons/iq/play.png", 56, 56, keep_aspect_ratio=True)
|
||||
self._on_play: Callable[[str], None] | None = None
|
||||
self._scroll_panel = GuiScrollPanel()
|
||||
self._entries: list = []
|
||||
self._row_hitboxes: list[tuple[rl.Rectangle, object]] = []
|
||||
self._cloud_thread: threading.Thread | None = None
|
||||
self._cloud_generation = 0
|
||||
|
||||
def set_on_back(self, cb: Callable[[], None]) -> None:
|
||||
self._header.set_on_back(cb)
|
||||
|
||||
def set_on_play(self, cb: Callable[[str], None]) -> None:
|
||||
self._on_play = cb
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
local_routes = list_local_routes()
|
||||
# Show local routes immediately, then fold in cloud upload status / cloud-only routes async.
|
||||
self._entries = cloud.merge_routes(local_routes, [])
|
||||
self._start_cloud_fetch(local_routes)
|
||||
|
||||
def _start_cloud_fetch(self, local_routes: list) -> None:
|
||||
if not cloud.cloud_available():
|
||||
return
|
||||
self._cloud_generation += 1
|
||||
generation = self._cloud_generation
|
||||
|
||||
def _worker():
|
||||
dongle_id = cloud.get_dongle_id()
|
||||
cloud_routes = cloud.list_cloud_routes(dongle_id) if dongle_id else []
|
||||
if generation == self._cloud_generation:
|
||||
self._entries = cloud.merge_routes(local_routes, cloud_routes)
|
||||
|
||||
self._cloud_thread = threading.Thread(target=_worker, daemon=True)
|
||||
self._cloud_thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _entry_title(entry) -> str:
|
||||
if entry.local is not None:
|
||||
return entry.local.label
|
||||
if entry.cloud is not None and entry.cloud.start_time > 0:
|
||||
return format_local_time(entry.cloud.start_time)
|
||||
return entry.name
|
||||
|
||||
@staticmethod
|
||||
def _fmt_duration(seconds: float) -> str:
|
||||
s = max(0, int(round(seconds)))
|
||||
h, rem = divmod(s, 3600)
|
||||
m, sec = divmod(rem, 60)
|
||||
return f"{h}h {m:02d}m" if h else f"{m}:{sec:02d}"
|
||||
|
||||
def _entry_subtitle_parts(self, entry) -> list[str]:
|
||||
if entry.local is not None:
|
||||
parts = [self._fmt_duration(entry.local.duration_s)]
|
||||
cams = ", ".join(CAMERA_LABELS.get(c, c).replace(" Cam", "") for c in entry.local.cameras)
|
||||
if cams:
|
||||
parts.append(cams)
|
||||
return parts
|
||||
if entry.cloud is not None:
|
||||
parts = []
|
||||
if entry.cloud.length_miles > 0:
|
||||
parts.append(f"{entry.cloud.length_miles:.1f} mi")
|
||||
parts.append(tr("Cloud only"))
|
||||
return parts
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _draw_dotted(font, parts: list[str], x: float, y: float, size: int, color) -> None:
|
||||
cx = x
|
||||
for i, part in enumerate(parts):
|
||||
if i > 0:
|
||||
cx += 10
|
||||
rl.draw_circle(int(cx), int(y + size / 2), 3, rl.Color(color.r, color.g, color.b, 150))
|
||||
cx += 16
|
||||
rl.draw_text_ex(font, part, rl.Vector2(int(cx), int(y)), size, 0, color)
|
||||
cx += measure_text_cached(font, part, size).x
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
|
||||
self._header.render(header_rect)
|
||||
|
||||
x = rect.x + MARGIN
|
||||
w = rect.width - 2 * MARGIN
|
||||
list_top = header_rect.y + HEADER_HEIGHT + SPACING
|
||||
list_rect = rl.Rectangle(x, list_top, w, rect.y + rect.height - list_top - MARGIN)
|
||||
|
||||
self._row_hitboxes = []
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
if not self._entries:
|
||||
note = tr("No routes recorded")
|
||||
ns = measure_text_cached(font, note, 44)
|
||||
rl.draw_text_ex(font, note, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2), int(list_top + 80)), 44, 0,
|
||||
rl.Color(150, 150, 155, 255))
|
||||
return
|
||||
|
||||
row_stride = ROW_HEIGHT + ROW_GAP
|
||||
content_rect = rl.Rectangle(list_rect.x, list_rect.y, list_rect.width, len(self._entries) * row_stride)
|
||||
offset = self._scroll_panel.update(list_rect, content_rect)
|
||||
|
||||
rl.begin_scissor_mode(int(list_rect.x), int(list_rect.y), int(list_rect.width), int(list_rect.height))
|
||||
for i, entry in enumerate(self._entries):
|
||||
ry = list_rect.y + i * row_stride + offset
|
||||
row = rl.Rectangle(x, ry, w, ROW_HEIGHT)
|
||||
if not rl.check_collision_recs(row, list_rect):
|
||||
continue
|
||||
|
||||
rl.draw_rectangle_rounded(row, 0.3, 20, ROW_BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(row, 0.3, 20, 2, ROW_BORDER)
|
||||
|
||||
title_size = 46
|
||||
subtitle_size = 30
|
||||
title = self._entry_title(entry)
|
||||
subtitle_parts = self._entry_subtitle_parts(entry)
|
||||
title_ts = measure_text_cached(font, title, title_size)
|
||||
text_y = ry + (ROW_HEIGHT - title_ts.y - subtitle_size - 10) / 2
|
||||
rl.draw_text_ex(font, title, rl.Vector2(int(x + 40), int(text_y)), title_size, 0, rl.WHITE)
|
||||
self._draw_dotted(font, subtitle_parts, x + 40, text_y + title_ts.y + 10, subtitle_size, SUBTEXT)
|
||||
|
||||
cy = ry + ROW_HEIGHT / 2
|
||||
self._draw_status_badge(entry, x + w - 40 - BTN_SIZE - 24, cy, font)
|
||||
|
||||
play_rect = rl.Rectangle(x + w - 40 - BTN_SIZE, cy - BTN_SIZE / 2, BTN_SIZE, BTN_SIZE)
|
||||
rl.draw_circle(int(play_rect.x + BTN_SIZE / 2), int(cy), BTN_SIZE / 2, PLAY_BG)
|
||||
rl.draw_texture(self._play_icon, int(play_rect.x + (BTN_SIZE - self._play_icon.width) / 2 + 4),
|
||||
int(cy - self._play_icon.height / 2), rl.Color(8, 16, 16, 255))
|
||||
|
||||
self._row_hitboxes.append((play_rect, entry))
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_status_badge(self, entry, right_x: float, cy: float, font) -> None:
|
||||
if entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADED:
|
||||
label, color = tr("Uploaded"), BADGE_UPLOADED
|
||||
elif entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADING:
|
||||
label, color = tr("Uploading"), BADGE_UPLOADING
|
||||
elif not entry.is_local and entry.is_cloud:
|
||||
label, color = tr("Cloud"), BADGE_CLOUD
|
||||
elif entry.is_local:
|
||||
label, color = tr("On device"), BADGE_LOCAL
|
||||
else:
|
||||
return
|
||||
|
||||
fs = 24
|
||||
pad = 18
|
||||
tw = measure_text_cached(font, label, fs).x + pad * 2
|
||||
badge = rl.Rectangle(right_x - tw, cy - 20, tw, 40)
|
||||
rl.draw_rectangle_rounded(badge, 0.5, 12, color)
|
||||
rl.draw_text_ex(font, label, rl.Vector2(int(badge.x + pad), int(cy - fs / 2)), fs, 0, BADGE_TEXT)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if not self._scroll_panel.is_touch_valid():
|
||||
return
|
||||
for play_rect, entry in self._row_hitboxes:
|
||||
if rl.check_collision_point_rec(mouse_pos, play_rect):
|
||||
if self._on_play:
|
||||
self._on_play(entry.name)
|
||||
return
|
||||
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal file
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
def restart_needed_callback(_=None):
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal file
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.hardware.tici.usb_storage import apply_usb_storage_state
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.list_view import toggle_item
|
||||
from iqpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
|
||||
|
||||
# Description constants
|
||||
DESCRIPTIONS = {
|
||||
'enable_adb': tr_noop(
|
||||
"ADB (Android Debug Bridge) allows connecting to your device over USB or over the network."
|
||||
),
|
||||
'ssh_key': tr_noop(
|
||||
"Warning: This grants SSH access to all public keys in your GitHub settings. Never enter a GitHub username " +
|
||||
"other than your own. An IQ.Pilot employee will NEVER ask you to add their GitHub username."
|
||||
),
|
||||
'usb_storage': tr_noop(
|
||||
"Exposes a snapshot of recent dashcam clips and logs as a USB drive when connected to a computer. " +
|
||||
"IQ.Pilot keeps running while this is enabled."
|
||||
),
|
||||
'long_maneuver': tr_noop(
|
||||
"Commands a scripted sequence of acceleration steps to measure longitudinal actuator response. " +
|
||||
"Requires IQ.Pilot longitudinal control. Only use on a clear, closed road."
|
||||
),
|
||||
'lat_maneuver': tr_noop(
|
||||
"Commands a scripted sequence of lateral acceleration steps to measure steering actuator response. " +
|
||||
"Only use on a straight, flat, clear road."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DeveloperLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# Build items and keep references for callbacks/state updates
|
||||
self._adb_toggle = toggle_item(
|
||||
lambda: tr("Enable ADB"),
|
||||
description=lambda: tr(DESCRIPTIONS["enable_adb"]),
|
||||
initial_state=self._params.get_bool("AdbEnabled"),
|
||||
callback=self._on_enable_adb,
|
||||
enabled=ui_state.is_offroad,
|
||||
)
|
||||
|
||||
self._usb_storage_toggle = toggle_item(
|
||||
lambda: tr("USB Storage"),
|
||||
description=lambda: tr(DESCRIPTIONS["usb_storage"]),
|
||||
initial_state=self._params.get_bool("UsbStorageEnabled"),
|
||||
callback=self._on_enable_usb_storage,
|
||||
enabled=ui_state.is_offroad,
|
||||
)
|
||||
|
||||
# SSH enable toggle + SSH key management
|
||||
self._ssh_toggle = toggle_item(
|
||||
lambda: tr("Enable SSH"),
|
||||
description="",
|
||||
initial_state=self._params.get_bool("SshEnabled"),
|
||||
callback=self._on_enable_ssh,
|
||||
)
|
||||
self._ssh_keys = ssh_key_item(lambda: tr("SSH Keys"), description=lambda: tr(DESCRIPTIONS["ssh_key"]))
|
||||
|
||||
self._long_maneuver_toggle = toggle_item(
|
||||
lambda: tr("Longitudinal Maneuver Mode"),
|
||||
description=lambda: tr(DESCRIPTIONS["long_maneuver"]),
|
||||
initial_state=self._params.get_bool("LongitudinalManeuverMode"),
|
||||
callback=self._on_long_maneuver_mode,
|
||||
)
|
||||
|
||||
self._lat_maneuver_toggle = toggle_item(
|
||||
lambda: tr("Lateral Maneuver Mode"),
|
||||
description=lambda: tr(DESCRIPTIONS["lat_maneuver"]),
|
||||
initial_state=self._params.get_bool("LateralManeuverMode"),
|
||||
callback=self._on_lat_maneuver_mode,
|
||||
)
|
||||
|
||||
self._on_enable_ui_debug(self._params.get_bool("ShowDebugInfo"))
|
||||
|
||||
self._scroller = Scroller([
|
||||
self._adb_toggle,
|
||||
self._usb_storage_toggle,
|
||||
self._ssh_toggle,
|
||||
self._ssh_keys,
|
||||
self._long_maneuver_toggle,
|
||||
self._lat_maneuver_toggle,
|
||||
], line_separator=True, spacing=0)
|
||||
|
||||
# Toggles should be not available to change in onroad state
|
||||
ui_state.add_offroad_transition_callback(self._update_toggles)
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def show_event(self):
|
||||
self._scroller.show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
|
||||
for item in (self._long_maneuver_toggle, self._lat_maneuver_toggle):
|
||||
item.set_visible(not self._is_release)
|
||||
|
||||
if ui_state.CP is not None:
|
||||
self._long_maneuver_toggle.action_item.set_enabled(ui_state.has_longitudinal_control and ui_state.is_offroad())
|
||||
self._lat_maneuver_toggle.action_item.set_enabled(ui_state.is_offroad())
|
||||
else:
|
||||
self._long_maneuver_toggle.action_item.set_enabled(False)
|
||||
self._lat_maneuver_toggle.action_item.set_enabled(False)
|
||||
|
||||
# TODO: make a param control list item so we don't need to manage internal state as much here
|
||||
# refresh toggles from params to mirror external changes
|
||||
for key, item in (
|
||||
("AdbEnabled", self._adb_toggle),
|
||||
("UsbStorageEnabled", self._usb_storage_toggle),
|
||||
("SshEnabled", self._ssh_toggle),
|
||||
("LongitudinalManeuverMode", self._long_maneuver_toggle),
|
||||
("LateralManeuverMode", self._lat_maneuver_toggle),
|
||||
):
|
||||
item.action_item.set_state(self._params.get_bool(key))
|
||||
|
||||
def _on_enable_ui_debug(self, state: bool):
|
||||
self._params.put_bool("ShowDebugInfo", state)
|
||||
gui_app.set_show_touches(state)
|
||||
gui_app.set_show_fps(state)
|
||||
gui_app.set_show_mouse_coords(state)
|
||||
|
||||
def _on_enable_adb(self, state: bool):
|
||||
self._params.put_bool("AdbEnabled", state)
|
||||
|
||||
def _on_enable_usb_storage(self, state: bool):
|
||||
apply_usb_storage_state(state)
|
||||
|
||||
def _on_enable_ssh(self, state: bool):
|
||||
self._params.put_bool("SshEnabled", state)
|
||||
|
||||
def _on_long_maneuver_mode(self, state: bool):
|
||||
self._params.put_bool("LongitudinalManeuverMode", state)
|
||||
self._params.put_bool("JoystickDebugMode", False)
|
||||
self._params.put_bool("LateralManeuverMode", False)
|
||||
self._lat_maneuver_toggle.action_item.set_state(False)
|
||||
|
||||
def _on_lat_maneuver_mode(self, state: bool):
|
||||
self._params.put_bool("LateralManeuverMode", state)
|
||||
self._params.put_bool("JoystickDebugMode", False)
|
||||
self._params.put_bool("ExperimentalMode", False)
|
||||
self._params.put_bool("LongitudinalManeuverMode", False)
|
||||
self._long_maneuver_toggle.action_item.set_state(False)
|
||||
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal file
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import os
|
||||
import math
|
||||
|
||||
from iqpilot.cereal import messaging, log
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
|
||||
from iqpilot.konn3kt.registration import get_cached_dongle_id
|
||||
from iqpilot.system.hardware import TICI
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.lib.multilang import multilang, tr, tr_noop
|
||||
from iqpilot.system.ui.widgets import Widget, DialogResult
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
|
||||
from iqpilot.system.ui.widgets.html_render import HtmlModal
|
||||
from iqpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item
|
||||
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
from iqpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
|
||||
|
||||
# Description constants
|
||||
DESCRIPTIONS = {
|
||||
'pair_device': tr_noop("Pair your device in the Konn3kt app."),
|
||||
'driver_camera': tr_noop("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"),
|
||||
'reset_calibration': tr_noop("IQ.Pilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."),
|
||||
}
|
||||
|
||||
|
||||
class DeviceLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._params = Params()
|
||||
self._select_language_dialog: MultiOptionDialog | None = None
|
||||
self._driver_camera: DriverCameraDialog | None = None
|
||||
self._pair_device_dialog: PairingDialog | None = None
|
||||
self._fcc_dialog: HtmlModal | None = None
|
||||
|
||||
items = self._initialize_items()
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
|
||||
ui_state.add_offroad_transition_callback(self._offroad_transition)
|
||||
|
||||
def _initialize_items(self):
|
||||
self._pair_device_btn = button_item(lambda: tr("Pair Device"), lambda: tr("PAIR"), lambda: tr(DESCRIPTIONS['pair_device']), callback=self._pair_device)
|
||||
self._pair_device_btn.set_visible(lambda: not ui_state.prime_state.is_paired())
|
||||
|
||||
self._reset_calib_btn = button_item(lambda: tr("Reset Calibration"), lambda: tr("RESET"), lambda: tr(DESCRIPTIONS['reset_calibration']),
|
||||
callback=self._reset_calibration_prompt)
|
||||
self._reset_calib_btn.set_description_opened_callback(self._update_calib_description)
|
||||
|
||||
self._power_off_btn = dual_button_item(lambda: tr("Reboot"), lambda: tr("Power Off"),
|
||||
left_callback=self._reboot_prompt, right_callback=self._power_off_prompt)
|
||||
|
||||
items = [
|
||||
text_item(lambda: tr("Dongle ID"), lambda: get_cached_dongle_id(self._params, prefer_readonly=True) or tr("N/A")),
|
||||
text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))),
|
||||
self._pair_device_btn,
|
||||
button_item(lambda: tr("Driver Camera"), lambda: tr("PREVIEW"), lambda: tr(DESCRIPTIONS['driver_camera']),
|
||||
callback=self._show_driver_camera, enabled=ui_state.is_offroad),
|
||||
self._reset_calib_btn,
|
||||
regulatory_btn := button_item(lambda: tr("Regulatory"), lambda: tr("VIEW"), callback=self._on_regulatory, enabled=ui_state.is_offroad),
|
||||
button_item(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog),
|
||||
self._power_off_btn,
|
||||
]
|
||||
regulatory_btn.set_visible(TICI)
|
||||
return items
|
||||
|
||||
def _offroad_transition(self):
|
||||
self._power_off_btn.action_item.right_button.set_visible(ui_state.is_offroad())
|
||||
|
||||
def show_event(self):
|
||||
self._scroller.show_event()
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def _show_language_dialog(self):
|
||||
def handle_language_selection(result: int):
|
||||
if result == 1 and self._select_language_dialog:
|
||||
selected_language = multilang.languages[self._select_language_dialog.selection]
|
||||
multilang.change_language(selected_language)
|
||||
self._update_calib_description()
|
||||
self._select_language_dialog = None
|
||||
|
||||
self._select_language_dialog = MultiOptionDialog(tr("Select a language"), multilang.languages, multilang.codes[multilang.language],
|
||||
option_font_weight=FontWeight.UNIFONT)
|
||||
gui_app.set_modal_overlay(self._select_language_dialog, callback=handle_language_selection)
|
||||
|
||||
def _show_driver_camera(self):
|
||||
if not self._driver_camera:
|
||||
self._driver_camera = DriverCameraDialog()
|
||||
|
||||
gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
|
||||
|
||||
def _reset_calibration_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reset Calibration")))
|
||||
return
|
||||
|
||||
def reset_calibration(result: int):
|
||||
# Check engaged again in case it changed while the dialog was open
|
||||
if ui_state.engaged or result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
self._params.remove("CalibrationParams")
|
||||
self._params.remove("LiveTorqueParameters")
|
||||
self._params.remove("LiveParameters")
|
||||
self._params.remove("LiveParametersV2")
|
||||
self._params.remove("LiveDelay")
|
||||
self._params.put_bool("OnroadCycleRequested", True)
|
||||
self._update_calib_description()
|
||||
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to reset calibration?"), tr("Reset"))
|
||||
gui_app.set_modal_overlay(dialog, callback=reset_calibration)
|
||||
|
||||
def _update_calib_description(self):
|
||||
desc = tr(DESCRIPTIONS['reset_calibration'])
|
||||
|
||||
calib_bytes = self._params.get("CalibrationParams")
|
||||
if calib_bytes:
|
||||
try:
|
||||
calib = messaging.log_from_bytes(calib_bytes, log.Event).extrinsicsCalibration
|
||||
|
||||
if calib.calStatus != log.ExtrinsicsCalibration.Status.uncalibrated:
|
||||
pitch = math.degrees(calib.rpyCalib[1])
|
||||
yaw = math.degrees(calib.rpyCalib[2])
|
||||
desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"),
|
||||
abs(yaw), tr("left") if yaw > 0 else tr("right"))
|
||||
except Exception:
|
||||
cloudlog.exception("invalid CalibrationParams")
|
||||
|
||||
lag_perc = 0
|
||||
lag_bytes = self._params.get("LiveDelay")
|
||||
if lag_bytes:
|
||||
try:
|
||||
lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).lateralDelay.calPerc
|
||||
except Exception:
|
||||
cloudlog.exception("invalid LiveDelay")
|
||||
if lag_perc < 100:
|
||||
desc += tr("<br><br>Steering lag calibration is {}% complete.").format(lag_perc)
|
||||
else:
|
||||
desc += tr("<br><br>Steering lag calibration is complete.")
|
||||
|
||||
torque_bytes = self._params.get("LiveTorqueParameters")
|
||||
if torque_bytes:
|
||||
try:
|
||||
torque = messaging.log_from_bytes(torque_bytes, log.Event).lateralTorqueParameters
|
||||
# don't add for non-torque cars
|
||||
if torque.useParams:
|
||||
torque_perc = torque.calPerc
|
||||
if torque_perc < 100:
|
||||
desc += tr(" Steering torque response calibration is {}% complete.").format(torque_perc)
|
||||
else:
|
||||
desc += tr(" Steering torque response calibration is complete.")
|
||||
except Exception:
|
||||
cloudlog.exception("invalid LiveTorqueParameters")
|
||||
|
||||
desc += "<br><br>"
|
||||
desc += tr("IQ.Pilot is continuously calibrating, resetting is rarely required. " +
|
||||
"Resetting calibration will restart IQ.Pilot if the car is powered on.")
|
||||
|
||||
self._reset_calib_btn.set_description(desc)
|
||||
|
||||
def _reboot_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
|
||||
return
|
||||
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
|
||||
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
|
||||
|
||||
def _perform_reboot(self, result: int):
|
||||
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
||||
self._params.put_bool_nonblocking("DoReboot", True)
|
||||
|
||||
def _power_off_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
|
||||
return
|
||||
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
|
||||
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
|
||||
|
||||
def _perform_power_off(self, result: int):
|
||||
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
||||
self._params.put_bool_nonblocking("DoShutdown", True)
|
||||
|
||||
def _pair_device(self):
|
||||
if not self._pair_device_dialog:
|
||||
self._pair_device_dialog = PairingDialog()
|
||||
gui_app.set_modal_overlay(self._pair_device_dialog, callback=lambda result: setattr(self, '_pair_device_dialog', None))
|
||||
|
||||
def _on_regulatory(self):
|
||||
if not self._fcc_dialog:
|
||||
self._fcc_dialog = HtmlModal(os.path.join(BASEDIR, "iqpilot/selfdrive/assets/offroad/fcc.html"))
|
||||
gui_app.set_modal_overlay(self._fcc_dialog)
|
||||
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal file
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
from iqpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wifi_manager import WifiManager
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.network import NetworkUI
|
||||
|
||||
# Constants
|
||||
SIDEBAR_WIDTH = 500
|
||||
CLOSE_BTN_SIZE = 200
|
||||
CLOSE_ICON_SIZE = 70
|
||||
NAV_BTN_HEIGHT = 110
|
||||
PANEL_MARGIN = 50
|
||||
|
||||
# Colors
|
||||
SIDEBAR_COLOR = rl.BLACK
|
||||
PANEL_COLOR = rl.Color(41, 41, 41, 255)
|
||||
CLOSE_BTN_COLOR = rl.Color(41, 41, 41, 255)
|
||||
CLOSE_BTN_PRESSED = rl.Color(59, 59, 59, 255)
|
||||
TEXT_NORMAL = rl.Color(128, 128, 128, 255)
|
||||
TEXT_SELECTED = rl.WHITE
|
||||
|
||||
|
||||
class PanelType(IntEnum):
|
||||
DEVICE = 0
|
||||
NETWORK = 1
|
||||
TOGGLES = 2
|
||||
SOFTWARE = 3
|
||||
DEVELOPER = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelInfo:
|
||||
name: str
|
||||
instance: Widget
|
||||
button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
|
||||
class SettingsLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._current_panel = PanelType.DEVICE
|
||||
|
||||
# Panel configuration
|
||||
wifi_manager = WifiManager()
|
||||
wifi_manager.set_active(False)
|
||||
|
||||
self._panels = {
|
||||
PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayout()),
|
||||
PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager)),
|
||||
PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout()),
|
||||
PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout()),
|
||||
PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout()),
|
||||
}
|
||||
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
self._close_icon = gui_app.texture("icons/close2.png", CLOSE_ICON_SIZE, CLOSE_ICON_SIZE)
|
||||
|
||||
# Callbacks
|
||||
self._close_callback: Callable | None = None
|
||||
|
||||
def set_callbacks(self, on_close: Callable):
|
||||
self._close_callback = on_close
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# Calculate layout
|
||||
sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
|
||||
panel_rect = rl.Rectangle(rect.x + SIDEBAR_WIDTH, rect.y, rect.width - SIDEBAR_WIDTH, rect.height)
|
||||
|
||||
# Draw components
|
||||
self._draw_sidebar(sidebar_rect)
|
||||
self._draw_current_panel(panel_rect)
|
||||
|
||||
def _draw_sidebar(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rec(rect, SIDEBAR_COLOR)
|
||||
|
||||
# Close button
|
||||
close_btn_rect = rl.Rectangle(
|
||||
rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 60, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE
|
||||
)
|
||||
|
||||
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
|
||||
rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect))
|
||||
close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR
|
||||
rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color)
|
||||
|
||||
icon_color = rl.Color(255, 255, 255, 255) if not pressed else rl.Color(220, 220, 220, 255)
|
||||
icon_dest = rl.Rectangle(
|
||||
close_btn_rect.x + (close_btn_rect.width - self._close_icon.width) / 2,
|
||||
close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2,
|
||||
self._close_icon.width,
|
||||
self._close_icon.height,
|
||||
)
|
||||
rl.draw_texture_pro(
|
||||
self._close_icon,
|
||||
rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height),
|
||||
icon_dest,
|
||||
rl.Vector2(0, 0),
|
||||
0,
|
||||
icon_color,
|
||||
)
|
||||
|
||||
# Store close button rect for click detection
|
||||
self._close_btn_rect = close_btn_rect
|
||||
|
||||
# Navigation buttons
|
||||
y = rect.y + 300
|
||||
for panel_type, panel_info in self._panels.items():
|
||||
button_rect = rl.Rectangle(rect.x + 50, y, rect.width - 150, NAV_BTN_HEIGHT)
|
||||
|
||||
# Button styling
|
||||
is_selected = panel_type == self._current_panel
|
||||
text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL
|
||||
# Draw button text (right-aligned)
|
||||
panel_name = tr(panel_info.name)
|
||||
text_size = measure_text_cached(self._font_medium, panel_name, 65)
|
||||
text_pos = rl.Vector2(
|
||||
button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2
|
||||
)
|
||||
rl.draw_text_ex(self._font_medium, panel_name, text_pos, 65, 0, text_color)
|
||||
|
||||
# Store button rect for click detection
|
||||
panel_info.button_rect = button_rect
|
||||
|
||||
y += NAV_BTN_HEIGHT
|
||||
|
||||
def _draw_current_panel(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rounded(
|
||||
rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR
|
||||
)
|
||||
content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50)
|
||||
# rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR)
|
||||
panel = self._panels[self._current_panel]
|
||||
if panel.instance:
|
||||
panel.instance.render(content_rect)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
# Check close button
|
||||
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
|
||||
if self._close_callback:
|
||||
self._close_callback()
|
||||
return
|
||||
|
||||
# Check navigation buttons
|
||||
for panel_type, panel_info in self._panels.items():
|
||||
if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect):
|
||||
self.set_current_panel(panel_type)
|
||||
return
|
||||
|
||||
def set_current_panel(self, panel_type: PanelType):
|
||||
if panel_type != self._current_panel:
|
||||
self._panels[self._current_panel].instance.hide_event()
|
||||
self._current_panel = panel_type
|
||||
self._panels[self._current_panel].instance.show_event()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._panels[self._current_panel].instance.show_event()
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
self._panels[self._current_panel].instance.hide_event()
|
||||
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal file
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal file
@@ -0,0 +1,262 @@
|
||||
import os
|
||||
import time
|
||||
import datetime
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.multilang import tr, trn
|
||||
from iqpilot.system.ui.widgets import Widget, DialogResult
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from iqpilot.system.ui.widgets.list_view import button_item, text_item, ListItem
|
||||
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
from iqpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
|
||||
|
||||
# TODO: remove this. updater fails to respond on startup if time is not correct
|
||||
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
|
||||
BRAND_NAME = "IQ.Pilot"
|
||||
|
||||
# Mapping updater internal states to translated display strings
|
||||
STATE_TO_DISPLAY_TEXT = {
|
||||
"checking...": tr("checking..."),
|
||||
"downloading...": tr("downloading..."),
|
||||
"finalizing update...": tr("finalizing update..."),
|
||||
}
|
||||
|
||||
|
||||
def format_updater_description(description: str | None) -> str:
|
||||
if not description:
|
||||
return BRAND_NAME
|
||||
|
||||
cleaned = description.strip()
|
||||
lower = cleaned.lower()
|
||||
if lower.startswith("iqpilot"):
|
||||
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
|
||||
|
||||
if cleaned.lower().startswith(BRAND_NAME.lower()):
|
||||
return cleaned
|
||||
return f"{BRAND_NAME} {cleaned}" if cleaned else BRAND_NAME
|
||||
|
||||
|
||||
def time_ago(date: datetime.datetime | None) -> str:
|
||||
if not date:
|
||||
return tr("never")
|
||||
|
||||
if not system_time_valid():
|
||||
return date.strftime("%a %b %d %Y")
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
if date.tzinfo is None:
|
||||
date = date.replace(tzinfo=datetime.UTC)
|
||||
|
||||
diff_seconds = int((now - date).total_seconds())
|
||||
if diff_seconds < 60:
|
||||
return tr("now")
|
||||
if diff_seconds < 3600:
|
||||
m = diff_seconds // 60
|
||||
return trn("{} minute ago", "{} minutes ago", m).format(m)
|
||||
if diff_seconds < 86400:
|
||||
h = diff_seconds // 3600
|
||||
return trn("{} hour ago", "{} hours ago", h).format(h)
|
||||
if diff_seconds < 604800:
|
||||
d = diff_seconds // 86400
|
||||
return trn("{} day ago", "{} days ago", d).format(d)
|
||||
return date.strftime("%a %b %d %Y")
|
||||
|
||||
|
||||
class SoftwareLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._onroad_label = ListItem(lambda: tr("Updates are only downloaded while the car is off."))
|
||||
self._version_item = text_item(lambda: tr("Current Version"), format_updater_description(ui_state.params.get("UpdaterCurrentDescription")))
|
||||
self._download_btn = button_item(lambda: tr("Download"), lambda: tr("CHECK"), callback=self._on_download_update)
|
||||
|
||||
# Install button is initially hidden
|
||||
self._install_btn = button_item(lambda: tr("Install Update"), lambda: tr("INSTALL"), callback=self._on_install_update)
|
||||
self._install_btn.set_visible(False)
|
||||
|
||||
# Track waiting-for-updater transition to avoid brief re-enable while still idle
|
||||
self._waiting_for_updater = False
|
||||
self._waiting_start_ts: float = 0.0
|
||||
|
||||
# Branch switcher
|
||||
self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch)
|
||||
self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
|
||||
self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "")
|
||||
self._branch_dialog: MultiOptionDialog | None = None
|
||||
|
||||
# Git auth for private-branch updates (sits with the Target Branch section)
|
||||
self._auth_btn = button_item(lambda: tr("Git Auth"), lambda: tr("AUTH"), callback=self._on_auth_branch)
|
||||
self._auth_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
|
||||
|
||||
self._scroller = Scroller([
|
||||
self._onroad_label,
|
||||
self._version_item,
|
||||
self._download_btn,
|
||||
self._install_btn,
|
||||
self._branch_btn,
|
||||
self._auth_btn,
|
||||
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall),
|
||||
], line_separator=True, spacing=0)
|
||||
|
||||
def show_event(self):
|
||||
self._refresh_auth_value()
|
||||
self._scroller.show_event()
|
||||
|
||||
def _refresh_auth_value(self):
|
||||
try:
|
||||
from iqpilot.common.git_creds import has_credentials
|
||||
configured = has_credentials()
|
||||
except Exception:
|
||||
configured = False
|
||||
self._auth_btn.action_item.set_value(tr("configured") if configured else "")
|
||||
|
||||
def _render(self, rect):
|
||||
self._scroller.render(rect)
|
||||
|
||||
def _update_state(self):
|
||||
# Show/hide onroad warning
|
||||
self._onroad_label.set_visible(ui_state.is_onroad())
|
||||
|
||||
# Update current version and release notes
|
||||
current_desc = format_updater_description(ui_state.params.get("UpdaterCurrentDescription"))
|
||||
current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace")
|
||||
self._version_item.action_item.set_text(current_desc)
|
||||
self._version_item.set_description(current_release_notes)
|
||||
|
||||
# Update download button visibility and state
|
||||
self._download_btn.set_visible(ui_state.is_offroad())
|
||||
|
||||
updater_state = ui_state.params.get("UpdaterState") or "idle"
|
||||
failed_count = ui_state.params.get("UpdateFailedCount") or 0
|
||||
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
|
||||
update_available = ui_state.params.get_bool("UpdateAvailable")
|
||||
|
||||
if updater_state != "idle":
|
||||
# Updater responded
|
||||
self._waiting_for_updater = False
|
||||
self._download_btn.action_item.set_enabled(False)
|
||||
# Use the mapping, with a fallback to the original state string
|
||||
display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state)
|
||||
self._download_btn.action_item.set_value(display_text)
|
||||
else:
|
||||
if failed_count > 0:
|
||||
self._download_btn.action_item.set_value(tr("failed to check for update"))
|
||||
self._download_btn.action_item.set_text(tr("CHECK"))
|
||||
elif fetch_available:
|
||||
self._download_btn.action_item.set_value(tr("update available"))
|
||||
self._download_btn.action_item.set_text(tr("DOWNLOAD"))
|
||||
else:
|
||||
last_update = ui_state.params.get("LastUpdateTime")
|
||||
if last_update:
|
||||
formatted = time_ago(last_update)
|
||||
self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted))
|
||||
else:
|
||||
self._download_btn.action_item.set_value(tr("up to date, last checked never"))
|
||||
self._download_btn.action_item.set_text(tr("CHECK"))
|
||||
|
||||
# If we've been waiting too long without a state change, reset state
|
||||
if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT):
|
||||
self._waiting_for_updater = False
|
||||
|
||||
# Only enable if we're not waiting for updater to flip out of idle
|
||||
self._download_btn.action_item.set_enabled(not self._waiting_for_updater)
|
||||
|
||||
# Update target branch button value
|
||||
current_branch = ui_state.params.get("UpdaterTargetBranch") or ""
|
||||
self._branch_btn.action_item.set_value(current_branch)
|
||||
|
||||
# Update install button
|
||||
self._install_btn.set_visible(ui_state.is_offroad() and update_available)
|
||||
if update_available:
|
||||
new_desc = format_updater_description(ui_state.params.get("UpdaterNewDescription"))
|
||||
new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace")
|
||||
self._install_btn.action_item.set_text(tr("INSTALL"))
|
||||
self._install_btn.action_item.set_value(new_desc)
|
||||
self._install_btn.set_description(new_release_notes)
|
||||
# Enable install button for testing (like Qt showEvent)
|
||||
self._install_btn.action_item.set_enabled(True)
|
||||
else:
|
||||
self._install_btn.set_visible(False)
|
||||
|
||||
def _on_download_update(self):
|
||||
# Check if we should start checking or start downloading
|
||||
self._download_btn.action_item.set_enabled(False)
|
||||
if self._download_btn.action_item.text == tr("CHECK"):
|
||||
# Start checking for updates
|
||||
self._waiting_for_updater = True
|
||||
self._waiting_start_ts = time.monotonic()
|
||||
os.system("pkill -SIGUSR1 -f system.updated.updated")
|
||||
else:
|
||||
# Start downloading
|
||||
self._waiting_for_updater = True
|
||||
self._waiting_start_ts = time.monotonic()
|
||||
os.system("pkill -SIGHUP -f system.updated.updated")
|
||||
|
||||
def _on_uninstall(self):
|
||||
def handle_uninstall_confirmation(result):
|
||||
if result == DialogResult.CONFIRM:
|
||||
ui_state.params.put_bool("DoUninstall", True)
|
||||
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall"))
|
||||
gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation)
|
||||
|
||||
def _on_install_update(self):
|
||||
# Trigger reboot to install update
|
||||
self._install_btn.action_item.set_enabled(False)
|
||||
ui_state.params.put_bool("DoReboot", True)
|
||||
|
||||
def _on_select_branch(self):
|
||||
# Get available branches and order
|
||||
current_git_branch = ui_state.params.get("GitBranch") or ""
|
||||
branches_str = ui_state.params.get("UpdaterAvailableBranches") or ""
|
||||
branches = [b for b in branches_str.split(",") if b]
|
||||
|
||||
for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]:
|
||||
if b in branches:
|
||||
branches.remove(b)
|
||||
branches.insert(0, b)
|
||||
|
||||
current_target = ui_state.params.get("UpdaterTargetBranch") or ""
|
||||
self._branch_dialog = MultiOptionDialog(tr("Select a branch"), branches, current_target)
|
||||
|
||||
def handle_selection(result):
|
||||
# Confirmed selection
|
||||
if result == DialogResult.CONFIRM and self._branch_dialog is not None and self._branch_dialog.selection:
|
||||
selection = self._branch_dialog.selection
|
||||
ui_state.params.put("UpdaterTargetBranch", selection)
|
||||
self._branch_btn.action_item.set_value(selection)
|
||||
os.system("pkill -SIGUSR1 -f system.updated.updated")
|
||||
self._branch_dialog = None
|
||||
|
||||
gui_app.set_modal_overlay(self._branch_dialog, callback=handle_selection)
|
||||
|
||||
def _on_auth_branch(self):
|
||||
# Collect username then token; store encrypted and signal the updater to
|
||||
# re-check (refreshing the available-branch list for private repos).
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import open_text_prompt
|
||||
from iqpilot.common import git_creds
|
||||
|
||||
creds = git_creds.get_credentials()
|
||||
current_user = creds[0] if creds else ""
|
||||
|
||||
def on_username(result, username):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
def on_token(token_result, token):
|
||||
if token_result != DialogResult.CONFIRM:
|
||||
return
|
||||
git_creds.set_credentials(username, token)
|
||||
self._refresh_auth_value()
|
||||
os.system("pkill -SIGUSR1 -f system.updated.updated")
|
||||
|
||||
open_text_prompt(tr("Git token / password"),
|
||||
tr("leave username and token blank to clear"),
|
||||
password=True, on_done=on_token)
|
||||
|
||||
open_text_prompt(tr("Git username"), tr("for private branch updates"),
|
||||
initial=current_user, on_done=on_username)
|
||||
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal file
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal file
@@ -0,0 +1,343 @@
|
||||
from iqpilot.cereal import log
|
||||
from iqpilot.common.params import Params, UnknownKeyName
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
|
||||
from iqpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from iqpilot.system.ui.widgets import DialogResult
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
|
||||
from iqpilot.system.ui.iqwidgets.widgets.list_view import multiple_button_item
|
||||
from iqpilot.ui.layouts.settings.iq_dynamic import IQDynamicLayout
|
||||
|
||||
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
|
||||
PERSONALITY_DISPLAY_TO_PARAM = [PERSONALITY_TO_INT["relaxed"], PERSONALITY_TO_INT["standard"], PERSONALITY_TO_INT["aggressive"]]
|
||||
PERSONALITY_PARAM_TO_DISPLAY = {param: idx for idx, param in enumerate(PERSONALITY_DISPLAY_TO_PARAM)}
|
||||
|
||||
# Description constants
|
||||
DESCRIPTIONS = {
|
||||
"OpenpilotEnabledToggle": tr_noop(
|
||||
"Use the IQ.Pilot system for adaptive cruise control and lane keep driver assistance. " +
|
||||
"Your attention is required at all times to use this feature."
|
||||
),
|
||||
"DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage IQ.Pilot."),
|
||||
"LongitudinalPersonality": tr_noop(
|
||||
"Standard is recommended. In aggressive mode, IQ.Pilot will follow lead cars closer and be more aggressive with the gas and brake. " +
|
||||
"In relaxed mode IQ.Pilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
|
||||
"your steering wheel distance button."
|
||||
),
|
||||
"IQSpeedAssistMode": tr_noop(
|
||||
"Controls IQ.Pilot speed limit behavior. Off disables speed limit features, Information only displays limits, Warning highlights overspeed, and Control adjusts set speed using detected limits."
|
||||
),
|
||||
"IsLdwEnabled": tr_noop(
|
||||
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
|
||||
"without a turn signal activated while driving over 31 mph (50 km/h)."
|
||||
),
|
||||
"AlwaysOnDM": tr_noop("Enable driver monitoring even when IQ.Pilot is not engaged."),
|
||||
"DashcamEnabled": tr_noop("Record and upload driving data and video. Disabling this stops all recording! No logs, no video, no audio."),
|
||||
'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."),
|
||||
"IsMetric": tr_noop("Display speed in km/h instead of mph."),
|
||||
"IQAutoUnits": tr_noop(
|
||||
"Set the units from the device location. Speeds switch to km/h everywhere except the United States, " +
|
||||
"the United Kingdom and Liberia, and are re-checked when you cross a border."
|
||||
),
|
||||
"RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in Konn3kt."),
|
||||
"LongitudinalControlMode": tr_noop(
|
||||
"Choose longitudinal behavior: IQ.Pilot (IQ longitudinal + end-to-end), "
|
||||
"IQ.Dynamic (IQ longitudinal + dynamic mode), IQ.Chill (IQ longitudinal + relaxed personality), "
|
||||
"or Stock ACC."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TogglesLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
# Keep IQ.Pilot enabled by default; the UI no longer exposes this toggle.
|
||||
self._params.put_bool("OpenpilotEnabledToggle", True)
|
||||
|
||||
# param, title, desc, icon, needs_restart
|
||||
self._toggle_defs = {
|
||||
"DisengageOnAccelerator": (
|
||||
lambda: tr("Disengage on Accelerator Pedal"),
|
||||
DESCRIPTIONS["DisengageOnAccelerator"],
|
||||
"disengage_on_accelerator.png",
|
||||
False,
|
||||
),
|
||||
"IsLdwEnabled": (
|
||||
lambda: tr("Enable Lane Departure Warnings"),
|
||||
DESCRIPTIONS["IsLdwEnabled"],
|
||||
"warning.png",
|
||||
False,
|
||||
),
|
||||
"DashcamEnabled": (
|
||||
lambda: tr("Enable Dashcam"),
|
||||
DESCRIPTIONS["DashcamEnabled"],
|
||||
"camera.png",
|
||||
True,
|
||||
),
|
||||
"RecordFront": (
|
||||
lambda: tr("Record and Upload Driver Camera"),
|
||||
DESCRIPTIONS["RecordFront"],
|
||||
"monitoring.png",
|
||||
True,
|
||||
),
|
||||
"RecordAudio": (
|
||||
lambda: tr("Record and Upload Microphone Audio"),
|
||||
DESCRIPTIONS["RecordAudio"],
|
||||
"microphone.png",
|
||||
True,
|
||||
),
|
||||
"IsMetric": (
|
||||
lambda: tr("Use Metric System"),
|
||||
DESCRIPTIONS["IsMetric"],
|
||||
"metric.png",
|
||||
False,
|
||||
),
|
||||
"IQAutoUnits": (
|
||||
lambda: tr("Set Units From Location"),
|
||||
DESCRIPTIONS["IQAutoUnits"],
|
||||
"metric.png",
|
||||
False,
|
||||
),
|
||||
}
|
||||
|
||||
self._long_personality_setting = multiple_button_item(
|
||||
lambda: tr("Driving Personality"),
|
||||
lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]),
|
||||
buttons=[lambda: tr("Relaxed"), lambda: tr("Standard"), lambda: tr("Aggressive")],
|
||||
button_width=300,
|
||||
callback=self._set_longitudinal_personality,
|
||||
selected_index=PERSONALITY_PARAM_TO_DISPLAY.get(self._params.get("LongitudinalPersonality", return_default=True), 1),
|
||||
icon="speed_limit.png"
|
||||
)
|
||||
self._speed_limit_mode_setting = multiple_button_item(
|
||||
lambda: tr("Speed Limit"),
|
||||
lambda: tr(DESCRIPTIONS["IQSpeedAssistMode"]),
|
||||
buttons=[lambda: tr("Off"), lambda: tr("Info"), lambda: tr("Warning"), lambda: tr("Control")],
|
||||
button_width=220,
|
||||
callback=self._set_speed_limit_mode,
|
||||
selected_index=self._params.get("IQSpeedAssistMode", return_default=True),
|
||||
icon="speed_limit.png",
|
||||
)
|
||||
self._longitudinal_control_mode_setting = multiple_button_item(
|
||||
lambda: tr("Longitudinal Control"),
|
||||
lambda: tr(DESCRIPTIONS["LongitudinalControlMode"]),
|
||||
buttons=[lambda: tr("Stock ACC"), lambda: tr("IQ.Chill"), lambda: tr("IQ.Dynamic"), lambda: tr("IQ.Pilot")],
|
||||
button_width=250,
|
||||
callback=self._set_longitudinal_control_mode,
|
||||
selected_index=self._get_longitudinal_control_mode_index(),
|
||||
icon="experimental_white.png",
|
||||
)
|
||||
|
||||
self._toggles = {}
|
||||
self._locked_toggles = set()
|
||||
self._toggles["LongitudinalControlMode"] = self._longitudinal_control_mode_setting
|
||||
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
|
||||
self._toggles["IQSpeedAssistMode"] = self._speed_limit_mode_setting
|
||||
|
||||
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
|
||||
initial_state = self._params.get_bool(param)
|
||||
toggle = toggle_item(
|
||||
title,
|
||||
desc,
|
||||
initial_state,
|
||||
callback=lambda state, p=param: self._toggle_callback(state, p),
|
||||
icon=icon,
|
||||
)
|
||||
|
||||
try:
|
||||
locked = self._params.get_bool(param + "Lock")
|
||||
except UnknownKeyName:
|
||||
locked = False
|
||||
toggle.action_item.set_enabled(not locked)
|
||||
|
||||
# Make description callable for live translation
|
||||
additional_desc = ""
|
||||
if needs_restart and not locked:
|
||||
additional_desc = tr("Changing this setting will restart IQ.Pilot if the car is powered on.")
|
||||
toggle.set_description(lambda og_desc=toggle.description, add_desc=additional_desc: tr(og_desc) + (" " + tr(add_desc) if add_desc else ""))
|
||||
|
||||
# track for engaged state updates
|
||||
if locked:
|
||||
self._locked_toggles.add(param)
|
||||
|
||||
self._toggles[param] = toggle
|
||||
|
||||
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
|
||||
|
||||
self._iq_dynamic_panel: "IQDynamicLayout | None" = None
|
||||
self._show_iq_dynamic = False
|
||||
if gui_app.iqpilot_ui():
|
||||
self._iq_dynamic_panel = IQDynamicLayout(self._close_iq_dynamic_panel)
|
||||
|
||||
ui_state.add_engaged_transition_callback(self._update_toggles)
|
||||
|
||||
def _update_state(self):
|
||||
if ui_state.sm.updated["selfdriveState"]:
|
||||
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
|
||||
if personality != ui_state.personality and ui_state.started:
|
||||
self._long_personality_setting.action_item.set_selected_button(PERSONALITY_PARAM_TO_DISPLAY.get(personality, 1))
|
||||
ui_state.personality = personality
|
||||
self._speed_limit_mode_setting.action_item.set_selected_button(self._params.get("IQSpeedAssistMode", return_default=True))
|
||||
|
||||
def _close_iq_dynamic_panel(self):
|
||||
self._show_iq_dynamic = False
|
||||
|
||||
def set_cruise_panel_callback(self, callback: "Callable") -> None:
|
||||
"""Register callback invoked on double-click of IQ.Dynamic (button index 2)."""
|
||||
action = self._longitudinal_control_mode_setting.action_item
|
||||
if hasattr(action, 'set_double_click_callback'):
|
||||
action.set_double_click_callback(2, callback)
|
||||
|
||||
def show_event(self):
|
||||
self._show_iq_dynamic = False
|
||||
self._scroller.show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
|
||||
e2e_description = tr(
|
||||
"Longitudinal Control modes:<br>" +
|
||||
"IQ.Pilot features are listed below:<br>" +
|
||||
"<h4>IQ.Pilot End-to-End Longitudinal Control</h4><br>" +
|
||||
"Let the driving model control the gas and brakes. IQ.Pilot will drive as it thinks a human would, including stopping for red lights and stop signs. " +
|
||||
"Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This feature is still being improved; " +
|
||||
"mistakes should be expected.<br>" +
|
||||
"<h4>IQ.Dynamic</h4><br>" +
|
||||
"Dynamically blends between adaptive cruise behavior and end-to-end behavior based on scene/context.<br>" +
|
||||
"<h4>IQ.Chill</h4><br>" +
|
||||
"Uses standard traffic-aware cruise behavior for longitudinal control.<br>" +
|
||||
"<h4>New Driving Visualization</h4><br>" +
|
||||
"The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " +
|
||||
"The IQ.Pilot logo will also be shown in the top right corner."
|
||||
)
|
||||
|
||||
alpha_available = bool(ui_state.CP is not None and ui_state.CP.alphaLongitudinalAvailable)
|
||||
alpha_requested = self._params.get_bool("AlphaLongitudinalEnabled")
|
||||
toyota_stock_long_forced = bool(
|
||||
ui_state.CP is not None and
|
||||
ui_state.CP.brand == "toyota" and
|
||||
self._params.get_bool("IQToyotaFactoryLong")
|
||||
)
|
||||
iq_modes_selectable = alpha_available or alpha_requested or toyota_stock_long_forced
|
||||
availability_note = ""
|
||||
if ui_state.CP is None:
|
||||
availability_note = tr("Vehicle longitudinal capability has not been detected yet. Start the car once to detect support.")
|
||||
elif toyota_stock_long_forced:
|
||||
availability_note = tr("Factory Toyota longitudinal control is currently enforced. Choose an IQ longitudinal mode to disable it.")
|
||||
elif not alpha_available:
|
||||
availability_note = tr("IQ longitudinal modes are unavailable for this vehicle. Stock ACC is the only available option.")
|
||||
|
||||
self._toggles["LongitudinalControlMode"].set_visible(True)
|
||||
self._long_personality_setting.set_visible(True)
|
||||
|
||||
mode_index = self._get_longitudinal_control_mode_index()
|
||||
longitudinal_control_item = self._toggles["LongitudinalControlMode"]
|
||||
longitudinal_control_item.action_item.set_selected_button(mode_index)
|
||||
longitudinal_control_item.action_item.set_enabled(not ui_state.engaged)
|
||||
longitudinal_control_item.action_item.set_enabled_buttons([True, iq_modes_selectable, iq_modes_selectable, iq_modes_selectable])
|
||||
|
||||
description = tr(DESCRIPTIONS["LongitudinalControlMode"]) + "<br><br>" + e2e_description
|
||||
if availability_note:
|
||||
description += "<br><br><i>" + availability_note + "</i>"
|
||||
longitudinal_control_item.set_description(description)
|
||||
|
||||
personality_enabled = iq_modes_selectable and mode_index in (2, 3)
|
||||
self._long_personality_setting.action_item.set_enabled(personality_enabled)
|
||||
|
||||
# TODO: make a param control list item so we don't need to manage internal state as much here
|
||||
# refresh toggles from params to mirror external changes
|
||||
for param in self._toggle_defs:
|
||||
self._toggles[param].action_item.set_state(self._params.get_bool(param))
|
||||
|
||||
# these toggles need restart, block while engaged
|
||||
for toggle_def in self._toggle_defs:
|
||||
if self._toggle_defs[toggle_def][3] and toggle_def not in self._locked_toggles:
|
||||
self._toggles[toggle_def].action_item.set_enabled(not ui_state.engaged)
|
||||
|
||||
def _render(self, rect):
|
||||
if self._show_iq_dynamic and self._iq_dynamic_panel is not None:
|
||||
self._iq_dynamic_panel.render(rect)
|
||||
else:
|
||||
self._scroller.render(rect)
|
||||
|
||||
def _get_longitudinal_control_mode_index(self) -> int:
|
||||
if not self._params.get_bool("AlphaLongitudinalEnabled"):
|
||||
return 0 # Stock ACC
|
||||
if not self._params.get_bool("ExperimentalMode"):
|
||||
return 1 # IQ.Chill
|
||||
return 2 if self._params.get_bool("IQDynamicMode") else 3 # IQ.Dynamic / IQ.Pilot
|
||||
|
||||
def _apply_longitudinal_control_mode(self, button_index: int):
|
||||
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
|
||||
previous_alpha = self._params.get_bool("AlphaLongitudinalEnabled")
|
||||
previous_toyota_stock_long = self._params.get_bool("IQToyotaFactoryLong")
|
||||
|
||||
if button_index == 0:
|
||||
self._params.put_bool("AlphaLongitudinalEnabled", False)
|
||||
self._params.put_bool("ExperimentalMode", False)
|
||||
self._params.put_bool("IQDynamicMode", False)
|
||||
elif button_index == 1:
|
||||
self._params.put_bool("AlphaLongitudinalEnabled", True)
|
||||
self._params.put_bool("ExperimentalMode", False)
|
||||
self._params.put_bool("IQDynamicMode", False)
|
||||
self._params.put("LongitudinalPersonality", PERSONALITY_TO_INT["relaxed"])
|
||||
elif button_index == 2:
|
||||
self._params.put_bool("AlphaLongitudinalEnabled", True)
|
||||
self._params.put_bool("ExperimentalMode", True)
|
||||
self._params.put_bool("IQDynamicMode", True)
|
||||
else:
|
||||
self._params.put_bool("AlphaLongitudinalEnabled", True)
|
||||
self._params.put_bool("ExperimentalMode", True)
|
||||
self._params.put_bool("IQDynamicMode", False)
|
||||
|
||||
if button_index != 0 and previous_toyota_stock_long:
|
||||
self._params.put_bool("IQToyotaFactoryLong", False)
|
||||
|
||||
if previous_alpha != self._params.get_bool("AlphaLongitudinalEnabled") or previous_toyota_stock_long != self._params.get_bool("IQToyotaFactoryLong"):
|
||||
self._params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _toggle_callback(self, state: bool, param: str):
|
||||
self._params.put_bool(param, state)
|
||||
if param == "IQAutoUnits" and state:
|
||||
self._params.remove("IQAutoUnitsRegion")
|
||||
if self._toggle_defs[param][3]:
|
||||
self._params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _set_longitudinal_personality(self, button_index: int):
|
||||
self._params.put("LongitudinalPersonality", PERSONALITY_DISPLAY_TO_PARAM[button_index])
|
||||
|
||||
def _set_speed_limit_mode(self, button_index: int):
|
||||
self._params.put("IQSpeedAssistMode", button_index)
|
||||
|
||||
def _set_longitudinal_control_mode(self, button_index: int):
|
||||
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
|
||||
if button_index == self._get_longitudinal_control_mode_index():
|
||||
if button_index == 2 and self._iq_dynamic_panel is not None:
|
||||
self._show_iq_dynamic = True
|
||||
return
|
||||
|
||||
# IQ.Pilot and IQ.Dynamic both require ExperimentalMode confirmation.
|
||||
if button_index in (2, 3) and not self._params.get_bool("ExperimentalModeConfirmed"):
|
||||
def confirm_callback(result: int):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self._apply_longitudinal_control_mode(button_index)
|
||||
self._params.put_bool("ExperimentalModeConfirmed", True)
|
||||
else:
|
||||
self._toggles["LongitudinalControlMode"].action_item.set_selected_button(self._get_longitudinal_control_mode_index())
|
||||
self._update_toggles()
|
||||
|
||||
content = (f"<h1>{self._toggles['LongitudinalControlMode'].title}</h1><br>" +
|
||||
f"<p>{self._toggles['LongitudinalControlMode'].description}</p>")
|
||||
dlg = ConfirmDialog(content, tr("Enable"), rich=True)
|
||||
gui_app.set_modal_overlay(dlg, callback=confirm_callback)
|
||||
else:
|
||||
self._apply_longitudinal_control_mode(button_index)
|
||||
self._update_toggles()
|
||||
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal file
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal file
@@ -0,0 +1,557 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.ui.layouts.settings.iq_panels import IQSettingsLayout
|
||||
from iqpilot.selfdrive.ui.layouts.home import _format_updater_description
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT, BACK_BTN_SIZE
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget, DialogResult
|
||||
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
# iOS-style swipe-from-the-left-edge-to-go-back, for panel -> grid only (the one place
|
||||
# this layout already has a from/to slide transition to reuse for the live drag).
|
||||
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back touch must start within
|
||||
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to "this is a swipe"
|
||||
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (while still under arm distance) that cancels it
|
||||
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of screen width dragged to complete the pop on release
|
||||
SWIPE_SETTLE_SECONDS = 0.16 # glide from the release point to done/cancelled (no snap)
|
||||
|
||||
MARGIN = 40
|
||||
SPACING = 25
|
||||
COLUMNS = 3
|
||||
PILL_GAP = 24
|
||||
PILL_MIN_HEIGHT = 150
|
||||
BUBBLE_SIZE = 86
|
||||
BUBBLE_RED = rl.Color(226, 60, 52, 255)
|
||||
BUBBLE_RED_PRESSED = rl.Color(245, 92, 82, 255)
|
||||
BUBBLE_GREY = rl.Color(70, 72, 78, 255)
|
||||
BUBBLE_GREY_PRESSED = rl.Color(95, 98, 106, 255)
|
||||
BUBBLE_TEAL = rl.Color(16, 185, 169, 255)
|
||||
BUBBLE_TEAL_PRESSED = rl.Color(22, 210, 192, 255)
|
||||
SETTINGS_TRANSITION_SECONDS = 0.28
|
||||
TRANSITION_SURFACE_OVERSCAN = 4
|
||||
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
|
||||
|
||||
|
||||
class MenuTransitionState(Enum):
|
||||
IDLE = auto()
|
||||
PUSHING = auto()
|
||||
POPPING = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuTransition:
|
||||
state: MenuTransitionState = MenuTransitionState.IDLE
|
||||
t: float = 0.0
|
||||
duration: float = SETTINGS_TRANSITION_SECONDS
|
||||
from_screen: object | None = None
|
||||
to_screen: object | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state != MenuTransitionState.IDLE
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def _ease_out_cubic(x: float) -> float:
|
||||
x = _clamp(x)
|
||||
return 1.0 - pow(1.0 - x, 3.0)
|
||||
|
||||
|
||||
def _ease_emphasized(x: float) -> float:
|
||||
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
|
||||
x = _clamp(x)
|
||||
if x < 0.5:
|
||||
return 4.0 * x * x * x
|
||||
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
|
||||
|
||||
|
||||
# Depth cues for the grid<->panel push/pop (see main.py for the same treatment).
|
||||
TRANSITION_PARALLAX = 0.28
|
||||
TRANSITION_MAX_DIM = 0.5
|
||||
TRANSITION_SHADOW_W = 32
|
||||
|
||||
|
||||
class SettingsPill(Widget):
|
||||
"""A settings-grid button: icon + label on a rounded card, opens a sub-panel."""
|
||||
|
||||
BG = rl.Color(38, 40, 46, 255)
|
||||
BG_PRESSED = rl.Color(54, 57, 65, 255)
|
||||
BORDER = rl.Color(255, 255, 255, 38)
|
||||
|
||||
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._icon = gui_app.texture(icon_path, 80, 80, keep_aspect_ratio=True) if icon_path else None
|
||||
self.set_click_callback(on_click)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rounded(rect, 0.25, 20, self.BG_PRESSED if self.is_pressed else self.BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 20, 2, self.BORDER)
|
||||
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
label_size = 50
|
||||
label = self._label() if callable(self._label) else self._label
|
||||
ts = measure_text_cached(font, label, label_size)
|
||||
icon_w = self._icon.width if self._icon else 0
|
||||
gap = 24 if self._icon else 0
|
||||
group_w = icon_w + gap + ts.x
|
||||
x = rect.x + (rect.width - group_w) / 2
|
||||
cy = rect.y + rect.height / 2
|
||||
|
||||
if self._icon:
|
||||
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
|
||||
x += icon_w + gap
|
||||
rl.draw_text_ex(font, label, rl.Vector2(int(x), int(cy - ts.y / 2)), label_size, 0, rl.WHITE)
|
||||
|
||||
|
||||
class SettingsHubLayout(Widget):
|
||||
"""Offroad Settings: a grid of pill buttons (the mockup look) that open the existing
|
||||
IQ settings sub-panels. Back from a panel returns to the grid; back from the grid goes home.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._settings = IQSettingsLayout() # one instance: reuse its configured panels + wiring
|
||||
self._header = self._child(ScreenHeader(lambda: tr("Settings")))
|
||||
self._on_close: Callable[[], None] | None = None
|
||||
|
||||
self._mode = "grid" # "grid" | "panel"
|
||||
self._cur_panel = None
|
||||
|
||||
# Edge-swipe-back drag state (panel -> grid only)
|
||||
self._swipe_start_pos: MousePos | None = None
|
||||
self._swipe_active = False # past EDGE_SWIPE_ARM_DISTANCE, now tracking finger 1:1
|
||||
self._swipe_blocked = False # vertical movement won out before we armed; ignore rest of this touch
|
||||
self._swipe_dx = 0.0
|
||||
# Release-settle animation (glide to grid/panel instead of snapping)
|
||||
self._swipe_settling = False
|
||||
self._swipe_settle_from = 0.0
|
||||
self._swipe_settle_to = 0.0
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_completing = False
|
||||
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
|
||||
self._version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
text_color=rl.Color(185, 185, 190, 255),
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
wrap_text=False, scroll=True)
|
||||
self._transition = MenuTransition()
|
||||
|
||||
# Restart / power-off / always-offroad / night-mode bubbles in the grid header
|
||||
self._restart_icon = gui_app.texture("icons/iq/restart.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._power_icon = gui_app.texture("icons/iq/power.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._night_icon = gui_app.texture("icons/iq/moon.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._bell_icon = gui_app.texture("icons/iq/bell.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._bell_slash_icon = gui_app.texture("icons/iq/bell-slash.png", 48, 48, keep_aspect_ratio=True)
|
||||
self._restart_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._power_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._offroad_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._night_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._silent_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
# Build the grid from the settings panels, skipping ones hidden from navigation (e.g. Cruise).
|
||||
panels = self._settings._panels
|
||||
hidden = self._settings._hidden_from_sidebar
|
||||
self._grid_panels = [pt for pt in panels if pt not in hidden]
|
||||
self._pills = [
|
||||
SettingsPill(panels[pt].icon, lambda p=pt: tr(panels[p].name), (lambda p=pt: self._open_panel(p)))
|
||||
for pt in self._grid_panels
|
||||
]
|
||||
|
||||
# Route the Toggles -> Cruise shortcut into this hub's panel view.
|
||||
cruise_pt = next((pt for pt in panels if pt.name == "CRUISE"), None)
|
||||
toggles_pt = next((pt for pt in panels if pt.name == "TOGGLES"), None)
|
||||
if cruise_pt is not None and toggles_pt is not None:
|
||||
toggles = panels[toggles_pt].instance
|
||||
if hasattr(toggles, "set_cruise_panel_callback"):
|
||||
toggles.set_cruise_panel_callback(lambda: self._open_panel(cruise_pt))
|
||||
|
||||
def set_callbacks(self, on_close: Callable[[], None] | None = None):
|
||||
self._on_close = on_close
|
||||
|
||||
def show_grid(self):
|
||||
self._mode = "grid"
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def set_current_panel(self, panel_type):
|
||||
self._open_panel(panel_type)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._mode = "grid"
|
||||
self._transition = MenuTransition()
|
||||
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
|
||||
|
||||
def _open_panel(self, panel_type):
|
||||
if self._transition.active:
|
||||
return
|
||||
|
||||
from_grid = self._mode == "grid"
|
||||
self._settings.set_current_panel(panel_type)
|
||||
self._cur_panel = panel_type
|
||||
if from_grid and not ui_state.started:
|
||||
self._start_transition("grid", "panel", MenuTransitionState.PUSHING)
|
||||
else:
|
||||
self._mode = "panel"
|
||||
|
||||
def _back_to_grid(self):
|
||||
if self._mode == "panel" and not self._transition.active:
|
||||
self._start_transition("panel", "grid", MenuTransitionState.POPPING)
|
||||
|
||||
def _reset_swipe(self):
|
||||
self._swipe_start_pos = None
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
if self._swipe_settling:
|
||||
return # let the release animation finish before accepting a new gesture
|
||||
|
||||
if mouse_event.slot != 0 or self._mode != "panel" or self._transition.active:
|
||||
self._reset_swipe()
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
|
||||
self._swipe_start_pos = mouse_event.pos
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
else:
|
||||
self._reset_swipe()
|
||||
|
||||
elif self._swipe_start_pos is not None:
|
||||
if mouse_event.left_down:
|
||||
dx = mouse_event.pos.x - self._swipe_start_pos.x
|
||||
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
|
||||
if not self._swipe_active and not self._swipe_blocked:
|
||||
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
|
||||
self._swipe_blocked = True
|
||||
elif dx > EDGE_SWIPE_ARM_DISTANCE:
|
||||
self._swipe_active = True
|
||||
if self._swipe_active:
|
||||
self._swipe_dx = max(0.0, dx)
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._swipe_active:
|
||||
complete = self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
|
||||
self._begin_swipe_settle(complete)
|
||||
self._reset_swipe()
|
||||
|
||||
def _start_transition(self, from_mode: str, to_mode: str, state: MenuTransitionState):
|
||||
self._transition = MenuTransition(
|
||||
state=state,
|
||||
t=0.0,
|
||||
duration=SETTINGS_TRANSITION_SECONDS,
|
||||
from_screen=from_mode,
|
||||
to_screen=to_mode,
|
||||
)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._render_transition(rect):
|
||||
return
|
||||
|
||||
if self._swipe_settling:
|
||||
if self._update_swipe_settle():
|
||||
self._render_swipe_drag(rect)
|
||||
return
|
||||
# settled this frame; fall through to render the (possibly switched) mode
|
||||
elif self._swipe_active and self._mode == "panel":
|
||||
self._render_swipe_drag(rect)
|
||||
return
|
||||
|
||||
self._render_mode(self._mode, rect)
|
||||
|
||||
def _begin_swipe_settle(self, complete: bool):
|
||||
# On release, glide from where the finger let go to fully-open (complete -> grid) or closed
|
||||
# (cancel -> stay on panel) instead of snapping.
|
||||
self._swipe_settle_from = self._swipe_dx
|
||||
self._swipe_settle_to = self._rect.width if complete else 0.0
|
||||
self._swipe_completing = complete
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_settling = True
|
||||
|
||||
def _update_swipe_settle(self) -> bool:
|
||||
"""Advance the release animation. Returns True while still animating."""
|
||||
self._swipe_settle_t += rl.get_frame_time()
|
||||
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
|
||||
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
|
||||
if p < 1.0:
|
||||
return True
|
||||
self._swipe_settling = False
|
||||
completing = self._swipe_completing
|
||||
self._swipe_completing = False
|
||||
self._swipe_dx = 0.0
|
||||
if completing:
|
||||
self._mode = "grid"
|
||||
self._transition = MenuTransition()
|
||||
return False
|
||||
|
||||
def _render_swipe_drag(self, rect: rl.Rectangle):
|
||||
# Live, finger-following preview of the panel -> grid pop, mirroring _render_transition's
|
||||
# POPPING geometry but driven 1:1 by touch position instead of eased elapsed time.
|
||||
dx = min(self._swipe_dx, rect.width)
|
||||
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
||||
self._render_mode_surface_translated("grid", rect, dx - rect.width)
|
||||
self._render_mode_surface_translated("panel", rect, dx)
|
||||
|
||||
def _layout_rects(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, rl.Rectangle]:
|
||||
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
|
||||
content_y = header_rect.y + HEADER_HEIGHT + SPACING
|
||||
content_rect = rl.Rectangle(rect.x + MARGIN, content_y, rect.width - 2 * MARGIN,
|
||||
rect.y + rect.height - content_y - MARGIN)
|
||||
return header_rect, content_rect
|
||||
|
||||
def _render_mode(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
|
||||
if mode == "panel" and self._cur_panel is not None:
|
||||
self._render_panel(rect, interactive)
|
||||
else:
|
||||
self._render_grid(rect, interactive)
|
||||
|
||||
def _render_mode_surface(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
|
||||
bg_rect = rl.Rectangle(
|
||||
rect.x - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.y - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
)
|
||||
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
|
||||
self._render_mode(mode, rect, interactive)
|
||||
|
||||
def _render_mode_surface_translated(self, mode: str, rect: rl.Rectangle, x_offset: float):
|
||||
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
|
||||
self._render_mode_surface(mode, translated_rect, interactive=False)
|
||||
|
||||
def _render_transition(self, rect: rl.Rectangle) -> bool:
|
||||
if not self._transition.active:
|
||||
return False
|
||||
|
||||
from_mode = self._transition.from_screen
|
||||
to_mode = self._transition.to_screen
|
||||
if from_mode is None or to_mode is None:
|
||||
self._finish_transition()
|
||||
return False
|
||||
from_mode = str(from_mode)
|
||||
to_mode = str(to_mode)
|
||||
|
||||
self._transition.t += rl.get_frame_time()
|
||||
if self._transition.t >= self._transition.duration:
|
||||
self._finish_transition()
|
||||
return False
|
||||
|
||||
p = _clamp(self._transition.t / self._transition.duration)
|
||||
e = _ease_emphasized(p)
|
||||
w = rect.width
|
||||
pushing = self._transition.state == MenuTransitionState.PUSHING
|
||||
|
||||
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
|
||||
if pushing:
|
||||
top_mode, back_mode = to_mode, from_mode
|
||||
top_x = _lerp(w, 0.0, e)
|
||||
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
|
||||
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
|
||||
else:
|
||||
top_mode, back_mode = from_mode, to_mode
|
||||
top_x = _lerp(0.0, w, e)
|
||||
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
|
||||
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
|
||||
|
||||
back_rect = rl.Rectangle(round(rect.x + back_x), rect.y, rect.width, rect.height)
|
||||
|
||||
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
||||
self._render_mode_surface_translated(back_mode, rect, back_x)
|
||||
if back_dim > 0:
|
||||
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
|
||||
# soft shadow cast by the top card's leading edge onto the page behind
|
||||
edge_x = round(rect.x + top_x)
|
||||
if edge_x > rect.x:
|
||||
sh = int(min(TRANSITION_SHADOW_W, edge_x - rect.x))
|
||||
rl.draw_rectangle_gradient_h(edge_x - sh, int(rect.y), sh, int(rect.height),
|
||||
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
|
||||
self._render_mode_surface_translated(top_mode, rect, top_x)
|
||||
return True
|
||||
|
||||
def _finish_transition(self):
|
||||
if self._transition.to_screen is not None:
|
||||
self._mode = str(self._transition.to_screen)
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def _render_widget(self, widget: Widget, rect: rl.Rectangle, interactive: bool):
|
||||
if interactive:
|
||||
widget.render(rect)
|
||||
return
|
||||
|
||||
enabled = widget._enabled
|
||||
widget.set_enabled(False)
|
||||
try:
|
||||
widget.render(rect)
|
||||
finally:
|
||||
widget.set_enabled(enabled)
|
||||
|
||||
def _render_panel(self, rect: rl.Rectangle, interactive: bool = True):
|
||||
header_rect, content_rect = self._layout_rects(rect)
|
||||
|
||||
self._header.set_title(tr(self._settings._panels[self._cur_panel].name))
|
||||
self._header.set_on_back(self._back_to_grid)
|
||||
self._header.set_title_offset(0)
|
||||
self._render_widget(self._header, header_rect, interactive)
|
||||
|
||||
panel = self._settings._panels[self._settings._current_panel].instance
|
||||
enabled = panel._enabled
|
||||
if not interactive:
|
||||
panel.set_enabled(False)
|
||||
try:
|
||||
self._settings._draw_current_panel(content_rect)
|
||||
finally:
|
||||
if not interactive:
|
||||
panel.set_enabled(enabled)
|
||||
|
||||
def _render_grid(self, rect: rl.Rectangle, interactive: bool = True):
|
||||
header_rect, content_rect = self._layout_rects(rect)
|
||||
|
||||
# Grid landing
|
||||
title = tr("Settings")
|
||||
title_offset = 5 * BUBBLE_SIZE + 4 * 18 + 36
|
||||
self._header.set_title(title)
|
||||
self._header.set_on_back(self._on_close)
|
||||
self._header.set_title_offset(title_offset) # make room for the bubbles
|
||||
self._render_widget(self._header, header_rect, interactive)
|
||||
|
||||
# Restart / power-off / always-offroad bubbles, just right of the back button
|
||||
cy = header_rect.y + HEADER_HEIGHT / 2
|
||||
bx = header_rect.x + BACK_BTN_SIZE + 24
|
||||
self._restart_rect = rl.Rectangle(bx, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
||||
self._power_rect = rl.Rectangle(bx + BUBBLE_SIZE + 18, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
||||
self._offroad_rect = rl.Rectangle(bx + 2 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
||||
self._night_rect = rl.Rectangle(bx + 3 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
||||
self._silent_rect = rl.Rectangle(bx + 4 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
||||
mouse = rl.get_mouse_position()
|
||||
for r, icon in ((self._restart_rect, self._restart_icon), (self._power_rect, self._power_icon)):
|
||||
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, r)
|
||||
rl.draw_circle(int(r.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, BUBBLE_RED_PRESSED if pressed else BUBBLE_RED)
|
||||
rl.draw_texture(icon, int(r.x + (BUBBLE_SIZE - icon.width) / 2), int(cy - icon.height / 2), rl.WHITE)
|
||||
# Always Offroad bubble (grey when off, teal when on)
|
||||
offroad_active = ui_state.params.get_bool("IQAlwaysOffroad")
|
||||
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._offroad_rect)
|
||||
offroad_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if offroad_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
||||
rl.draw_circle(int(self._offroad_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, offroad_color)
|
||||
rl.draw_texture(self._offroad_icon,
|
||||
int(self._offroad_rect.x + (BUBBLE_SIZE - self._offroad_icon.width) / 2),
|
||||
int(cy - self._offroad_icon.height / 2), rl.WHITE)
|
||||
# Night Mode bubble (grey when off, teal when on)
|
||||
night_active = ui_state.params.get_bool("NightMode")
|
||||
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._night_rect)
|
||||
night_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if night_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
||||
rl.draw_circle(int(self._night_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, night_color)
|
||||
rl.draw_texture(self._night_icon,
|
||||
int(self._night_rect.x + (BUBBLE_SIZE - self._night_icon.width) / 2),
|
||||
int(cy - self._night_icon.height / 2), rl.WHITE)
|
||||
|
||||
# Silent Mode bubble (grey bell when off, red bell-with-slash when on)
|
||||
silent_active = ui_state.params.get_bool("IQAlertSilence")
|
||||
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._silent_rect)
|
||||
silent_color = (BUBBLE_RED_PRESSED if pressed else BUBBLE_RED) if silent_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
||||
silent_icon = self._bell_slash_icon if silent_active else self._bell_icon
|
||||
rl.draw_circle(int(self._silent_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, silent_color)
|
||||
rl.draw_texture(silent_icon,
|
||||
int(self._silent_rect.x + (BUBBLE_SIZE - silent_icon.width) / 2),
|
||||
int(cy - silent_icon.height / 2), rl.WHITE)
|
||||
|
||||
# Small version label, top-right of the header row. Its scroll viewport
|
||||
# starts after the title, so long branch text does not clip at the bubbles.
|
||||
font = gui_app.font(FontWeight.MEDIUM)
|
||||
title_font = gui_app.font(FontWeight.BOLD)
|
||||
ver_fs = 44
|
||||
title_size = measure_text_cached(title_font, title, 64)
|
||||
ver_size = measure_text_cached(font, self._version_text, ver_fs)
|
||||
title_x = header_rect.x + BACK_BTN_SIZE + 36 + title_offset
|
||||
version_left = title_x + title_size.x + 36
|
||||
version_right = header_rect.x + header_rect.width
|
||||
version_rect = rl.Rectangle(version_left, header_rect.y, max(0, version_right - version_left), HEADER_HEIGHT)
|
||||
if version_rect.width > 0:
|
||||
if ver_size.x <= version_rect.width:
|
||||
rl.draw_text_ex(font, self._version_text,
|
||||
rl.Vector2(int(header_rect.x + header_rect.width - ver_size.x),
|
||||
int(header_rect.y + (HEADER_HEIGHT - ver_size.y) / 2)),
|
||||
ver_fs, 0, rl.Color(185, 185, 190, 255))
|
||||
else:
|
||||
self._version_label.set_text(self._version_text)
|
||||
self._version_label.render(version_rect)
|
||||
|
||||
rows = (len(self._pills) + COLUMNS - 1) // COLUMNS
|
||||
pill_w = (content_rect.width - PILL_GAP * (COLUMNS - 1)) / COLUMNS
|
||||
pill_h = max(PILL_MIN_HEIGHT, (content_rect.height - PILL_GAP * (rows - 1)) / rows)
|
||||
for i, pill in enumerate(self._pills):
|
||||
col = i % COLUMNS
|
||||
row = i // COLUMNS
|
||||
px = content_rect.x + col * (pill_w + PILL_GAP)
|
||||
py = content_rect.y + row * (pill_h + PILL_GAP)
|
||||
self._render_widget(pill, rl.Rectangle(px, py, pill_w, pill_h), interactive)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if self._mode != "grid" or self._transition.active:
|
||||
return
|
||||
if rl.check_collision_point_rec(mouse_pos, self._restart_rect):
|
||||
self._reboot_prompt()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._power_rect):
|
||||
self._power_off_prompt()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._offroad_rect):
|
||||
self._toggle_offroad_prompt()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._night_rect):
|
||||
ui_state.params.put_bool("NightMode", not ui_state.params.get_bool("NightMode"))
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._silent_rect):
|
||||
ui_state.params.put_bool("IQAlertSilence", not ui_state.params.get_bool("IQAlertSilence"))
|
||||
|
||||
def _reboot_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
|
||||
return
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
|
||||
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
|
||||
|
||||
def _perform_reboot(self, result: int):
|
||||
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
||||
self._params.put_bool_nonblocking("DoReboot", True)
|
||||
|
||||
def _power_off_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
|
||||
return
|
||||
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
|
||||
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
|
||||
|
||||
def _perform_power_off(self, result: int):
|
||||
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
||||
self._params.put_bool_nonblocking("DoShutdown", True)
|
||||
|
||||
def _toggle_offroad_prompt(self):
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage before forcing offroad")))
|
||||
return
|
||||
active = ui_state.params.get_bool("IQAlwaysOffroad")
|
||||
msg = tr("Leave forced-offroad mode now?") if active else tr("Switch the device into forced-offroad mode?")
|
||||
|
||||
def _confirm(result: int):
|
||||
if result == DialogResult.CONFIRM and not ui_state.engaged:
|
||||
ui_state.params.put_bool("IQAlwaysOffroad", not active)
|
||||
|
||||
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Confirm")), callback=_confirm)
|
||||
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal file
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import pyray as rl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
from iqpilot.cereal import log
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
SIDEBAR_WIDTH = 300
|
||||
METRIC_HEIGHT = 120
|
||||
METRIC_WIDTH = 252
|
||||
METRIC_MARGIN = 24
|
||||
|
||||
CARD_GAP = 16
|
||||
STATUS_DOT_CENTER_X = 36
|
||||
STATUS_TEXT_X = 66
|
||||
STATUS_TEXT_RIGHT_MARGIN = 20
|
||||
STATUS_LABEL_SIZE = 26
|
||||
STATUS_VALUE_SIZE = 36
|
||||
STATUS_LINE_GAP = 2
|
||||
NETWORK_RECT = rl.Rectangle(24, 18, 252, 76)
|
||||
SETTINGS_BTN = rl.Rectangle(24, 112, 252, 112)
|
||||
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
|
||||
|
||||
ThermalStatus = log.DeviceState.ThermalStatus
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
|
||||
# Color scheme
|
||||
class Colors:
|
||||
WHITE = rl.WHITE
|
||||
WHITE_DIM = rl.Color(255, 255, 255, 85)
|
||||
GRAY = rl.Color(84, 84, 84, 255)
|
||||
|
||||
# Keep these in parity with the offroad home status indicators.
|
||||
GOOD = rl.Color(16, 185, 169, 255)
|
||||
WARNING = rl.Color(245, 166, 35, 255)
|
||||
DANGER = rl.Color(226, 72, 58, 255)
|
||||
|
||||
# UI elements
|
||||
CARD_BG = rl.Color(28, 30, 36, 255)
|
||||
CARD_BG_PRESSED = rl.Color(50, 53, 61, 255)
|
||||
METRIC_BORDER = rl.Color(255, 255, 255, 22)
|
||||
BOOKMARK_BG = CARD_BG
|
||||
BOOKMARK_BG_PRESSED = CARD_BG_PRESSED
|
||||
BUTTON_NORMAL = rl.WHITE
|
||||
BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
|
||||
|
||||
|
||||
NETWORK_TYPES = {
|
||||
NetworkType.none: tr_noop("--"),
|
||||
NetworkType.wifi: tr_noop("Wi-Fi"),
|
||||
NetworkType.ethernet: tr_noop("ETH"),
|
||||
NetworkType.cell2G: tr_noop("2G"),
|
||||
NetworkType.cell3G: tr_noop("3G"),
|
||||
NetworkType.cell4G: tr_noop("LTE"),
|
||||
NetworkType.cell5G: tr_noop("5G"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MetricData:
|
||||
label: str
|
||||
value: str
|
||||
color: rl.Color
|
||||
|
||||
def update(self, label: str, value: str, color: rl.Color):
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.color = color
|
||||
|
||||
|
||||
class Sidebar(Widget):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
self._net_type = NETWORK_TYPES.get(NetworkType.none)
|
||||
self._net_strength = 0
|
||||
|
||||
self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
|
||||
self._panda_status = MetricData(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
|
||||
self._connect_status = MetricData(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
|
||||
self._recording_audio = False
|
||||
|
||||
self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height)
|
||||
self._konn3kt_home_logo = gui_app.texture("icons_mici/settings/konn3kt_icon.png", 104, 104)
|
||||
self._settings_img = gui_app.texture("icons/iq/tile_settings.png", 92, 92, keep_aspect_ratio=True)
|
||||
self._mic_img = gui_app.texture("icons_mici/microphone.png", 26, 30, keep_aspect_ratio=True)
|
||||
self._live_view_img = gui_app.texture("icons/live_view.png", 38, 32, keep_aspect_ratio=True)
|
||||
self._live_streaming = False
|
||||
_net_base = "icons_mici/settings/network/"
|
||||
self._cell_strength_icons = [
|
||||
gui_app.texture(f"{_net_base}cell_strength_none.png", 56, 56, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_medium.png", 56, 56, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_high.png", 56, 56, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}cell_strength_full.png", 56, 56, keep_aspect_ratio=True),
|
||||
]
|
||||
self._wifi_strength_icons = [
|
||||
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
|
||||
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
|
||||
]
|
||||
self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._font_regular = gui_app.font(FontWeight.MEDIUM)
|
||||
self._font_bold = gui_app.font(FontWeight.BOLD)
|
||||
|
||||
# Callbacks
|
||||
self._on_settings_click: Callable | None = None
|
||||
self._on_flag_click: Callable | None = None
|
||||
self._open_settings_callback: Callable | None = None
|
||||
|
||||
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None,
|
||||
open_settings: Callable | None = None):
|
||||
self._on_settings_click = on_settings
|
||||
self._on_flag_click = on_flag
|
||||
self._open_settings_callback = open_settings
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# Background
|
||||
rl.draw_rectangle_rec(rect, rl.BLACK)
|
||||
|
||||
self._draw_buttons(rect)
|
||||
self._draw_network_indicator(rect)
|
||||
self._draw_metrics(rect)
|
||||
|
||||
def _update_state(self):
|
||||
sm = ui_state.sm
|
||||
device_state = sm['deviceState']
|
||||
|
||||
self._recording_audio = ui_state.recording_audio
|
||||
# Konn3kt Live View is streaming to a viewer (hephaestusd sets this while a session is live)
|
||||
self._live_streaming = ui_state.params.get_bool("IsLiveStreaming")
|
||||
self._update_network_status(device_state)
|
||||
self._update_temperature_status(device_state)
|
||||
self._update_connection_status(device_state)
|
||||
self._update_panda_status()
|
||||
|
||||
def _update_network_status(self, device_state):
|
||||
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
|
||||
strength = device_state.networkStrength
|
||||
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
|
||||
|
||||
def _update_temperature_status(self, device_state):
|
||||
thermal_status = device_state.thermalStatus
|
||||
|
||||
if thermal_status == ThermalStatus.green:
|
||||
self._temp_status.update(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
|
||||
elif thermal_status == ThermalStatus.yellow:
|
||||
self._temp_status.update(tr_noop("TEMP"), tr_noop("OK"), Colors.WARNING)
|
||||
else:
|
||||
self._temp_status.update(tr_noop("TEMP"), tr_noop("HIGH"), Colors.DANGER)
|
||||
|
||||
def _update_connection_status(self, device_state):
|
||||
last_ping = device_state.lastAthenaPingTime
|
||||
if last_ping == 0:
|
||||
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
|
||||
elif time.monotonic_ns() - last_ping < 80_000_000_000: # 80 seconds in nanoseconds
|
||||
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ONLINE"), Colors.GOOD)
|
||||
else:
|
||||
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ERROR"), Colors.DANGER)
|
||||
|
||||
def _update_panda_status(self):
|
||||
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
||||
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("NO PANDA"), Colors.DANGER)
|
||||
else:
|
||||
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
|
||||
if self._on_settings_click:
|
||||
self._on_settings_click()
|
||||
elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started:
|
||||
if self._on_flag_click:
|
||||
self._on_flag_click()
|
||||
elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect):
|
||||
if self._open_settings_callback:
|
||||
self._open_settings_callback()
|
||||
|
||||
def _draw_buttons(self, rect: rl.Rectangle):
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
|
||||
|
||||
# Settings button
|
||||
settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)
|
||||
rl.draw_rectangle_rounded(SETTINGS_BTN, 0.28, 18, Colors.CARD_BG_PRESSED if settings_down else Colors.CARD_BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(SETTINGS_BTN, 0.28, 18, 2, rl.Color(255, 255, 255, 28))
|
||||
tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL
|
||||
rl.draw_texture(self._settings_img,
|
||||
int(SETTINGS_BTN.x + (SETTINGS_BTN.width - self._settings_img.width) / 2),
|
||||
int(SETTINGS_BTN.y + (SETTINGS_BTN.height - self._settings_img.height) / 2),
|
||||
tint)
|
||||
|
||||
# Home/Flag button
|
||||
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
|
||||
if ui_state.started:
|
||||
center_x = HOME_BTN.x + HOME_BTN.width / 2
|
||||
center_y = HOME_BTN.y + HOME_BTN.height / 2
|
||||
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.5
|
||||
fill = Colors.BOOKMARK_BG_PRESSED if flag_pressed else Colors.BOOKMARK_BG
|
||||
tint = Colors.BUTTON_PRESSED if flag_pressed else Colors.BUTTON_NORMAL
|
||||
rl.draw_circle(int(center_x), int(center_y), radius, fill)
|
||||
self._draw_bookmark_icon(center_x, center_y, tint, fill)
|
||||
else:
|
||||
center_x = HOME_BTN.x + HOME_BTN.width / 2
|
||||
center_y = HOME_BTN.y + HOME_BTN.height / 2
|
||||
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.47
|
||||
circle_color = Colors.BUTTON_PRESSED if mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) else Colors.BUTTON_NORMAL
|
||||
rl.draw_circle(int(center_x), int(center_y), radius, circle_color)
|
||||
|
||||
logo_x = center_x - self._konn3kt_home_logo.width / 2
|
||||
logo_y = center_y - self._konn3kt_home_logo.height / 2
|
||||
rl.draw_texture(self._konn3kt_home_logo, int(logo_x), int(logo_y), rl.WHITE)
|
||||
|
||||
# Status indicators (right-anchored row): Live View (when streaming to konn3kt) sits to the LEFT
|
||||
# of the Microphone (when recording audio). Each shows independently. Mic keeps its tap target.
|
||||
slot_w, slot_h, gap = 70, 38, 12
|
||||
slot_y = rect.y + 240
|
||||
row_right = rect.x + rect.width - 36 # right edge of the rightmost slot
|
||||
indicators = []
|
||||
if self._live_streaming:
|
||||
indicators.append(("live", self._live_view_img))
|
||||
if self._recording_audio:
|
||||
indicators.append(("mic", self._mic_img))
|
||||
|
||||
total_w = len(indicators) * slot_w + max(0, len(indicators) - 1) * gap
|
||||
slot_x = row_right - total_w
|
||||
for kind, img in indicators:
|
||||
slot = rl.Rectangle(slot_x, slot_y, slot_w, slot_h)
|
||||
icon_x = int(slot.x + (slot_w - img.width) / 2)
|
||||
icon_y = int(slot.y + (slot_h - img.height) / 2)
|
||||
if kind == "mic":
|
||||
self._mic_indicator_rect = slot
|
||||
mic_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, slot)
|
||||
tint = rl.Color(255, 255, 255, 150) if mic_pressed else rl.WHITE
|
||||
rl.draw_texture(img, icon_x, icon_y, tint)
|
||||
else:
|
||||
rl.draw_texture(img, icon_x, icon_y, rl.WHITE)
|
||||
slot_x += slot_w + gap
|
||||
|
||||
def _draw_network_indicator(self, rect: rl.Rectangle):
|
||||
on_wifi = self._net_type == NETWORK_TYPES[NetworkType.wifi]
|
||||
net_text = current_ssid(on_wifi) or tr(self._net_type)
|
||||
icon_list = self._wifi_strength_icons if self._net_type in (NETWORK_TYPES[NetworkType.wifi], NETWORK_TYPES[NetworkType.ethernet]) else self._cell_strength_icons
|
||||
signal_icon = icon_list[min(self._net_strength, len(icon_list) - 1)]
|
||||
text_size = measure_text_cached(self._font_regular, net_text, 44)
|
||||
content_w = signal_icon.width + 14 + text_size.x
|
||||
|
||||
icon_x = NETWORK_RECT.x + (NETWORK_RECT.width - content_w) / 2
|
||||
icon_y = NETWORK_RECT.y + (NETWORK_RECT.height - signal_icon.height) / 2
|
||||
rl.draw_texture(signal_icon, int(icon_x), int(icon_y), Colors.WHITE)
|
||||
|
||||
text_x = icon_x + signal_icon.width + 14
|
||||
text_y = NETWORK_RECT.y + (NETWORK_RECT.height - text_size.y) / 2
|
||||
rl.draw_text_ex(self._font_regular, net_text, rl.Vector2(int(text_x), int(text_y)), 44, 0, rl.Color(215, 215, 215, 255))
|
||||
|
||||
def _draw_metrics(self, rect: rl.Rectangle):
|
||||
metric_count = 3
|
||||
metrics_height = metric_count * METRIC_HEIGHT + (metric_count - 1) * CARD_GAP
|
||||
available_top = SETTINGS_BTN.y + SETTINGS_BTN.height
|
||||
available_height = HOME_BTN.y - available_top
|
||||
first_metric_y = available_top + max(0, (available_height - metrics_height) / 2)
|
||||
metrics = [
|
||||
(self._temp_status, first_metric_y),
|
||||
(self._panda_status, first_metric_y + METRIC_HEIGHT + CARD_GAP),
|
||||
(self._connect_status, first_metric_y + 2 * (METRIC_HEIGHT + CARD_GAP)),
|
||||
]
|
||||
|
||||
for metric, y_offset in metrics:
|
||||
self._draw_metric(rect, metric, rect.y + y_offset)
|
||||
|
||||
def _draw_metric(self, rect: rl.Rectangle, metric: MetricData, y: float):
|
||||
metric_rect = rl.Rectangle(rect.x + METRIC_MARGIN, y, METRIC_WIDTH, METRIC_HEIGHT)
|
||||
rl.draw_rectangle_rounded(metric_rect, 0.28, 16, Colors.CARD_BG)
|
||||
rl.draw_rectangle_rounded_lines_ex(metric_rect, 0.28, 16, 2, Colors.METRIC_BORDER)
|
||||
|
||||
dot_r = 11
|
||||
label = tr(metric.label)
|
||||
value = tr(metric.value)
|
||||
dot_x = metric_rect.x + STATUS_DOT_CENTER_X
|
||||
text_x = metric_rect.x + STATUS_TEXT_X
|
||||
text_col_w = metric_rect.width - STATUS_TEXT_X - STATUS_TEXT_RIGHT_MARGIN
|
||||
|
||||
label_size = STATUS_LABEL_SIZE
|
||||
value_size = STATUS_VALUE_SIZE
|
||||
label_text_size = measure_text_cached(self._font_regular, label, label_size)
|
||||
value_text_size = measure_text_cached(self._font_bold, value, value_size)
|
||||
while value_text_size.x > text_col_w and value_size > 30:
|
||||
value_size -= 2
|
||||
value_text_size = measure_text_cached(self._font_bold, value, value_size)
|
||||
|
||||
text_h = label_text_size.y + value_text_size.y + STATUS_LINE_GAP
|
||||
label_y = metric_rect.y + (metric_rect.height - text_h) / 2
|
||||
value_y = label_y + label_text_size.y + STATUS_LINE_GAP
|
||||
dot_y = label_y + text_h / 2
|
||||
rl.draw_circle(int(dot_x), int(dot_y), dot_r, metric.color)
|
||||
|
||||
rl.begin_scissor_mode(int(text_x), int(metric_rect.y), int(text_col_w), int(metric_rect.height))
|
||||
rl.draw_text_ex(self._font_regular, label, rl.Vector2(int(text_x), int(label_y)), label_size, 0, rl.Color(185, 185, 190, 255))
|
||||
rl.draw_text_ex(self._font_bold, value, rl.Vector2(int(text_x), int(value_y)), value_size, 0, Colors.WHITE)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_bookmark_icon(self, center_x: float, center_y: float, tint: rl.Color, cutout_color: rl.Color):
|
||||
icon_w = 78
|
||||
icon_h = 96
|
||||
icon_rect = rl.Rectangle(center_x - icon_w / 2, center_y - icon_h / 2, icon_w, icon_h)
|
||||
rl.draw_rectangle_rounded(icon_rect, 0.18, 14, tint)
|
||||
|
||||
notch_top = icon_rect.y + icon_rect.height - 31
|
||||
notch_left = icon_rect.x + 10
|
||||
notch_right = icon_rect.x + icon_rect.width - 10
|
||||
notch_bottom = icon_rect.y + icon_rect.height
|
||||
rl.draw_triangle(
|
||||
rl.Vector2(center_x, notch_top),
|
||||
rl.Vector2(notch_left, notch_bottom),
|
||||
rl.Vector2(notch_right, notch_bottom),
|
||||
cutout_color,
|
||||
)
|
||||
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal file
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
|
||||
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
MARGIN = 40
|
||||
SPACING = 25
|
||||
|
||||
|
||||
class StatsLayout(Widget):
|
||||
"""Offroad Stats screen: drive stats (miles / time / routes) with a back header."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._header = self._child(ScreenHeader(lambda: tr("Stats")))
|
||||
self._trips = self._child(TripsLayout())
|
||||
|
||||
def set_on_back(self, cb: Callable[[], None]) -> None:
|
||||
self._header.set_on_back(cb)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
|
||||
self._header.render(header_rect)
|
||||
|
||||
content_y = header_rect.y + HEADER_HEIGHT + SPACING
|
||||
content_rect = rl.Rectangle(
|
||||
rect.x + MARGIN, content_y, rect.width - 2 * MARGIN, rect.y + rect.height - content_y - MARGIN
|
||||
)
|
||||
self._trips.render(content_rect)
|
||||
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
File diff suppressed because it is too large
Load Diff
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal file
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from iqpilot.common.api import Api
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 2
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_token(dongle_id: str, t: int):
|
||||
if not system_time_valid():
|
||||
raise RuntimeError("System time is not valid, cannot generate token")
|
||||
|
||||
return Api(dongle_id).get_token(expiry_hours=TOKEN_EXPIRY_HOURS)
|
||||
|
||||
|
||||
def get_token(dongle_id: str):
|
||||
return _get_token(dongle_id, int(time.monotonic() / (TOKEN_EXPIRY_HOURS / 2 * 60 * 60)))
|
||||
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal file
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Public, konn3kt-agnostic shim to the private cloud route client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
|
||||
UPLOAD_NONE = "none"
|
||||
UPLOAD_UPLOADING = "uploading"
|
||||
UPLOAD_UPLOADED = "uploaded"
|
||||
|
||||
@cache
|
||||
def _load_cloud():
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
return import_verified_module("iqpilot_hephaestusd_private",
|
||||
"iqpilot_private.konn3kt.hephaestus.cloud_routes")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def cloud_available() -> bool:
|
||||
return _load_cloud() is not None
|
||||
|
||||
|
||||
def get_dongle_id() -> str | None:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.get_dongle_id()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_cloud_routes(dongle_id: str) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.list_cloud_routes(dongle_id)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def cloud_route_road_segments(dongle_id: str, fullname: str) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.cloud_route_road_segments(dongle_id, fullname)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def cloud_route_camera_urls(dongle_id: str, fullname: str, camera: str = "road") -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.cloud_route_camera_urls(dongle_id, fullname, camera)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def request_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.request_mp4_conversion(dongle_id, segment_canonical_name, camera)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.get_mp4_conversion(dongle_id, segment_canonical_name, camera)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def merge_routes(local_routes: list, cloud_routes: list) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is not None:
|
||||
try:
|
||||
return cloud.merge_routes(local_routes, cloud_routes)
|
||||
except Exception:
|
||||
pass
|
||||
return [_LocalOnly(local) for local in local_routes]
|
||||
|
||||
|
||||
class _LocalOnly:
|
||||
def __init__(self, local):
|
||||
self.name = local.name
|
||||
self.local = local
|
||||
self.cloud = None
|
||||
self.is_local = True
|
||||
self.is_cloud = False
|
||||
self.upload_state = UPLOAD_NONE
|
||||
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal file
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
|
||||
_utc_offset_cache: int | None = None
|
||||
|
||||
|
||||
def utc_offset_hours() -> int:
|
||||
"""Approximate local UTC offset from the device's last GPS longitude (comma devices run in UTC
|
||||
with no timezone configured). ~1h imprecise (ignores DST/political borders) but auto and close;
|
||||
cached for the session."""
|
||||
global _utc_offset_cache
|
||||
if _utc_offset_cache is not None:
|
||||
return _utc_offset_cache
|
||||
offset = 0
|
||||
try:
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
|
||||
lat, lon, _, have_fix = current_or_last_gps_position()
|
||||
if have_fix:
|
||||
offset = int(round(lon / 15.0)) # solar offset ≈ standard-time zone
|
||||
# Crude DST: add an hour in the local warm season (northern spring–autumn, southern inverse).
|
||||
month = datetime.now(timezone.utc).month
|
||||
northern_dst = 3 <= month <= 10
|
||||
if (lat >= 0 and northern_dst) or (lat < 0 and not northern_dst):
|
||||
offset += 1
|
||||
offset = max(-12, min(14, offset))
|
||||
except Exception:
|
||||
offset = 0
|
||||
_utc_offset_cache = offset
|
||||
return offset
|
||||
|
||||
|
||||
def format_local_time(epoch_seconds: float) -> str:
|
||||
if epoch_seconds <= 0:
|
||||
return "Recorded route"
|
||||
dt = datetime.fromtimestamp(epoch_seconds, timezone(timedelta(hours=utc_offset_hours())))
|
||||
return f"{dt.strftime('%b')} {dt.day} {dt.strftime('%I:%M %p').lstrip('0').lower()}"
|
||||
|
||||
# Dongleless on-device segment directory, e.g. "00000051--3141cf1d76--6".
|
||||
# The stock tools/lib Route + RE parsers require a 16-hex dongle id + '|' delimiter (cloud naming)
|
||||
# and reject these, which is why the Routes page was empty. We parse them directly instead.
|
||||
SEGMENT_DIR_RE = re.compile(r"^(?P<route>[0-9a-f]{8}--[0-9a-z]{10})--(?P<seg>\d+)$")
|
||||
|
||||
# Logical camera -> on-disk VIDEO file. Order defines the player's selector order.
|
||||
# Road uses qcamera.ts (H.264, ~1052x660): small enough to software-decode at hundreds of fps and
|
||||
# it carries the audio track. Wide/Driver are full-res HEVC (hardware-decoded offroad). The
|
||||
# streaming decoder handles both containers via ffmpeg's concat demuxer.
|
||||
CAMERA_FILES: dict[str, str] = {
|
||||
"road": "qcamera.ts",
|
||||
"wide": "ecamera.hevc",
|
||||
"driver": "dcamera.hevc",
|
||||
}
|
||||
CAMERA_LABELS: dict[str, str] = {
|
||||
"road": "Road Cam",
|
||||
"wide": "Wide Cam",
|
||||
"driver": "Driver Cam",
|
||||
}
|
||||
# The road preview (qcamera.ts) is the only file with an audio track, and the smaller
|
||||
# cloud-streamable road video. It is not a FrameReader source (TS container).
|
||||
AUDIO_CAMERA_FILE = "qcamera.ts"
|
||||
NOMINAL_SEGMENT_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalRouteInfo:
|
||||
name: str
|
||||
label: str
|
||||
subtitle: str
|
||||
segment_count: int
|
||||
cameras: tuple[str, ...]
|
||||
modified_at: float
|
||||
duration_s: float
|
||||
distance_miles: float | None = None
|
||||
|
||||
|
||||
def _scan_segments(root: Path) -> dict[str, dict[int, Path]]:
|
||||
"""Group dongleless segment dirs under `root` by route id -> {segment_num: dir}."""
|
||||
routes: dict[str, dict[int, Path]] = {}
|
||||
if not root.exists():
|
||||
return routes
|
||||
for child in root.iterdir():
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
m = SEGMENT_DIR_RE.match(child.name)
|
||||
if m is None:
|
||||
continue
|
||||
routes.setdefault(m.group("route"), {})[int(m.group("seg"))] = child
|
||||
return routes
|
||||
|
||||
|
||||
def _cameras_present(seg_dir: Path) -> tuple[str, ...]:
|
||||
present = []
|
||||
for cam, filename in CAMERA_FILES.items():
|
||||
try:
|
||||
if (seg_dir / filename).exists():
|
||||
present.append(cam)
|
||||
except OSError:
|
||||
continue
|
||||
return tuple(present)
|
||||
|
||||
|
||||
def _format_route_time(ts: float) -> str:
|
||||
return format_local_time(ts)
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
s = max(0, int(round(seconds)))
|
||||
h, rem = divmod(s, 3600)
|
||||
m, sec = divmod(rem, 60)
|
||||
if h:
|
||||
return f"{h}h {m:02d}m"
|
||||
return f"{m}:{sec:02d}"
|
||||
|
||||
|
||||
def local_route_camera_paths(route_name: str, camera: str = "road", log_root: str | Path | None = None) -> list[str]:
|
||||
"""Ordered per-segment file paths for one camera of a local route (for FrameReader)."""
|
||||
root = Path(log_root or Paths.log_root())
|
||||
segments = _scan_segments(root).get(route_name, {})
|
||||
filename = CAMERA_FILES.get(camera, CAMERA_FILES["road"])
|
||||
paths: list[str] = []
|
||||
for seg_num in sorted(segments):
|
||||
path = segments[seg_num] / filename
|
||||
try:
|
||||
if path.exists():
|
||||
paths.append(path.as_posix())
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def local_route_audio_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
|
||||
"""Ordered per-segment qcamera.ts paths (the only files with an audio track)."""
|
||||
root = Path(log_root or Paths.log_root())
|
||||
segments = _scan_segments(root).get(route_name, {})
|
||||
paths: list[str] = []
|
||||
for seg_num in sorted(segments):
|
||||
path = segments[seg_num] / AUDIO_CAMERA_FILE
|
||||
try:
|
||||
if path.exists():
|
||||
paths.append(path.as_posix())
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def local_route_qlog_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
|
||||
"""Ordered per-segment qlog paths for a local route."""
|
||||
root = Path(log_root or Paths.log_root())
|
||||
segments = _scan_segments(root).get(route_name, {})
|
||||
paths: list[str] = []
|
||||
for seg_num in sorted(segments):
|
||||
for name in ("qlog.zst", "qlog"):
|
||||
path = segments[seg_num] / name
|
||||
try:
|
||||
if path.exists():
|
||||
paths.append(path.as_posix())
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def compute_route_distance_miles(route_name: str, log_root: str | Path | None = None) -> float:
|
||||
"""Total driven distance (miles) by integrating carState.vEgo over the route's qlogs.
|
||||
|
||||
Uses vEgo (not GPS) so it still works on cars with broken GPS. Decimated qlog cadence is
|
||||
plenty for a distance total. This reads every segment's qlog, so callers should run it off the
|
||||
UI thread."""
|
||||
from iqpilot.tools.lib.logreader import LogReader
|
||||
|
||||
total_m = 0.0
|
||||
for qlog_path in local_route_qlog_paths(route_name, log_root):
|
||||
last_t: float | None = None
|
||||
try:
|
||||
for msg in LogReader(qlog_path):
|
||||
if msg.which() != "carState":
|
||||
continue
|
||||
t = msg.logMonoTime * 1e-9
|
||||
v = float(msg.carState.vEgo)
|
||||
# Guard against segment boundaries / gaps: only integrate contiguous samples.
|
||||
if last_t is not None and 0.0 < t - last_t < 1.0:
|
||||
total_m += v * (t - last_t)
|
||||
last_t = t
|
||||
except Exception:
|
||||
continue
|
||||
return total_m * 0.000621371
|
||||
|
||||
|
||||
def get_local_route(route_name: str, log_root: str | Path | None = None) -> LocalRouteInfo | None:
|
||||
root = Path(log_root or Paths.log_root())
|
||||
segments = _scan_segments(root).get(route_name)
|
||||
if not segments:
|
||||
return None
|
||||
return _build_info(route_name, segments)
|
||||
|
||||
|
||||
def _build_info(route_name: str, segments: dict[int, Path]) -> LocalRouteInfo:
|
||||
seg_nums = sorted(segments)
|
||||
mtimes = []
|
||||
for seg_num in seg_nums:
|
||||
try:
|
||||
mtimes.append(segments[seg_num].stat().st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
modified_at = max(mtimes) if mtimes else 0.0
|
||||
started_at = min(mtimes) if mtimes else 0.0
|
||||
|
||||
# Cameras available anywhere in the route (union across segments).
|
||||
cameras: list[str] = []
|
||||
for cam in CAMERA_FILES:
|
||||
if any((segments[s] / CAMERA_FILES[cam]).exists() for s in seg_nums):
|
||||
cameras.append(cam)
|
||||
|
||||
segment_count = len(seg_nums)
|
||||
duration_s = segment_count * NOMINAL_SEGMENT_SECONDS
|
||||
cam_names = ", ".join(CAMERA_LABELS[c] for c in cameras) if cameras else "no cameras"
|
||||
subtitle = f"{_format_duration(duration_s)} · {cam_names}"
|
||||
|
||||
return LocalRouteInfo(
|
||||
name=route_name,
|
||||
label=_format_route_time(started_at),
|
||||
subtitle=subtitle,
|
||||
segment_count=segment_count,
|
||||
cameras=tuple(cameras),
|
||||
modified_at=modified_at,
|
||||
duration_s=duration_s,
|
||||
)
|
||||
|
||||
|
||||
def list_local_routes(log_root: str | Path | None = None, limit: int = 100) -> list[LocalRouteInfo]:
|
||||
root = Path(log_root or Paths.log_root())
|
||||
routes = _scan_segments(root)
|
||||
infos = [_build_info(name, segs) for name, segs in routes.items()]
|
||||
infos.sort(key=lambda info: info.modified_at, reverse=True)
|
||||
return infos[:limit]
|
||||
35
iqpilot/selfdrive/ui/lib/motd.py
Normal file
35
iqpilot/selfdrive/ui/lib/motd.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
FALLBACK_MOTDS = ("Drive safely. Stay focused.",)
|
||||
_BUNDLE_NAME = "iqpilot_hephaestusd_private"
|
||||
_MODULE_NAME = "iqpilot_private.konn3kt.hephaestus.motd"
|
||||
|
||||
|
||||
def _dongle_id() -> str | None:
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
return Params().get("DongleId", encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_messages(messages: object) -> list[str]:
|
||||
if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
|
||||
return []
|
||||
return [message.strip() for message in messages if isinstance(message, str) and message.strip()]
|
||||
|
||||
|
||||
def load_motds(dongle_id: str | None = None) -> list[str]:
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
module = import_verified_module(_BUNDLE_NAME, _MODULE_NAME)
|
||||
messages = module.messages_for_dongle(_dongle_id() if dongle_id is None else dongle_id)
|
||||
cleaned = _clean_messages(messages)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
except Exception:
|
||||
pass
|
||||
return list(FALLBACK_MOTDS)
|
||||
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal file
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
|
||||
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = False
|
||||
_GPS_SERVICES = ("gpsLocationExternal", "gpsLocation")
|
||||
_POSITION_PARAM_KEYS = ("LastGPSPosition", "LastGPSPositionIQLoc")
|
||||
|
||||
|
||||
def _decode_param(value) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="ignore").strip()
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_mapbox_token(params: Params | None = None) -> str:
|
||||
global _MAPBOX_DEFAULT_HELPER_UNAVAILABLE
|
||||
|
||||
params = params or Params()
|
||||
token = _decode_param(params.get("MapboxToken"))
|
||||
if token:
|
||||
return token
|
||||
|
||||
if _MAPBOX_DEFAULT_HELPER_UNAVAILABLE:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
runtime_common = import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.runtime_common")
|
||||
except Exception:
|
||||
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = True
|
||||
return ""
|
||||
|
||||
for args in ((params,), ()):
|
||||
try:
|
||||
token = _decode_param(runtime_common.ensure_default_mapbox_token(*args))
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception:
|
||||
token = ""
|
||||
|
||||
if not token:
|
||||
token = _decode_param(params.get("MapboxToken"))
|
||||
if token:
|
||||
return token
|
||||
|
||||
return _decode_param(params.get("MapboxToken"))
|
||||
|
||||
|
||||
def has_mapbox_token(params: Params | None = None) -> bool:
|
||||
return bool(resolve_mapbox_token(params))
|
||||
|
||||
|
||||
def _valid_lat_lon(lat: float, lon: float) -> bool:
|
||||
return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4)
|
||||
|
||||
|
||||
def _float_field(data: dict, *names: str) -> float:
|
||||
for name in names:
|
||||
if name in data:
|
||||
return float(data.get(name) or 0.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _position_from_json(raw) -> tuple[float, float, float, bool]:
|
||||
text = _decode_param(raw)
|
||||
if not text:
|
||||
return 0.0, 0.0, 0.0, False
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if not isinstance(data, dict):
|
||||
return 0.0, 0.0, 0.0, False
|
||||
lat = _float_field(data, "latitude", "lat")
|
||||
lon = _float_field(data, "longitude", "lon", "lng")
|
||||
if _valid_lat_lon(lat, lon):
|
||||
return lat, lon, _float_field(data, "bearing", "bearingDeg"), True
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def _position_from_msg(msg, lat_name: str = "latitude", lon_name: str = "longitude",
|
||||
bearing_name: str = "bearingDeg") -> tuple[float, float, float, bool]:
|
||||
try:
|
||||
lat = float(getattr(msg, lat_name, 0.0))
|
||||
lon = float(getattr(msg, lon_name, 0.0))
|
||||
if _valid_lat_lon(lat, lon):
|
||||
return lat, lon, float(getattr(msg, bearing_name, 0.0)), True
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def _position_from_params(params: Params) -> tuple[float, float, float, bool]:
|
||||
for key in _POSITION_PARAM_KEYS:
|
||||
lat, lon, bearing, valid = _position_from_json(params.get(key))
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def current_or_last_gps_position(params: Params | None = None) -> tuple[float, float, float, bool]:
|
||||
# ui_state is imported lazily AND guarded: night-mode init constructs the ui_state singleton,
|
||||
# which calls in here before the module finishes importing. In that window the import raises
|
||||
# (partially initialized module) — fall back to the params path (Night Mode passes self.params),
|
||||
# since there's no live GPS during boot anyway.
|
||||
try:
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
except ImportError:
|
||||
ui_state = None
|
||||
|
||||
if ui_state is not None:
|
||||
for service in _GPS_SERVICES:
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_msg(ui_state.sm[service])
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_msg(
|
||||
ui_state.sm["iqNavRenderState"],
|
||||
lat_name="currentLatitude",
|
||||
lon_name="currentLongitude",
|
||||
bearing_name="bearingDeg",
|
||||
)
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explicit_params = params is not None
|
||||
params = params or (ui_state.params if ui_state is not None else Params())
|
||||
lat, lon, bearing, valid = _position_from_params(params)
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
|
||||
if not explicit_params and platform.system() != "Darwin" and Path("/dev/shm/params/d").exists():
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_params(Params("/dev/shm/params"))
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0.0, 0.0, 0.0, False
|
||||
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal file
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Destination search + persistence for the offroad Navigate screen.
|
||||
|
||||
Uses Mapbox's Search Box API (the same public Mapbox service the nav map preview already calls) for
|
||||
POI-and-address autocomplete biased to the device's location — so "Walmart" returns the nearest
|
||||
Walmart store, not a street named Walmart, and partial addresses complete as you type. Selecting a
|
||||
result writes NavigationDestination, which navd picks up to build the route.
|
||||
|
||||
Home/Work/Recents live in a small JSON file on /data (persistent, no new param key needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
import requests
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import resolve_mapbox_token, current_or_last_gps_position
|
||||
|
||||
SEARCHBOX = "https://api.mapbox.com/search/searchbox/v1"
|
||||
FAVORITES_PATH = "/data/nav_favorites.json"
|
||||
MAX_RESULTS = 6
|
||||
MAX_RECENTS = 8
|
||||
|
||||
|
||||
def _load_amap_client():
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
return import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.amap_client")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_amap_client = _load_amap_client()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
name: str
|
||||
address: str
|
||||
mapbox_id: str = ""
|
||||
distance_m: float | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
provider: str = "mapbox"
|
||||
|
||||
@property
|
||||
def has_coords(self) -> bool:
|
||||
return self.lat is not None and self.lon is not None
|
||||
|
||||
|
||||
class NavSearch:
|
||||
"""Threaded, debounced Search Box autocomplete. The UI calls search() as the user types and reads
|
||||
results()/searching each frame; a stale query's results are dropped so only the latest shows."""
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._session = str(uuid.uuid4())
|
||||
self._lock = threading.Lock()
|
||||
self._results: list[SearchResult] = []
|
||||
self._seq = 0
|
||||
self._last_query = ""
|
||||
self._searching = False
|
||||
self._amap_adcode = ""
|
||||
|
||||
def new_session(self) -> None:
|
||||
# A Search Box "session" groups suggest+retrieve for billing; start one per search visit.
|
||||
self._session = str(uuid.uuid4())
|
||||
with self._lock:
|
||||
self._results = []
|
||||
self._last_query = ""
|
||||
self._amap_adcode = ""
|
||||
|
||||
def results(self) -> list[SearchResult]:
|
||||
with self._lock:
|
||||
return list(self._results)
|
||||
|
||||
@property
|
||||
def searching(self) -> bool:
|
||||
return self._searching
|
||||
|
||||
def search(self, query: str) -> None:
|
||||
query = query.strip()
|
||||
if query == self._last_query:
|
||||
return
|
||||
self._last_query = query
|
||||
self._seq += 1
|
||||
seq = self._seq
|
||||
if len(query) < 2:
|
||||
with self._lock:
|
||||
self._results = []
|
||||
self._searching = False
|
||||
return
|
||||
self._searching = True
|
||||
threading.Thread(target=self._do_search, args=(query, seq), daemon=True).start()
|
||||
|
||||
def _do_search(self, query: str, seq: int) -> None:
|
||||
try:
|
||||
lat, lon, _, fix = current_or_last_gps_position(self._params)
|
||||
position = SimpleNamespace(latitude=lat, longitude=lon) if fix else None
|
||||
use_amap = _amap_client is not None and _amap_client.is_mainland_china_configured(self._params, position)
|
||||
if use_amap:
|
||||
key = _amap_client.get_key(self._params)
|
||||
if not self._amap_adcode and position is not None:
|
||||
self._amap_adcode = _amap_client.reverse_adcode(position, key)
|
||||
amap_results = _amap_client.autocomplete(
|
||||
query,
|
||||
key,
|
||||
city=self._amap_adcode,
|
||||
position=position,
|
||||
)
|
||||
try:
|
||||
self._params.put("AmapStatus", _amap_client.status())
|
||||
except Exception:
|
||||
pass
|
||||
results = [
|
||||
SearchResult(
|
||||
name=item.name,
|
||||
address=item.address,
|
||||
mapbox_id=item.provider_id,
|
||||
lat=item.latitude,
|
||||
lon=item.longitude,
|
||||
provider="amap",
|
||||
)
|
||||
for item in amap_results[:MAX_RESULTS]
|
||||
]
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = results
|
||||
return
|
||||
|
||||
token = resolve_mapbox_token(self._params)
|
||||
params = {"q": query, "access_token": token, "session_token": self._session,
|
||||
"limit": MAX_RESULTS, "language": "en"}
|
||||
if fix:
|
||||
params["proximity"] = f"{lon},{lat}"
|
||||
resp = requests.get(f"{SEARCHBOX}/suggest", params=params, timeout=8)
|
||||
resp.raise_for_status()
|
||||
results = []
|
||||
for s in resp.json().get("suggestions", []):
|
||||
mid = s.get("mapbox_id")
|
||||
addr = s.get("full_address") or s.get("place_formatted") or ""
|
||||
# Skip brand/category refinement rows (e.g. "Walmart · Brand") — not a single routable place.
|
||||
if not mid or not addr or s.get("feature_type") in ("category", "brand"):
|
||||
continue
|
||||
results.append(SearchResult(name=s.get("name", ""), address=addr, mapbox_id=mid,
|
||||
distance_m=s.get("distance")))
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = results
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.suggest_failed", error=str(e))
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = []
|
||||
finally:
|
||||
if seq == self._seq:
|
||||
self._searching = False
|
||||
|
||||
def retrieve(self, result: SearchResult) -> SearchResult | None:
|
||||
"""Resolve a suggestion's coordinates (Search Box suggest omits them by design)."""
|
||||
if result.has_coords:
|
||||
return result
|
||||
try:
|
||||
if result.provider == "amap":
|
||||
if _amap_client is None:
|
||||
return None
|
||||
item = _amap_client.place_detail(result.mapbox_id, _amap_client.get_key(self._params))
|
||||
try:
|
||||
self._params.put("AmapStatus", _amap_client.status())
|
||||
except Exception:
|
||||
pass
|
||||
if item is None or item.latitude is None or item.longitude is None:
|
||||
return None
|
||||
result.lat, result.lon = item.latitude, item.longitude
|
||||
result.name = item.name or result.name
|
||||
result.address = item.address or result.address
|
||||
return result
|
||||
|
||||
token = resolve_mapbox_token(self._params)
|
||||
resp = requests.get(f"{SEARCHBOX}/retrieve/{result.mapbox_id}",
|
||||
params={"access_token": token, "session_token": self._session}, timeout=8)
|
||||
resp.raise_for_status()
|
||||
feats = resp.json().get("features", [])
|
||||
if not feats:
|
||||
return None
|
||||
coords = feats[0]["geometry"]["coordinates"]
|
||||
props = feats[0].get("properties", {})
|
||||
result.lon, result.lat = float(coords[0]), float(coords[1])
|
||||
result.name = props.get("name") or result.name
|
||||
result.address = props.get("full_address") or result.address
|
||||
return result
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.retrieve_failed", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
# --- destination + favorites persistence -------------------------------------------------------
|
||||
|
||||
def set_destination(lat: float, lon: float, name: str) -> None:
|
||||
"""Hand a destination to navd (it routes off NavigationDestination). Mirrors hephaestusd's
|
||||
setNavDestination so a fresh route is always recomputed."""
|
||||
params = Params()
|
||||
params.remove("AthenaNavigationRoute")
|
||||
params.put_bool("NavigationActive", False)
|
||||
# NavigationDestination is a JSON-typed param: pass the object, not a pre-serialized string.
|
||||
params.put("NavigationDestination", {"latitude": float(lat), "longitude": float(lon), "name": name or ""})
|
||||
|
||||
|
||||
def cancel_navigation() -> None:
|
||||
"""Clear the active route/destination so navd stops navigating."""
|
||||
params = Params()
|
||||
params.remove("NavigationDestination")
|
||||
params.remove("AthenaNavigationRoute")
|
||||
params.put_bool("NavigationActive", False)
|
||||
|
||||
|
||||
def has_active_destination() -> bool:
|
||||
try:
|
||||
return bool(Params().get("NavigationDestination"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_favorites() -> dict:
|
||||
try:
|
||||
with open(FAVORITES_PATH) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_favorites(data: dict) -> None:
|
||||
try:
|
||||
tmp = FAVORITES_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f)
|
||||
import os
|
||||
os.replace(tmp, FAVORITES_PATH)
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.save_favorites_failed", error=str(e))
|
||||
|
||||
|
||||
def _place_to_result(place: dict | None) -> SearchResult | None:
|
||||
if not place:
|
||||
return None
|
||||
try:
|
||||
return SearchResult(name=place.get("name", ""), address=place.get("address", ""),
|
||||
lat=float(place["lat"]), lon=float(place["lon"]))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_home() -> SearchResult | None:
|
||||
return _place_to_result(_load_favorites().get("home"))
|
||||
|
||||
|
||||
def get_work() -> SearchResult | None:
|
||||
return _place_to_result(_load_favorites().get("work"))
|
||||
|
||||
|
||||
def _place_dict(r: SearchResult) -> dict:
|
||||
return {"name": r.name, "address": r.address, "lat": r.lat, "lon": r.lon}
|
||||
|
||||
|
||||
def save_home(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["home"] = _place_dict(r)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def save_work(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["work"] = _place_dict(r)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_home() -> None:
|
||||
data = _load_favorites()
|
||||
data.pop("home", None)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_work() -> None:
|
||||
data = _load_favorites()
|
||||
data.pop("work", None)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_recent(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["recents"] = [p for p in data.get("recents", [])
|
||||
if not (abs(p.get("lat", 0) - (r.lat or 0)) < 1e-5 and abs(p.get("lon", 0) - (r.lon or 0)) < 1e-5)]
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def get_recents() -> list[SearchResult]:
|
||||
out = []
|
||||
for p in _load_favorites().get("recents", []):
|
||||
r = _place_to_result(p)
|
||||
if r is not None:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def add_recent(r: SearchResult) -> None:
|
||||
if not r.has_coords:
|
||||
return
|
||||
data = _load_favorites()
|
||||
recents = [p for p in data.get("recents", [])
|
||||
if not (abs(p.get("lat", 0) - r.lat) < 1e-5 and abs(p.get("lon", 0) - r.lon) < 1e-5)]
|
||||
recents.insert(0, _place_dict(r))
|
||||
data["recents"] = recents[:MAX_RECENTS]
|
||||
_save_favorites(data)
|
||||
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal file
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from enum import IntEnum
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.konn3kt.cloud_client import Konn3ktApi
|
||||
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID, get_cached_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import PC
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
|
||||
|
||||
class PairState(IntEnum):
|
||||
UNKNOWN = -2
|
||||
UNPAIRED = -1
|
||||
PAIRED = 0
|
||||
|
||||
|
||||
class PrimeState:
|
||||
FETCH_INTERVAL = 5.0 # seconds between konn3kt pairing checks
|
||||
API_TIMEOUT = 10.0 # seconds for konn3kt API requests
|
||||
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._lock = threading.Lock()
|
||||
# Must be computed at runtime (OPENPILOT_PREFIX can change paths).
|
||||
# Keep a writable fallback in /tmp in case /persist becomes read-only.
|
||||
self._konn3kt_state_paths = [
|
||||
Path(Paths.persist_root()) / "comma" / "konn3kt_prime_type",
|
||||
Path(Paths.config_root()) / "konn3kt_prime_type",
|
||||
]
|
||||
|
||||
if PC and os.getenv("KONN3KT_DEV_PAIRING") == "1":
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
self._write_cached_state(PairState.UNPAIRED)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"dev pairing identity setup failed: {e}")
|
||||
|
||||
self.pair_state: PairState = self._load_initial_state()
|
||||
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def _write_cached_state(self, pair_state: PairState) -> None:
|
||||
payload = str(int(pair_state))
|
||||
for path in self._konn3kt_state_paths:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(payload)
|
||||
return
|
||||
except OSError:
|
||||
continue
|
||||
except Exception:
|
||||
cloudlog.exception("failed to write konn3kt pairing cache")
|
||||
return
|
||||
cloudlog.warning("failed to write konn3kt pairing cache to any path")
|
||||
|
||||
def _coerce(self, value: int | None) -> PairState:
|
||||
if value is None:
|
||||
return PairState.UNKNOWN
|
||||
if value >= 0:
|
||||
return PairState.PAIRED
|
||||
if value == PairState.UNPAIRED:
|
||||
return PairState.UNPAIRED
|
||||
return PairState.UNKNOWN
|
||||
|
||||
def _load_initial_state(self) -> PairState:
|
||||
env_val = os.getenv("PRIME_TYPE")
|
||||
if env_val is not None:
|
||||
try:
|
||||
return self._coerce(int(env_val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for path in self._konn3kt_state_paths:
|
||||
try:
|
||||
if path.is_file():
|
||||
return self._coerce(int(path.read_text().strip()))
|
||||
except Exception:
|
||||
cloudlog.exception("failed to read konn3kt pairing cache")
|
||||
return PairState.UNKNOWN
|
||||
|
||||
def _refresh_pair_status(self) -> None:
|
||||
dongle_id = get_cached_dongle_id(self._params, prefer_readonly=True)
|
||||
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
|
||||
return
|
||||
|
||||
# the JWT can't be minted until the clock is NTP-synced; at boot skip
|
||||
# quietly instead of error-spamming every retry
|
||||
if not system_time_valid():
|
||||
return
|
||||
|
||||
try:
|
||||
api = Konn3ktApi(dongle_id)
|
||||
resp = api.get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=api.get_token())
|
||||
if resp.status_code == 200:
|
||||
paired = bool(resp.json().get("is_paired", False))
|
||||
self.set_paired(paired)
|
||||
elif resp.status_code == 404:
|
||||
self.set_paired(False)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"failed to fetch konn3kt pairing status: {e}")
|
||||
|
||||
def set_paired(self, paired: bool) -> None:
|
||||
new_state = PairState.PAIRED if paired else PairState.UNPAIRED
|
||||
with self._lock:
|
||||
if new_state != self.pair_state:
|
||||
self.pair_state = new_state
|
||||
self._write_cached_state(new_state)
|
||||
cloudlog.info(f"konn3kt pairing updated to {new_state}")
|
||||
|
||||
def _worker_thread(self) -> None:
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
while self._running:
|
||||
if not ui_state.started and device._awake:
|
||||
self._refresh_pair_status()
|
||||
|
||||
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(self.SLEEP_INTERVAL)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def is_paired(self) -> bool:
|
||||
with self._lock:
|
||||
return self.pair_state > PairState.UNPAIRED
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
36
iqpilot/selfdrive/ui/lib/solar.py
Normal file
36
iqpilot/selfdrive/ui/lib/solar.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
_J2000 = datetime(2000, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
SUNSET_ELEVATION_DEG = -0.833 # standard sunset/sunrise threshold, corrected for atmospheric refraction
|
||||
|
||||
|
||||
def sun_elevation_deg(lat: float, lon: float, dt_utc: datetime | None = None) -> float:
|
||||
"""Low-precision solar elevation angle (accurate to well under a degree), per the
|
||||
standard Meeus-derived approximation. lat/lon in degrees (lon positive east)."""
|
||||
dt_utc = dt_utc or datetime.now(UTC)
|
||||
n = (dt_utc - _J2000).total_seconds() / 86400.0
|
||||
|
||||
mean_lon = math.radians((280.460 + 0.9856474 * n) % 360)
|
||||
mean_anomaly = math.radians((357.528 + 0.9856003 * n) % 360)
|
||||
ecliptic_lon = (mean_lon + math.radians(1.915) * math.sin(mean_anomaly)
|
||||
+ math.radians(0.020) * math.sin(2 * mean_anomaly))
|
||||
obliquity = math.radians(23.439 - 0.0000004 * n)
|
||||
|
||||
declination = math.asin(math.sin(obliquity) * math.sin(ecliptic_lon))
|
||||
right_ascension = math.atan2(math.cos(obliquity) * math.sin(ecliptic_lon), math.cos(ecliptic_lon))
|
||||
|
||||
equation_of_time_deg = math.degrees(mean_lon - right_ascension)
|
||||
equation_of_time_deg = (equation_of_time_deg + 180) % 360 - 180
|
||||
|
||||
utc_hours = dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600
|
||||
hour_angle = math.radians(15 * (utc_hours - 12) + lon + equation_of_time_deg)
|
||||
|
||||
lat_rad = math.radians(lat)
|
||||
elevation = math.asin(math.sin(lat_rad) * math.sin(declination)
|
||||
+ math.cos(lat_rad) * math.cos(declination) * math.cos(hour_angle))
|
||||
return math.degrees(elevation)
|
||||
|
||||
|
||||
def is_after_sunset(lat: float, lon: float, dt_utc: datetime | None = None) -> bool:
|
||||
return sun_elevation_deg(lat, lon, dt_utc) < SUNSET_ELEVATION_DEG
|
||||
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal file
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
# Shared, throttled current-Wi-Fi-SSID lookup for the status bars (home pill + onroad sidebar).
|
||||
# The SSID isn't in deviceState, so we shell out to NetworkManager on a background thread and cache
|
||||
# the result; the render thread only ever reads the cached string.
|
||||
|
||||
_ssid = ""
|
||||
_last_fetch = 0.0
|
||||
_fetching = False
|
||||
_lock = threading.Lock()
|
||||
REFRESH_SECONDS = 10.0
|
||||
|
||||
|
||||
def _read_ssid() -> str:
|
||||
try:
|
||||
out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"],
|
||||
capture_output=True, text=True, timeout=4).stdout
|
||||
for line in out.splitlines():
|
||||
if line.startswith("yes:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
name = subprocess.run(["iwgetid", "-r"], capture_output=True, text=True, timeout=4).stdout.strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _fetch() -> None:
|
||||
global _ssid, _last_fetch, _fetching
|
||||
name = _read_ssid()
|
||||
with _lock:
|
||||
_ssid = name
|
||||
_last_fetch = time.monotonic()
|
||||
_fetching = False
|
||||
|
||||
|
||||
def current_ssid(on_wifi: bool) -> str:
|
||||
"""Return the connected SSID (or "" if unknown / not on Wi-Fi). Kicks a throttled background
|
||||
refresh; never blocks the caller."""
|
||||
global _fetching
|
||||
if not on_wifi:
|
||||
return ""
|
||||
with _lock:
|
||||
if not _fetching and time.monotonic() - _last_fetch > REFRESH_SECONDS:
|
||||
_fetching = True
|
||||
threading.Thread(target=_fetch, daemon=True).start()
|
||||
return _ssid
|
||||
0
iqpilot/selfdrive/ui/mici/layouts/__init__.py
Normal file
0
iqpilot/selfdrive/ui/mici/layouts/__init__.py
Normal file
422
iqpilot/selfdrive/ui/mici/layouts/home.py
Normal file
422
iqpilot/selfdrive/ui/mici/layouts/home.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from iqpilot.cereal import log
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets.label import gui_label, MiciLabel, UnifiedLabel
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.text import wrap_text
|
||||
from iqpilot.system.version import training_version, RELEASE_IQ_BRANCHES
|
||||
|
||||
HEAD_BUTTON_FONT_SIZE = 40
|
||||
HOME_PADDING = 8
|
||||
HOME_TITLE_MAX_FONT_SIZE = 72
|
||||
HOME_TITLE_MIN_FONT_SIZE = 36
|
||||
HOME_TITLE_TEXT = "IQ.Pilot"
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
NETWORK_TYPES = {
|
||||
NetworkType.none: "Offline",
|
||||
NetworkType.wifi: "WiFi",
|
||||
NetworkType.cell2G: "2G",
|
||||
NetworkType.cell3G: "3G",
|
||||
NetworkType.cell4G: "LTE",
|
||||
NetworkType.cell5G: "5G",
|
||||
NetworkType.ethernet: "Ethernet",
|
||||
}
|
||||
|
||||
|
||||
class DeviceStatus(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 300, 175))
|
||||
self._update_state()
|
||||
self._version_text = self._get_version_text()
|
||||
|
||||
self._do_welcome()
|
||||
|
||||
def _do_welcome(self):
|
||||
ui_state.params.put("CompletedTrainingVersion", training_version)
|
||||
|
||||
def refresh(self):
|
||||
self._update_state()
|
||||
self._version_text = self._get_version_text()
|
||||
|
||||
def _get_version_text(self) -> str:
|
||||
brand = "IQ.Pilot"
|
||||
description = ui_state.params.get("UpdaterCurrentDescription")
|
||||
return f"{brand} {description}" if description else brand
|
||||
|
||||
def _update_state(self):
|
||||
# TODO: refresh function that can be called periodically, not at 60 fps, so we can update version
|
||||
# update system status
|
||||
self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..."
|
||||
|
||||
# update network status
|
||||
strength = ui_state.sm['deviceState'].networkStrength.raw
|
||||
strength_text = "● " * strength + "○ " * (4 - strength) # ◌ also works
|
||||
network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw]
|
||||
self._network_status = f"{network_type} {strength_text}"
|
||||
|
||||
def _render(self, _):
|
||||
# draw status
|
||||
status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40)
|
||||
gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR,
|
||||
font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
# draw network status
|
||||
network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40)
|
||||
gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR,
|
||||
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
# draw version
|
||||
version_font_size = 30
|
||||
version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40)
|
||||
wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width))
|
||||
gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR,
|
||||
font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
|
||||
|
||||
class MiciHomeLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._on_settings_click: Callable | None = None
|
||||
|
||||
self._last_refresh = 0
|
||||
self._mouse_down_t: None | float = None
|
||||
self._did_long_press = False
|
||||
self._is_pressed_prev = False
|
||||
|
||||
self._version_text = None
|
||||
self._experimental_mode = False
|
||||
|
||||
self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48)
|
||||
self._experimental_txt = gui_app.texture("icons_mici/experimental_mode_mici.png", 48, 48)
|
||||
self._iqdynamic_txt = gui_app.texture("icons_mici/iqdynamic_mode_mici.png", 48, 48)
|
||||
self._iqstandard_txt = gui_app.texture("icons_mici/iqstandard_mode_mici.png", 48, 48)
|
||||
self._mode_txt = None
|
||||
self._mic_txt = gui_app.texture("icons_mici/microphone.png", 32, 46)
|
||||
self._egpu_txt = gui_app.texture("icons_mici/egpu.png", 62, 46)
|
||||
self._egpu_green_txt = gui_app.texture("icons_mici/egpu_green.png", 62, 46)
|
||||
self._egpu_orange_txt = gui_app.texture("icons_mici/egpu_orange.png", 78, 46)
|
||||
self._mac_txt = gui_app.texture("icons_mici/mac.png", 62, 46)
|
||||
self._mac_green_txt = gui_app.texture("icons_mici/mac_green.png", 62, 46)
|
||||
self._mac_orange_txt = gui_app.texture("icons_mici/mac_orange.png", 78, 46)
|
||||
self._egpu_state: str | None = None
|
||||
self._mac_state: str | None = None
|
||||
self._egpu_progress = 0.0
|
||||
self._mac_progress = 0.0
|
||||
|
||||
self._net_type = NETWORK_TYPES.get(NetworkType.none)
|
||||
self._net_strength = 0
|
||||
|
||||
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44)
|
||||
self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37)
|
||||
|
||||
self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36)
|
||||
self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36)
|
||||
self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36)
|
||||
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36)
|
||||
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36)
|
||||
self._lte_label = UnifiedLabel("LTE", font_size=22, text_color=rl.Color(255, 255, 255, int(255 * 0.82)),
|
||||
font_weight=FontWeight.BOLD)
|
||||
|
||||
self._openpilot_label = MiciLabel(HOME_TITLE_TEXT, font_size=HOME_TITLE_MAX_FONT_SIZE, color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
font_weight=FontWeight.SYNCOPATE)
|
||||
self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
|
||||
self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
|
||||
self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
|
||||
self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True)
|
||||
self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
|
||||
|
||||
def show_event(self):
|
||||
self._version_text = self._get_version_text()
|
||||
self._update_network_status(ui_state.sm['deviceState'])
|
||||
self._update_params()
|
||||
|
||||
def _update_params(self):
|
||||
p = ui_state.params
|
||||
self._experimental_mode = p.get_bool("ExperimentalMode")
|
||||
if not p.get_bool("AlphaLongitudinalEnabled"):
|
||||
self._mode_txt = None
|
||||
elif not self._experimental_mode:
|
||||
self._mode_txt = self._iqstandard_txt
|
||||
elif p.get_bool("IQDynamicMode"):
|
||||
self._mode_txt = self._iqdynamic_txt
|
||||
else:
|
||||
self._mode_txt = self._experimental_txt
|
||||
|
||||
def _update_state(self):
|
||||
if self.is_pressed and not self._is_pressed_prev:
|
||||
self._mouse_down_t = time.monotonic()
|
||||
elif not self.is_pressed and self._is_pressed_prev:
|
||||
self._mouse_down_t = None
|
||||
self._did_long_press = False
|
||||
self._is_pressed_prev = self.is_pressed
|
||||
|
||||
if self._mouse_down_t is not None:
|
||||
if time.monotonic() - self._mouse_down_t > 0.5:
|
||||
# long gating for experimental mode - only allow toggle if longitudinal control is available
|
||||
if ui_state.has_longitudinal_control:
|
||||
self._experimental_mode = not self._experimental_mode
|
||||
ui_state.params.put("ExperimentalMode", self._experimental_mode)
|
||||
if not self._experimental_mode:
|
||||
ui_state.params.put_bool("IQDynamicMode", False)
|
||||
self._update_params()
|
||||
self._mouse_down_t = None
|
||||
self._did_long_press = True
|
||||
|
||||
if rl.get_time() - self._last_refresh > 5.0:
|
||||
device_state = ui_state.sm['deviceState']
|
||||
self._update_network_status(device_state)
|
||||
|
||||
# Update version text
|
||||
self._version_text = self._get_version_text()
|
||||
self._last_refresh = rl.get_time()
|
||||
self._update_params()
|
||||
self._update_dock_status()
|
||||
|
||||
def _update_dock_status(self):
|
||||
p = ui_state.params
|
||||
egpu_present = bool(getattr(ui_state.sm['deviceState'], "egpuDockPresent", False))
|
||||
if not egpu_present:
|
||||
self._egpu_state = None
|
||||
elif any(p.get_bool(k) for k in ("Offroad_EgpuPcieUnavailable", "Offroad_EgpuOverheated",
|
||||
"Offroad_EgpuFansObstructed", "Offroad_EgpuUpdateFailed",
|
||||
"Offroad_EgpuNotDetected")):
|
||||
self._egpu_state = "orange"
|
||||
elif p.get_bool("UsbGpuLoading") and not p.get_bool("UsbGpuCompiled"):
|
||||
self._egpu_state = "compiling"
|
||||
try:
|
||||
self._egpu_progress = max(0.0, min(1.0, float(p.get("UsbGpuSetupProgress") or 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
self._egpu_progress = 0.0
|
||||
elif p.get_bool("Offroad_EgpuUsbSlow") or p.get_bool("Offroad_EgpuUncompiled"):
|
||||
self._egpu_state = "grey"
|
||||
else:
|
||||
self._egpu_state = "green"
|
||||
|
||||
mac_present = p.get_bool("MacModelPresent") or p.get_bool("MacModelReachable")
|
||||
if not mac_present:
|
||||
self._mac_state = None
|
||||
elif p.get_bool("MacModelFault"):
|
||||
self._mac_state = "orange"
|
||||
elif p.get_bool("MacModelReady") or p.get_bool("MacModelActive"):
|
||||
self._mac_state = "green"
|
||||
else:
|
||||
self._mac_state = "grey"
|
||||
|
||||
def _update_network_status(self, device_state):
|
||||
self._net_type = device_state.networkType
|
||||
strength = device_state.networkStrength
|
||||
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
|
||||
|
||||
def set_callbacks(self, on_settings: Callable | None = None):
|
||||
self._on_settings_click = on_settings
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if not self._did_long_press:
|
||||
if self._on_settings_click:
|
||||
self._on_settings_click()
|
||||
self._did_long_press = False
|
||||
|
||||
def _get_version_text(self) -> tuple[str, str, str, str] | None:
|
||||
description = ui_state.params.get("UpdaterCurrentDescription")
|
||||
|
||||
if description is not None and len(description) > 0:
|
||||
# Expect "version / branch / commit / date"; be tolerant of other formats
|
||||
try:
|
||||
version, branch, commit, date = description.split(" / ")
|
||||
# version/date/branch share one 536px line: the brand prefix and clock time push the branch off screen
|
||||
return version.removeprefix(HOME_TITLE_TEXT).strip() or version, branch, commit, date.split(" ")[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _fit_title_font(self, title_x: float) -> None:
|
||||
font = gui_app.font(FontWeight.SYNCOPATE)
|
||||
viewport_width = min(w for w in (self.rect.width, gui_app.width, rl.get_screen_width()) if w > 0)
|
||||
max_width = max(100, int(viewport_width - title_x - HOME_PADDING - 16))
|
||||
fit_width = max_width * 0.9
|
||||
text_width = measure_text_cached(font, HOME_TITLE_TEXT, HOME_TITLE_MAX_FONT_SIZE).x
|
||||
if text_width <= fit_width:
|
||||
target_size = HOME_TITLE_MAX_FONT_SIZE
|
||||
else:
|
||||
target_size = int(HOME_TITLE_MAX_FONT_SIZE * (fit_width / text_width))
|
||||
target_size = max(HOME_TITLE_MIN_FONT_SIZE, min(HOME_TITLE_MAX_FONT_SIZE, target_size))
|
||||
|
||||
if target_size != self._openpilot_label.font_size:
|
||||
self._openpilot_label.set_font_size(target_size)
|
||||
|
||||
def _render_title_gradient(self, x: float, y: float) -> None:
|
||||
"""Render HOME_TITLE_TEXT with a left-to-right gradient (#7400b8 → #80ffdb).
|
||||
|
||||
Technique: render white text to an offscreen RenderTexture, then draw it
|
||||
tinted by sampling the gradient per-column using draw_texture_pro with a
|
||||
tint. Since draw_texture_pro only supports a single tint color, we use the
|
||||
BLEND_MULTIPLIED trick:
|
||||
1. Draw white text normally onto the framebuffer.
|
||||
2. Draw gradient rect with BLEND_MULTIPLIED on top — this multiplies each
|
||||
existing pixel by the gradient color, turning white text into the gradient
|
||||
while the dark background (near-zero RGB) stays dark.
|
||||
"""
|
||||
font = gui_app.font(FontWeight.SYNCOPATE)
|
||||
font_size = self._openpilot_label.font_size
|
||||
text = HOME_TITLE_TEXT
|
||||
|
||||
text_size = measure_text_cached(font, text, font_size)
|
||||
tw = int(text_size.x) + 4
|
||||
th = int(text_size.y) + 4
|
||||
|
||||
# Step 1: draw white text at full opacity
|
||||
rl.draw_text_ex(font, text, rl.Vector2(x + 2, y + 2), font_size, 0,
|
||||
rl.Color(255, 255, 255, 230))
|
||||
|
||||
# Step 2: multiply gradient over the text region — white → gradient color,
|
||||
# black background → stays black (0 × anything = 0)
|
||||
rl.begin_blend_mode(rl.BlendMode.BLEND_MULTIPLIED)
|
||||
rl.draw_rectangle_gradient_h(
|
||||
int(x), int(y), tw, th,
|
||||
rl.Color(0x80, 0xff, 0xdb, 255), # #80ffdb — left
|
||||
rl.Color(0x74, 0x00, 0xb8, 255), # #7400b8 — right
|
||||
)
|
||||
rl.end_blend_mode()
|
||||
|
||||
def _render(self, _):
|
||||
text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y + HOME_PADDING)
|
||||
self._fit_title_font(text_pos.x)
|
||||
self._render_title_gradient(text_pos.x, text_pos.y)
|
||||
|
||||
if self._version_text is not None:
|
||||
# release branch
|
||||
release_branch = self._version_text[1] in RELEASE_IQ_BRANCHES
|
||||
version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44)
|
||||
self._version_label.set_text(self._version_text[0])
|
||||
self._version_label.set_position(version_pos.x, version_pos.y)
|
||||
self._version_label.render()
|
||||
|
||||
self._date_label.set_text(" " + self._version_text[3])
|
||||
self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y)
|
||||
self._date_label.render()
|
||||
|
||||
viewport_right = min(w for w in (self.rect.x + self.rect.width, gui_app.width, rl.get_screen_width()) if w > 0)
|
||||
branch_x = version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20
|
||||
self._branch_label.set_max_width(max(80, int(viewport_right - branch_x - HOME_PADDING)))
|
||||
self._branch_label.set_text(" " + ("release" if release_branch else self._version_text[1]))
|
||||
self._branch_label.set_position(branch_x, version_pos.y)
|
||||
self._branch_label.render()
|
||||
|
||||
if not release_branch:
|
||||
# 2nd line
|
||||
self._version_commit_label.set_text(self._version_text[2])
|
||||
commit_y = version_pos.y + self._date_label.font_size + 7
|
||||
commit_rect = rl.Rectangle(version_pos.x, commit_y, max(100, viewport_right - version_pos.x - HOME_PADDING), 44)
|
||||
self._version_commit_label.render(commit_rect)
|
||||
|
||||
self._render_bottom_status_bar()
|
||||
|
||||
def _render_bottom_status_bar(self):
|
||||
# ***** Center-aligned bottom section icons *****
|
||||
|
||||
# TODO: refactor repeated icon drawing into a small loop
|
||||
ITEM_SPACING = 18
|
||||
Y_CENTER = 24
|
||||
|
||||
last_x = self.rect.x + HOME_PADDING
|
||||
|
||||
# Draw settings icon in bottom left corner
|
||||
rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER),
|
||||
rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
last_x = last_x + self._settings_txt.width + ITEM_SPACING
|
||||
|
||||
# draw network
|
||||
if self._net_type == NetworkType.wifi:
|
||||
# There is no 1
|
||||
draw_net_txt = {0: self._wifi_none_txt,
|
||||
2: self._wifi_low_txt,
|
||||
3: self._wifi_medium_txt,
|
||||
4: self._wifi_full_txt,
|
||||
5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt)
|
||||
rl.draw_texture(draw_net_txt, int(last_x),
|
||||
int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
last_x += draw_net_txt.width + ITEM_SPACING
|
||||
|
||||
elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
|
||||
last_x = self._draw_cellular_cluster(last_x, ITEM_SPACING, Y_CENTER, connected=True)
|
||||
|
||||
else:
|
||||
last_x = self._draw_cellular_cluster(last_x, ITEM_SPACING, Y_CENTER, connected=False)
|
||||
|
||||
if self._mode_txt is not None:
|
||||
rl.draw_texture(self._mode_txt, int(last_x),
|
||||
int(self._rect.y + self.rect.height - self._mode_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
|
||||
last_x += self._mode_txt.width + ITEM_SPACING
|
||||
|
||||
# draw microphone icon when recording audio is enabled
|
||||
if ui_state.recording_audio:
|
||||
rl.draw_texture(self._mic_txt, int(last_x),
|
||||
int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
|
||||
last_x += self._mic_txt.width + ITEM_SPACING
|
||||
|
||||
for state, base, green, orange, progress in (
|
||||
(self._egpu_state, self._egpu_txt, self._egpu_green_txt, self._egpu_orange_txt, self._egpu_progress),
|
||||
(self._mac_state, self._mac_txt, self._mac_green_txt, self._mac_orange_txt, self._mac_progress)):
|
||||
if state is None:
|
||||
continue
|
||||
y_top = int(self._rect.y + self.rect.height - base.height / 2 - Y_CENTER)
|
||||
if state == "compiling":
|
||||
self._draw_compile_gauge(int(last_x), y_top, base, green, progress)
|
||||
last_x += base.width + ITEM_SPACING
|
||||
continue
|
||||
if state == "green":
|
||||
tex, tint = green, rl.Color(255, 255, 255, 255)
|
||||
elif state == "orange":
|
||||
tex, tint = orange, rl.Color(255, 255, 255, 255)
|
||||
else:
|
||||
tex, tint = base, rl.Color(165, 165, 170, 235)
|
||||
rl.draw_texture(tex, int(last_x), y_top, tint)
|
||||
last_x += tex.width + ITEM_SPACING
|
||||
|
||||
def _draw_compile_gauge(self, x: int, y_top: int, base, fill, progress: float):
|
||||
rl.draw_texture(base, x, y_top, rl.Color(165, 165, 170, 235))
|
||||
fill_h = int(base.height * max(0.0, min(1.0, progress)))
|
||||
if fill_h > 0:
|
||||
rl.begin_scissor_mode(x, y_top + base.height - fill_h, base.width, fill_h)
|
||||
rl.draw_texture(fill, x, y_top, rl.Color(255, 255, 255, 255))
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_cellular_cluster(self, start_x: float, spacing: int, y_center: int, connected: bool) -> float:
|
||||
draw_net_txt = {0: self._cell_none_txt,
|
||||
2: self._cell_low_txt,
|
||||
3: self._cell_medium_txt,
|
||||
4: self._cell_high_txt,
|
||||
5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt)
|
||||
|
||||
icon_y = int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - y_center)
|
||||
rl.draw_texture(draw_net_txt, int(start_x), icon_y, rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
label_x = start_x + draw_net_txt.width + 12
|
||||
label_y = self._rect.y + self.rect.height - 37
|
||||
label_rect = rl.Rectangle(label_x, label_y, 44, 24)
|
||||
self._lte_label.render(label_rect)
|
||||
|
||||
if not connected:
|
||||
slash_start = rl.Vector2(start_x + 8, icon_y + draw_net_txt.height - 3)
|
||||
slash_end = rl.Vector2(start_x + draw_net_txt.width - 2, icon_y + 3)
|
||||
rl.draw_line_ex(slash_start, slash_end, 8, rl.Color(255, 255, 255, int(255 * 0.22)))
|
||||
rl.draw_line_ex(slash_start, slash_end, 5, rl.Color(255, 255, 255, int(255 * 0.82)))
|
||||
|
||||
return label_x + label_rect.width + spacing
|
||||
134
iqpilot/selfdrive/ui/mici/layouts/main.py
Normal file
134
iqpilot/selfdrive/ui/mici/layouts/main.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import pyray as rl
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
|
||||
from iqpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
|
||||
from iqpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
|
||||
from iqpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from iqpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.multilang import multilang
|
||||
from iqpilot.system.version import training_version
|
||||
|
||||
|
||||
ONROAD_DELAY = 2.5 # seconds
|
||||
|
||||
|
||||
class MiciMainLayout(Widget):
|
||||
"""Root mici layout. Lives at the bottom of the nav stack; settings push on top.
|
||||
|
||||
Keeps the IQ.Pilot custom home + onroad as horizontally-scrolled pages, while
|
||||
the (stock) settings open as a swipe-to-dismiss NavWidget on the nav stack.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._pm = messaging.PubMaster(['bookmarkButton'])
|
||||
|
||||
self._prev_onroad = False
|
||||
self._prev_standstill = False
|
||||
self._onroad_time_delay: float | None = None
|
||||
self._setup = False
|
||||
self._rebuild_settings = False
|
||||
|
||||
self._home_layout = MiciHomeLayout()
|
||||
self._alerts_layout = MiciOffroadAlerts()
|
||||
self._settings_layout = SettingsLayout()
|
||||
self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
|
||||
|
||||
for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout):
|
||||
widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
self._scroller = Scroller([
|
||||
self._alerts_layout,
|
||||
self._home_layout,
|
||||
self._onroad_layout,
|
||||
], spacing=0, pad_start=0, pad_end=0)
|
||||
self._scroller.set_reset_scroll_at_show(False)
|
||||
|
||||
# Disable scrolling when onroad is interacting with bookmark
|
||||
self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
|
||||
|
||||
self._setup_callbacks()
|
||||
|
||||
if ui_state.params.get("CompletedTrainingVersion") != training_version:
|
||||
ui_state.params.put("CompletedTrainingVersion", training_version)
|
||||
|
||||
gui_app.add_nav_stack_tick(self._handle_transitions)
|
||||
gui_app.push_widget(self)
|
||||
|
||||
self._onboarding_window = OnboardingWindow()
|
||||
if not self._onboarding_window.completed:
|
||||
gui_app.set_modal_overlay(self._onboarding_window)
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self._home_layout.set_callbacks(on_settings=self._on_settings_clicked)
|
||||
self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
|
||||
device.add_interactive_timeout_callback(self._on_interactive_timeout)
|
||||
multilang.add_change_callback(self._on_language_changed)
|
||||
|
||||
def _on_language_changed(self):
|
||||
self._rebuild_settings = True
|
||||
|
||||
def _scroll_to(self, layout: Widget):
|
||||
self._scroller.scroll_to(int(layout.rect.x), smooth=True)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
|
||||
def _render(self, _):
|
||||
if not self._setup:
|
||||
if self._alerts_layout.active_alerts() > 0:
|
||||
self._scroller.scroll_to(self._alerts_layout.rect.x)
|
||||
else:
|
||||
self._scroller.scroll_to(self._rect.width)
|
||||
self._setup = True
|
||||
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
def _handle_transitions(self):
|
||||
if ui_state.started != self._prev_onroad:
|
||||
self._prev_onroad = ui_state.started
|
||||
|
||||
if ui_state.started:
|
||||
self._onroad_time_delay = rl.get_time()
|
||||
else:
|
||||
self._scroll_to(self._home_layout)
|
||||
|
||||
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._onroad_time_delay = None
|
||||
|
||||
CS = ui_state.sm["carState"]
|
||||
if not CS.standstill and self._prev_standstill:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._prev_standstill = CS.standstill
|
||||
|
||||
if self._rebuild_settings:
|
||||
self._rebuild_settings = False
|
||||
gui_app.pop_widgets_to(self, instant=True)
|
||||
self._settings_layout = SettingsLayout()
|
||||
self._settings_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
def _on_interactive_timeout(self):
|
||||
if ui_state.started:
|
||||
if not ui_state.sm["carState"].standstill:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
else:
|
||||
gui_app.pop_widgets_to(self, instant=True)
|
||||
self._scroll_to(self._home_layout)
|
||||
|
||||
def _on_settings_clicked(self):
|
||||
gui_app.push_widget(self._settings_layout)
|
||||
|
||||
def _on_bookmark_clicked(self):
|
||||
user_bookmark = messaging.new_message('bookmarkButton')
|
||||
user_bookmark.valid = True
|
||||
self._pm.send('bookmarkButton', user_bookmark)
|
||||
309
iqpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
Normal file
309
iqpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
Normal file
@@ -0,0 +1,309 @@
|
||||
import pyray as rl
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
|
||||
from iqpilot.system.hardware import HARDWARE
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
REFRESH_INTERVAL = 5.0 # seconds
|
||||
|
||||
|
||||
class AlertSize(IntEnum):
|
||||
SMALL = 0
|
||||
MEDIUM = 1
|
||||
BIG = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlertData:
|
||||
key: str
|
||||
text: str
|
||||
severity: int
|
||||
visible: bool = False
|
||||
|
||||
|
||||
class AlertItem(Widget):
|
||||
# TODO: click should always go somewhere: home or specific settings pane
|
||||
"""Individual alert item widget with background image and text."""
|
||||
ALERT_WIDTH = 520
|
||||
ALERT_HEIGHT_SMALL = 212
|
||||
ALERT_HEIGHT_MED = 240
|
||||
ALERT_HEIGHT_BIG = 324
|
||||
ALERT_PADDING = 28
|
||||
ICON_SIZE = 64
|
||||
ICON_MARGIN = 12
|
||||
TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
TITLE_BODY_SPACING = 24
|
||||
|
||||
def __init__(self, alert_data: AlertData):
|
||||
super().__init__()
|
||||
self.alert_data = alert_data
|
||||
|
||||
# Load background textures
|
||||
self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
||||
self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
||||
self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
||||
self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
||||
self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
||||
self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
||||
|
||||
# Load warning icons
|
||||
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
|
||||
|
||||
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
|
||||
|
||||
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
|
||||
|
||||
self._title_text = ""
|
||||
self._body_text = ""
|
||||
self._alert_size = AlertSize.SMALL
|
||||
|
||||
self._update_content()
|
||||
|
||||
def _split_text(self, text: str) -> tuple[str, str]:
|
||||
"""Split text into title (first sentence) and body (remaining text)."""
|
||||
# Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
|
||||
match = re.search(r'[.!?](?:\s+|$)', text)
|
||||
if match:
|
||||
# Found a sentence boundary - split at the end of the sentence
|
||||
title = text[:match.start()].strip()
|
||||
body = text[match.end():].strip()
|
||||
return title, body
|
||||
else:
|
||||
# No sentence boundary found, return full text as title
|
||||
return "", text
|
||||
|
||||
def _update_content(self):
|
||||
"""Update text and calculate height."""
|
||||
if not self.alert_data.visible or not self.alert_data.text:
|
||||
self.set_visible(False)
|
||||
return
|
||||
|
||||
self.set_visible(True)
|
||||
|
||||
# Split text into title and body
|
||||
self._title_text, self._body_text = self._split_text(self.alert_data.text)
|
||||
|
||||
# Calculate text width (alert width minus padding and icon space on right)
|
||||
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
|
||||
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
|
||||
|
||||
# Update labels
|
||||
self._title_label.set_text(self._title_text)
|
||||
self._body_label.set_text(self._body_text)
|
||||
|
||||
# Calculate content height
|
||||
title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
|
||||
body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
|
||||
spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
|
||||
total_text_height = title_height + spacing + body_height
|
||||
|
||||
# Determine which background size to use based on content height
|
||||
min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
|
||||
if min_height_with_padding > self.ALERT_HEIGHT_MED:
|
||||
self._alert_size = AlertSize.BIG
|
||||
height = self.ALERT_HEIGHT_BIG
|
||||
elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
|
||||
self._alert_size = AlertSize.MEDIUM
|
||||
height = self.ALERT_HEIGHT_MED
|
||||
else:
|
||||
self._alert_size = AlertSize.SMALL
|
||||
height = self.ALERT_HEIGHT_SMALL
|
||||
|
||||
# Set rect size
|
||||
self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
|
||||
|
||||
def update_alert_data(self, alert_data: AlertData):
|
||||
"""Update alert data and refresh display."""
|
||||
self.alert_data = alert_data
|
||||
self._update_content()
|
||||
|
||||
def _render(self, _):
|
||||
if not self.alert_data.visible or not self.alert_data.text:
|
||||
return
|
||||
|
||||
# Choose background based on size
|
||||
if self._alert_size == AlertSize.BIG:
|
||||
bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
|
||||
elif self._alert_size == AlertSize.MEDIUM:
|
||||
bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
|
||||
else: # AlertSize.SMALL
|
||||
bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
|
||||
|
||||
# Draw background
|
||||
rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE)
|
||||
|
||||
# Calculate text area (left side, avoiding icon on right)
|
||||
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
|
||||
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
|
||||
text_x = self._rect.x + self.ALERT_PADDING
|
||||
text_y = self._rect.y + self.ALERT_PADDING
|
||||
|
||||
# Draw title label
|
||||
if self._title_text:
|
||||
title_rect = rl.Rectangle(
|
||||
text_x,
|
||||
text_y,
|
||||
title_width,
|
||||
self._title_label.get_content_height(title_width),
|
||||
)
|
||||
self._title_label.render(title_rect)
|
||||
text_y += title_rect.height + self.TITLE_BODY_SPACING
|
||||
|
||||
# Draw body label
|
||||
if self._body_text:
|
||||
body_rect = rl.Rectangle(
|
||||
text_x,
|
||||
text_y,
|
||||
body_width,
|
||||
self._rect.height - text_y + self._rect.y - self.ALERT_PADDING,
|
||||
)
|
||||
self._body_label.render(body_rect)
|
||||
|
||||
# Draw warning icon on the right side
|
||||
# Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
|
||||
if self.alert_data.severity == -1:
|
||||
icon_texture = self._icon_green
|
||||
elif self.alert_data.severity > 0:
|
||||
icon_texture = self._icon_red
|
||||
else:
|
||||
icon_texture = self._icon_orange
|
||||
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
|
||||
icon_y = self._rect.y + self.ALERT_PADDING
|
||||
rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
|
||||
|
||||
|
||||
class MiciOffroadAlerts(Widget):
|
||||
"""Offroad alerts layout with vertical scrolling."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.params = Params()
|
||||
self.sorted_alerts: list[AlertData] = []
|
||||
self.alert_items: list[AlertItem] = []
|
||||
self._last_refresh = 0.0
|
||||
|
||||
# Create vertical scroller
|
||||
self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False)
|
||||
|
||||
# Create empty state label
|
||||
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
# Build initial alert list
|
||||
self._build_alerts()
|
||||
|
||||
def active_alerts(self) -> int:
|
||||
return sum(alert.visible for alert in self.sorted_alerts)
|
||||
|
||||
def scrolling(self):
|
||||
return self._scroller.scroll_panel.is_touch_valid()
|
||||
|
||||
def _build_alerts(self):
|
||||
"""Build sorted list of alerts from OFFROAD_ALERTS."""
|
||||
self.sorted_alerts = []
|
||||
|
||||
# Add UpdateAvailable alert at the top (severity = -1 to indicate special handling)
|
||||
update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1)
|
||||
self.sorted_alerts.append(update_alert_data)
|
||||
update_alert_item = AlertItem(update_alert_data)
|
||||
update_alert_item.set_click_callback(lambda: HARDWARE.reboot())
|
||||
self.alert_items.append(update_alert_item)
|
||||
self._scroller.add_widget(update_alert_item)
|
||||
|
||||
# Add regular alerts sorted by severity
|
||||
for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
|
||||
severity = config.get("severity", 0)
|
||||
alert_data = AlertData(key=key, text="", severity=severity)
|
||||
self.sorted_alerts.append(alert_data)
|
||||
|
||||
# Create alert item widget
|
||||
alert_item = AlertItem(alert_data)
|
||||
self.alert_items.append(alert_item)
|
||||
self._scroller.add_widget(alert_item)
|
||||
|
||||
def refresh(self) -> int:
|
||||
"""Refresh alerts from params and return active count."""
|
||||
active_count = 0
|
||||
|
||||
# Handle UpdateAvailable alert specially
|
||||
update_available = self.params.get_bool("UpdateAvailable")
|
||||
update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None)
|
||||
|
||||
if update_alert_data:
|
||||
if update_available:
|
||||
version_string = ""
|
||||
|
||||
# Get new version description and parse version and date
|
||||
new_desc = self.params.get("UpdaterNewDescription") or ""
|
||||
if new_desc:
|
||||
# format: "version / branch / commit / date"
|
||||
parts = new_desc.split(" / ")
|
||||
if len(parts) > 3:
|
||||
version, date = parts[0], parts[3]
|
||||
version_string = f"\nIQ.Pilot {version}, {date}\n"
|
||||
|
||||
update_alert_data.text = f"Update available {version_string}. Click to update."
|
||||
update_alert_data.visible = True
|
||||
active_count += 1
|
||||
else:
|
||||
update_alert_data.text = ""
|
||||
update_alert_data.visible = False
|
||||
|
||||
# Handle regular alerts
|
||||
for alert_data in self.sorted_alerts:
|
||||
if alert_data.key == "UpdateAvailable":
|
||||
continue # Skip, already handled above
|
||||
|
||||
text = ""
|
||||
alert_json = self.params.get(alert_data.key)
|
||||
|
||||
if alert_json:
|
||||
text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
|
||||
|
||||
alert_data.text = text
|
||||
alert_data.visible = bool(text)
|
||||
|
||||
if alert_data.visible:
|
||||
active_count += 1
|
||||
|
||||
# Update alert items (they reference the same alert_data objects)
|
||||
for alert_item in self.alert_items:
|
||||
alert_item.update_alert_data(alert_item.alert_data)
|
||||
|
||||
return active_count
|
||||
|
||||
def show_event(self):
|
||||
"""Reset scroll position when shown and refresh alerts."""
|
||||
self._scroller.show_event()
|
||||
self._last_refresh = time.monotonic()
|
||||
self.refresh()
|
||||
|
||||
def _update_state(self):
|
||||
"""Periodically refresh alerts."""
|
||||
# Refresh alerts periodically, not every frame
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_refresh >= REFRESH_INTERVAL:
|
||||
self.refresh()
|
||||
self._last_refresh = current_time
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
"""Render the alerts scroller or empty state."""
|
||||
if self.active_alerts() == 0:
|
||||
self._empty_label.render(rect)
|
||||
else:
|
||||
self._scroller.render(rect)
|
||||
533
iqpilot/selfdrive/ui/mici/layouts/onboarding.py
Normal file
533
iqpilot/selfdrive/ui/mici/layouts/onboarding.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from enum import IntEnum
|
||||
|
||||
import weakref
|
||||
import math
|
||||
import os
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.slider import SmallSlider
|
||||
from iqpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from iqpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
|
||||
from iqpilot.system.ui.widgets.label import gui_label
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.version import terms_version, training_version
|
||||
|
||||
|
||||
class OnboardingState(IntEnum):
|
||||
TERMS = 0
|
||||
ONBOARDING = 1
|
||||
DECLINE = 2
|
||||
|
||||
|
||||
class DriverCameraSetupDialog(DriverCameraDialog):
|
||||
def __init__(self):
|
||||
super().__init__(no_escape=True)
|
||||
self.driver_state_renderer = DriverStateRenderer(inset=True)
|
||||
self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 120, 120))
|
||||
self.driver_state_renderer.load_icons()
|
||||
self.driver_state_renderer.set_force_active(True)
|
||||
|
||||
def _render(self, rect):
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
self._camera_view._render(rect)
|
||||
|
||||
if not self._camera_view.frame:
|
||||
gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
rl.end_scissor_mode()
|
||||
return -1
|
||||
|
||||
# Position dmoji on opposite side from driver
|
||||
is_rhd = self.driver_state_renderer.is_rhd
|
||||
self.driver_state_renderer.set_position(
|
||||
rect.x + 8 if is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width - 8,
|
||||
rect.y + 8,
|
||||
)
|
||||
self.driver_state_renderer.render()
|
||||
|
||||
self._draw_face_detection(rect)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
return -1
|
||||
|
||||
|
||||
class TrainingGuidePreDMTutorial(SetupTermsPage):
|
||||
def __init__(self, continue_callback):
|
||||
super().__init__(continue_callback, continue_text=tr("continue"))
|
||||
self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
|
||||
|
||||
self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " +
|
||||
"unplug and remount before continuing.", 42,
|
||||
FontWeight.ROMAN)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# Get driver monitoring model ready for next step
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._rect.y + 16 + scroll_offset,
|
||||
self._title_header.rect.width,
|
||||
self._title_header.rect.height,
|
||||
))
|
||||
|
||||
self._dm_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + 16,
|
||||
self._rect.width - 32,
|
||||
self._dm_label.get_content_height(int(self._rect.width - 32)),
|
||||
))
|
||||
|
||||
|
||||
class DMBadFaceDetected(SetupTermsPage):
|
||||
def __init__(self, continue_callback, back_callback):
|
||||
super().__init__(continue_callback, back_callback, continue_text=tr("power off"))
|
||||
self._title_header = TermsHeader("make sure comma four can see your face", gui_app.texture("icons_mici/setup/orange_dm.png", 60, 60))
|
||||
self._dm_label = UnifiedLabel(tr("Re-mount if your face is occluded or driver monitoring has difficulty tracking your face."), 42, FontWeight.ROMAN)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._rect.y + 16 + scroll_offset,
|
||||
self._title_header.rect.width,
|
||||
self._title_header.rect.height,
|
||||
))
|
||||
|
||||
self._dm_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + 16,
|
||||
self._rect.width - 32,
|
||||
self._dm_label.get_content_height(int(self._rect.width - 32)),
|
||||
))
|
||||
|
||||
|
||||
class TrainingGuideDMTutorial(Widget):
|
||||
PROGRESS_DURATION = 4
|
||||
LOOKING_THRESHOLD_DEG = 30.0
|
||||
NO_CAMERA_BYPASS_DELAY_SEC = 2.0
|
||||
|
||||
def __init__(self, continue_callback):
|
||||
super().__init__()
|
||||
self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 28, 48))
|
||||
self._back_button.set_click_callback(self._show_bad_face_page)
|
||||
self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 42, 42))
|
||||
|
||||
# Wrap the continue callback to restore settings
|
||||
def wrapped_continue_callback():
|
||||
device.set_offroad_brightness(None)
|
||||
continue_callback()
|
||||
|
||||
self._good_button.set_click_callback(wrapped_continue_callback)
|
||||
self._good_button.set_enabled(False)
|
||||
|
||||
self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps)
|
||||
self._dialog = DriverCameraSetupDialog()
|
||||
self._bad_face_page = DMBadFaceDetected(HARDWARE.shutdown, self._hide_bad_face_page)
|
||||
self._should_show_bad_face_page = False
|
||||
self._no_camera_elapsed_sec = 0.0
|
||||
self._allow_no_camera_bypass = PC or os.getenv("IQPILOT_ALLOW_DM_NO_CAMERA", "0") == "1"
|
||||
|
||||
# Disable driver monitoring model when device times out for inactivity
|
||||
def inactivity_callback():
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
|
||||
device.add_interactive_timeout_callback(inactivity_callback)
|
||||
|
||||
def _show_bad_face_page(self):
|
||||
self._bad_face_page.show_event()
|
||||
self.hide_event()
|
||||
self._should_show_bad_face_page = True
|
||||
|
||||
def _hide_bad_face_page(self):
|
||||
self._bad_face_page.hide_event()
|
||||
self.show_event()
|
||||
self._should_show_bad_face_page = False
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._dialog.show_event()
|
||||
self._progress.x = 0.0
|
||||
self._no_camera_elapsed_sec = 0.0
|
||||
|
||||
device.set_offroad_brightness(100)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if device.awake and not ui_state.params.get_bool("IsDriverViewEnabled"):
|
||||
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True)
|
||||
|
||||
has_camera_frame = self._dialog._camera_view.frame is not None
|
||||
if has_camera_frame:
|
||||
self._no_camera_elapsed_sec = 0.0
|
||||
else:
|
||||
self._no_camera_elapsed_sec += 1.0 / gui_app.target_fps
|
||||
|
||||
# Dev-mode fallback: allow progressing onboarding even when no camera frames are available.
|
||||
if self._allow_no_camera_bypass and not has_camera_frame:
|
||||
self._good_button.set_enabled(self._no_camera_elapsed_sec >= self.NO_CAMERA_BYPASS_DELAY_SEC)
|
||||
return
|
||||
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame.get("driverMonitoringState", 0) == 0:
|
||||
self._good_button.set_enabled(False)
|
||||
return
|
||||
|
||||
dm_state = sm["driverMonitoringState"]
|
||||
driver_data = self._dialog.driver_state_renderer.get_driver_data()
|
||||
|
||||
if len(driver_data.faceOrientation) == 3:
|
||||
pitch, yaw, _ = driver_data.faceOrientation
|
||||
looking_center = abs(math.degrees(pitch)) < self.LOOKING_THRESHOLD_DEG and abs(math.degrees(yaw)) < self.LOOKING_THRESHOLD_DEG
|
||||
else:
|
||||
looking_center = False
|
||||
|
||||
# stay at 100% once reached
|
||||
if (dm_state.faceDetected and looking_center) or self._progress.x > 0.99:
|
||||
slow = self._progress.x < 0.25
|
||||
duration = self.PROGRESS_DURATION * 2 if slow else self.PROGRESS_DURATION
|
||||
self._progress.x += 1.0 / (duration * gui_app.target_fps)
|
||||
self._progress.x = min(1.0, self._progress.x)
|
||||
else:
|
||||
self._progress.update(0.0)
|
||||
|
||||
self._good_button.set_enabled(self._progress.x >= 0.999)
|
||||
|
||||
def _render(self, _):
|
||||
if self._should_show_bad_face_page:
|
||||
return self._bad_face_page.render(self._rect)
|
||||
|
||||
self._dialog.render(self._rect)
|
||||
|
||||
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 80),
|
||||
int(self._rect.width), 80, rl.BLANK, rl.BLACK)
|
||||
|
||||
# draw white ring around dm icon to indicate progress
|
||||
ring_thickness = 8
|
||||
|
||||
# DM icon is 120x120, positioned on opposite side from driver
|
||||
dm_size = 120
|
||||
is_rhd = self._dialog.driver_state_renderer._is_rhd
|
||||
dm_center_x = (self._rect.x + dm_size / 2 + 8) if is_rhd else (self._rect.x + self._rect.width - dm_size / 2 - 8)
|
||||
dm_center_y = self._rect.y + dm_size / 2 + 8
|
||||
icon_edge_radius = dm_size / 2
|
||||
outer_radius = icon_edge_radius + 1 # 2px outward from icon edge
|
||||
inner_radius = outer_radius - ring_thickness # Inset by ring_thickness
|
||||
start_angle = 90.0 # Start from bottom
|
||||
end_angle = start_angle + self._progress.x * 360.0 # Clockwise
|
||||
|
||||
# Fade in alpha
|
||||
current_angle = end_angle - start_angle
|
||||
alpha = int(np.interp(current_angle, [0.0, 45.0], [0, 255]))
|
||||
|
||||
# White to green
|
||||
color_t = np.clip(np.interp(current_angle, [45.0, 360.0], [0.0, 1.0]), 0.0, 1.0)
|
||||
r = int(np.interp(color_t, [0.0, 1.0], [255, 0]))
|
||||
g = int(np.interp(color_t, [0.0, 1.0], [255, 255]))
|
||||
b = int(np.interp(color_t, [0.0, 1.0], [255, 64]))
|
||||
ring_color = rl.Color(r, g, b, alpha)
|
||||
|
||||
rl.draw_ring(
|
||||
rl.Vector2(dm_center_x, dm_center_y),
|
||||
inner_radius,
|
||||
outer_radius,
|
||||
start_angle,
|
||||
end_angle,
|
||||
36,
|
||||
ring_color,
|
||||
)
|
||||
|
||||
has_camera_frame = self._dialog._camera_view.frame is not None
|
||||
show_no_camera_bypass = self._allow_no_camera_bypass and not has_camera_frame
|
||||
if has_camera_frame or show_no_camera_bypass:
|
||||
self._back_button.render(rl.Rectangle(
|
||||
self._rect.x + 8,
|
||||
self._rect.y + self._rect.height - self._back_button.rect.height,
|
||||
self._back_button.rect.width,
|
||||
self._back_button.rect.height,
|
||||
))
|
||||
|
||||
self._good_button.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width - self._good_button.rect.width - 8,
|
||||
self._rect.y + self._rect.height - self._good_button.rect.height,
|
||||
self._good_button.rect.width,
|
||||
self._good_button.rect.height,
|
||||
))
|
||||
|
||||
if show_no_camera_bypass:
|
||||
gui_label(
|
||||
rl.Rectangle(self._rect.x + 20, self._rect.y + self._rect.height - 140, self._rect.width - 40, 60),
|
||||
tr("No camera detected in dev mode. Tap check to continue."),
|
||||
font_size=34,
|
||||
font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
)
|
||||
|
||||
# rounded border
|
||||
rl.draw_rectangle_rounded_lines_ex(self._rect, 0.2 * 1.02, 10, 50, rl.BLACK)
|
||||
|
||||
|
||||
class TrainingGuideRecordFront(SetupTermsPage):
|
||||
def __init__(self, continue_callback):
|
||||
def on_back():
|
||||
ui_state.params.put_bool("RecordFront", False)
|
||||
continue_callback()
|
||||
|
||||
def on_continue():
|
||||
ui_state.params.put_bool("RecordFront", True)
|
||||
continue_callback()
|
||||
|
||||
super().__init__(on_continue, back_callback=on_back, back_text=tr("no"), continue_text=tr("yes"))
|
||||
self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
|
||||
|
||||
self._dm_label = UnifiedLabel(tr("Do you want to upload driver camera data?"), 42,
|
||||
FontWeight.ROMAN)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# Disable driver monitoring model after last step
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._rect.y + 16 + scroll_offset,
|
||||
self._title_header.rect.width,
|
||||
self._title_header.rect.height,
|
||||
))
|
||||
|
||||
self._dm_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + 16,
|
||||
self._rect.width - 32,
|
||||
self._dm_label.get_content_height(int(self._rect.width - 32)),
|
||||
))
|
||||
|
||||
|
||||
class TrainingGuideAttentionNotice(SetupTermsPage):
|
||||
def __init__(self, continue_callback):
|
||||
super().__init__(continue_callback, continue_text=tr("continue"))
|
||||
self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
|
||||
self._warning_label = UnifiedLabel("1. IQ.Pilot is a driver assistance system.\n\n" +
|
||||
"2. You must pay attention at all times.\n\n" +
|
||||
"3. You must be ready to take over at any time.\n\n" +
|
||||
"4. You are fully responsible for driving the car.", 42,
|
||||
FontWeight.ROMAN)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._rect.y + 16 + scroll_offset,
|
||||
self._title_header.rect.width,
|
||||
self._title_header.rect.height,
|
||||
))
|
||||
|
||||
self._warning_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + 16,
|
||||
self._rect.width - 32,
|
||||
self._warning_label.get_content_height(int(self._rect.width - 32)),
|
||||
))
|
||||
|
||||
|
||||
class TrainingGuide(Widget):
|
||||
def __init__(self, completed_callback=None):
|
||||
super().__init__()
|
||||
self._completed_callback = completed_callback
|
||||
self._step = 0
|
||||
|
||||
self_ref = weakref.ref(self)
|
||||
|
||||
def on_continue():
|
||||
if obj := self_ref():
|
||||
obj._advance_step()
|
||||
|
||||
self._steps = [
|
||||
TrainingGuideAttentionNotice(continue_callback=on_continue),
|
||||
TrainingGuidePreDMTutorial(continue_callback=on_continue),
|
||||
TrainingGuideDMTutorial(continue_callback=on_continue),
|
||||
TrainingGuideRecordFront(continue_callback=on_continue),
|
||||
]
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
device.set_override_interactive_timeout(300)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
device.set_override_interactive_timeout(None)
|
||||
|
||||
def _advance_step(self):
|
||||
if self._step < len(self._steps) - 1:
|
||||
self._step += 1
|
||||
self._steps[self._step].show_event()
|
||||
else:
|
||||
self._step = 0
|
||||
if self._completed_callback:
|
||||
self._completed_callback()
|
||||
|
||||
def _render(self, _):
|
||||
if self._step < len(self._steps):
|
||||
self._steps[self._step].render(self._rect)
|
||||
return -1
|
||||
|
||||
|
||||
class DeclinePage(Widget):
|
||||
def __init__(self, back_callback=None):
|
||||
super().__init__()
|
||||
self._uninstall_slider = SmallSlider(tr("uninstall IQ.Pilot"), self._on_uninstall)
|
||||
|
||||
self._back_button = SmallButton(tr("back"))
|
||||
self._back_button.set_click_callback(back_callback)
|
||||
|
||||
self._warning_header = TermsHeader("you must accept the\nterms to use IQ.Pilot",
|
||||
gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
|
||||
|
||||
def _on_uninstall(self):
|
||||
ui_state.params.put_bool("DoUninstall", True)
|
||||
gui_app.request_close()
|
||||
|
||||
def _render(self, _):
|
||||
self._warning_header.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._rect.y + 16,
|
||||
self._warning_header.rect.width,
|
||||
self._warning_header.rect.height,
|
||||
))
|
||||
|
||||
self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage)
|
||||
self._back_button.render(rl.Rectangle(
|
||||
self._rect.x + 8,
|
||||
self._rect.y + self._rect.height - self._back_button.rect.height,
|
||||
self._back_button.rect.width,
|
||||
self._back_button.rect.height,
|
||||
))
|
||||
|
||||
self._uninstall_slider.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width - self._uninstall_slider.rect.width,
|
||||
self._rect.y + self._rect.height - self._uninstall_slider.rect.height,
|
||||
self._uninstall_slider.rect.width,
|
||||
self._uninstall_slider.rect.height,
|
||||
))
|
||||
|
||||
|
||||
class TermsPage(SetupTermsPage):
|
||||
def __init__(self, on_accept=None, on_decline=None):
|
||||
super().__init__(on_accept, on_decline, tr("decline"))
|
||||
|
||||
info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
|
||||
self._title_header = TermsHeader("terms of service", info_txt)
|
||||
|
||||
self._terms_label = UnifiedLabel("You must accept the Terms of Service to use IQ.Pilot. " +
|
||||
"Read the latest terms before continuing at https://iqlvbs.com/tos", 36,
|
||||
FontWeight.ROMAN)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
|
||||
self._title_header.render()
|
||||
|
||||
self._terms_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
|
||||
self._rect.width - 100,
|
||||
self._terms_label.get_content_height(int(self._rect.width - 100)),
|
||||
))
|
||||
|
||||
|
||||
class OnboardingWindow(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
|
||||
self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
|
||||
|
||||
self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height))
|
||||
|
||||
# Windows
|
||||
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
|
||||
self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
|
||||
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
|
||||
|
||||
if not self._accepted_terms:
|
||||
self._state = OnboardingState.TERMS
|
||||
elif not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
device.set_override_interactive_timeout(300)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
device.set_override_interactive_timeout(None)
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self._accepted_terms and self._training_done
|
||||
|
||||
def _on_terms_declined(self):
|
||||
self._state = OnboardingState.DECLINE
|
||||
|
||||
def _on_decline_back(self):
|
||||
self._state = OnboardingState.TERMS
|
||||
|
||||
def close(self):
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def _on_terms_accepted(self):
|
||||
ui_state.params.put("HasAcceptedTerms", terms_version)
|
||||
if not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_completed_training(self):
|
||||
ui_state.params.put("CompletedTrainingVersion", training_version)
|
||||
self.close()
|
||||
|
||||
def _render(self, _):
|
||||
# opaque full-screen background so the home/onroad underneath doesn't show through
|
||||
rl.draw_rectangle_rec(rl.Rectangle(0, 0, gui_app.width, gui_app.height), rl.BLACK)
|
||||
if self._state == OnboardingState.TERMS:
|
||||
self._terms.render(self._rect)
|
||||
elif self._state == OnboardingState.ONBOARDING:
|
||||
if not self._training_done:
|
||||
self._training_guide.render(self._rect)
|
||||
else:
|
||||
self.close()
|
||||
elif self._state == OnboardingState.DECLINE:
|
||||
self._decline_page.render(self._rect)
|
||||
return -1
|
||||
101
iqpilot/selfdrive/ui/mici/layouts/settings/cruise.py
Normal file
101
iqpilot/selfdrive/ui/mici/layouts/settings/cruise.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import FollowDistanceSelector, MappedParamToggle, IQModeSelector, SafeParamControl
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
MS_TO_MPH = 2.23694
|
||||
_SPEED_MPH = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80]
|
||||
_SPEED_OPTIONS = [f"{s} mph" for s in _SPEED_MPH]
|
||||
_SPEED_VALUES = [round(s / MS_TO_MPH, 2) for s in _SPEED_MPH]
|
||||
|
||||
_LEAD_SPEED_MPH = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85]
|
||||
_LEAD_SPEED_OPTIONS = [f"{s} mph" for s in _LEAD_SPEED_MPH]
|
||||
_LEAD_SPEED_VALUES = [round(s / MS_TO_MPH, 2) for s in _LEAD_SPEED_MPH]
|
||||
|
||||
_STOP_TIME_OPTIONS = ["1.0s", "1.5s", "2.0s", "2.5s", "3.0s", "3.5s", "4.0s", "4.5s", "5.0s", "5.5s", "6.0s"]
|
||||
_STOP_TIME_VALUES = [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0]
|
||||
|
||||
_LOOKAHEAD_OPTIONS = ["1.0s", "2.0s", "3.0s", "4.0s", "5.0s", "6.0s", "7.0s", "8.0s", "9.0s", "10.0s"]
|
||||
_LOOKAHEAD_VALUES = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
||||
|
||||
|
||||
class DynamicSettingsPanel(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._items = [
|
||||
BigParamControl(tr("IQ.Dynamic Curves"), "IQDynamicConditionalCurves"),
|
||||
BigParamControl(tr("IQ.Dynamic Slower Lead"), "IQDynamicConditionalSlowerLead"),
|
||||
BigParamControl(tr("IQ.Dynamic Stopped Lead"), "IQDynamicConditionalStoppedLead"),
|
||||
BigParamControl(tr("IQ.Dynamic Model Stops"), "IQDynamicConditionalModelStops"),
|
||||
BigParamControl(tr("IQ.Dynamic SLC Fallback"), "IQDynamicConditionalSLCFallback"),
|
||||
MappedParamToggle(tr("IQ.Dynamic Low Speed"), "IQDynamicConditionalSpeed", _SPEED_OPTIONS, _SPEED_VALUES),
|
||||
MappedParamToggle(tr("IQ.Dynamic Lead Speed"), "IQDynamicConditionalLeadSpeed", _LEAD_SPEED_OPTIONS, _LEAD_SPEED_VALUES),
|
||||
MappedParamToggle(tr("Model Stop Time"), "IQDynamicModelStopTime", _STOP_TIME_OPTIONS, _STOP_TIME_VALUES),
|
||||
BigParamControl(tr("IQ Force Stops"), "IQForceStops"),
|
||||
]
|
||||
self._scroller.add_widgets(self._items)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for w in self._items:
|
||||
w.refresh()
|
||||
|
||||
|
||||
class SlcSettingsPanel(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._items = [
|
||||
MappedParamToggle(tr("SLC Policy"), "SLCPolicy", [tr("map only"), tr("map priority"), tr("combined")], [0, 1, 2]),
|
||||
MappedParamToggle(tr("SLC Override"), "SLCOverrideMethod", [tr("manual"), tr("set speed")], [0, 1]),
|
||||
BigParamControl(tr("SLC Confirm Higher"), "SpeedLimitConfirmationHigher"),
|
||||
BigParamControl(tr("SLC Confirm Lower"), "SpeedLimitConfirmationLower"),
|
||||
BigParamControl(tr("SLC Auto Confirm"), "SLCAutoConfirm"),
|
||||
BigParamControl(tr("SLC Fallback IQ.Pilot"), "SLCFallbackExperimentalMode"),
|
||||
BigParamControl(tr("SLC Online Filler"), "SLCOnlineFiller"),
|
||||
MappedParamToggle(tr("Lookahead Higher"), "MapSpeedLookaheadHigher", _LOOKAHEAD_OPTIONS, _LOOKAHEAD_VALUES),
|
||||
MappedParamToggle(tr("Lookahead Lower"), "MapSpeedLookaheadLower", _LOOKAHEAD_OPTIONS, _LOOKAHEAD_VALUES),
|
||||
]
|
||||
self._scroller.add_widgets(self._items)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for w in self._items:
|
||||
w.refresh()
|
||||
|
||||
|
||||
class CruiseLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._dynamic_panel = DynamicSettingsPanel()
|
||||
self._slc_panel = SlcSettingsPanel()
|
||||
|
||||
self._follow_dist = FollowDistanceSelector()
|
||||
self._mode = IQModeSelector(self._follow_dist.refresh)
|
||||
self._dynamic_settings = BigButton(tr("iq.dynamic settings"))
|
||||
self._dynamic_settings.set_click_callback(lambda: gui_app.push_widget(self._dynamic_panel))
|
||||
self._dynamic_settings.set_visible(self._mode.is_dynamic)
|
||||
self._speed_limit = MappedParamToggle(tr("Speed Limit"), "IQSpeedAssistMode",
|
||||
[tr("off"), tr("info"), tr("warning"), tr("control")])
|
||||
self._slc_settings = BigButton(tr("speed limit settings"))
|
||||
self._slc_settings.set_click_callback(lambda: gui_app.push_widget(self._slc_panel))
|
||||
self._new_lead_mpc = SafeParamControl(tr("Experimental Lead MPC"), "newLeadMpc", default_on=True)
|
||||
|
||||
self._main = [self._mode, self._dynamic_settings, self._follow_dist, self._speed_limit,
|
||||
self._new_lead_mpc, self._slc_settings]
|
||||
self._scroller.add_widgets(self._main)
|
||||
|
||||
def _refresh(self):
|
||||
self._mode.refresh()
|
||||
self._follow_dist.refresh()
|
||||
self._speed_limit.refresh()
|
||||
self._new_lead_mpc.refresh()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._refresh()
|
||||
38
iqpilot/selfdrive/ui/mici/layouts/settings/dashcam.py
Normal file
38
iqpilot/selfdrive/ui/mici/layouts/settings/dashcam.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
|
||||
from iqpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class DashcamLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._dashcam = BigParamControl(tr("enable dashcam"), "DashcamEnabled", toggle_callback=restart_needed_callback)
|
||||
self._record_front = BigParamControl(tr("record driver camera"), "RecordFront", toggle_callback=restart_needed_callback)
|
||||
self._record_audio = BigParamControl(tr("record microphone audio"), "RecordAudio", toggle_callback=restart_needed_callback)
|
||||
|
||||
self._scroller.add_widgets([self._dashcam, self._record_front, self._record_audio])
|
||||
|
||||
self._refresh_toggles = (
|
||||
("DashcamEnabled", self._dashcam),
|
||||
("RecordFront", self._record_front),
|
||||
("RecordAudio", self._record_audio),
|
||||
)
|
||||
|
||||
self._record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
|
||||
self._record_audio.set_enabled(lambda: not ui_state.engaged)
|
||||
ui_state.add_engaged_transition_callback(self._update_toggles)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
129
iqpilot/selfdrive/ui/mici/layouts/settings/developer.py
Normal file
129
iqpilot/selfdrive/ui/mici/layouts/settings/developer.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.system.hardware.tici.usb_storage import apply_usb_storage_state
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigToggle, BigCircleParamControl
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog, BigInputDialog
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class DeveloperLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._ssh_fetcher = SshKeyFetcher(ui_state.params)
|
||||
|
||||
def github_username_callback(username: str):
|
||||
if username:
|
||||
self._ssh_keys_btn.set_value(tr("Loading..."))
|
||||
self._ssh_keys_btn.set_enabled(False)
|
||||
|
||||
def on_response(error):
|
||||
self._ssh_keys_btn.set_enabled(True)
|
||||
if error is None:
|
||||
self._ssh_keys_btn.set_value(username)
|
||||
else:
|
||||
self._ssh_keys_btn.set_value(tr("Not set"))
|
||||
gui_app.push_widget(BigDialog("", error))
|
||||
|
||||
self._ssh_fetcher.fetch(username, on_response)
|
||||
else:
|
||||
self._ssh_fetcher.clear()
|
||||
self._ssh_keys_btn.set_value(tr("Not set"))
|
||||
|
||||
def ssh_keys_callback():
|
||||
github_username = ui_state.params.get("GithubUsername") or ""
|
||||
dlg = BigInputDialog(tr("enter GitHub username..."), github_username, minimum_length=0, confirm_callback=github_username_callback)
|
||||
if not system_time_valid():
|
||||
dlg = BigDialog("", tr("Please connect to Wi-Fi to fetch your key."))
|
||||
gui_app.push_widget(dlg)
|
||||
return
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64)
|
||||
github_username = ui_state.params.get("GithubUsername") or ""
|
||||
self._ssh_keys_btn = BigButton(tr("SSH keys"), tr("Not set") if not github_username else github_username, icon=txt_ssh)
|
||||
self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
|
||||
|
||||
self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12))
|
||||
self._usb_storage_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "UsbStorageEnabled",
|
||||
toggle_callback=apply_usb_storage_state, icon_offset=(0, 12))
|
||||
self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12))
|
||||
self._long_maneuver_toggle = BigToggle(tr("longitudinal maneuver mode"),
|
||||
initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
|
||||
toggle_callback=self._on_long_maneuver_mode)
|
||||
self._lat_maneuver_toggle = BigToggle(tr("lateral maneuver mode"),
|
||||
initial_state=ui_state.params.get_bool("LateralManeuverMode"),
|
||||
toggle_callback=self._on_lat_maneuver_mode)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._adb_toggle,
|
||||
self._usb_storage_toggle,
|
||||
self._ssh_toggle,
|
||||
self._ssh_keys_btn,
|
||||
self._long_maneuver_toggle,
|
||||
self._lat_maneuver_toggle,
|
||||
])
|
||||
|
||||
self._refresh_toggles = (
|
||||
("AdbEnabled", self._adb_toggle),
|
||||
("UsbStorageEnabled", self._usb_storage_toggle),
|
||||
("SshEnabled", self._ssh_toggle),
|
||||
("LongitudinalManeuverMode", self._long_maneuver_toggle),
|
||||
("LateralManeuverMode", self._lat_maneuver_toggle),
|
||||
)
|
||||
onroad_blocked_toggles = (self._adb_toggle, self._usb_storage_toggle)
|
||||
release_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle)
|
||||
engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle)
|
||||
|
||||
for item in release_blocked_toggles:
|
||||
item.set_visible(not ui_state.is_release)
|
||||
|
||||
for item in onroad_blocked_toggles:
|
||||
item.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
for item in engaged_blocked_toggles:
|
||||
item.set_enabled(lambda: not ui_state.engaged)
|
||||
|
||||
ui_state.add_offroad_transition_callback(self._update_toggles)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._ssh_fetcher.update()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
|
||||
if ui_state.CP is not None:
|
||||
long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
|
||||
self._long_maneuver_toggle.set_enabled(long_man_enabled)
|
||||
self._lat_maneuver_toggle.set_enabled(ui_state.is_offroad())
|
||||
else:
|
||||
self._long_maneuver_toggle.set_enabled(False)
|
||||
self._lat_maneuver_toggle.set_enabled(False)
|
||||
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
|
||||
def _on_long_maneuver_mode(self, state: bool):
|
||||
ui_state.params.put_bool("LongitudinalManeuverMode", state)
|
||||
ui_state.params.put_bool("LateralManeuverMode", False)
|
||||
self._lat_maneuver_toggle.set_checked(False)
|
||||
restart_needed_callback()
|
||||
|
||||
def _on_lat_maneuver_mode(self, state: bool):
|
||||
ui_state.params.put_bool("LateralManeuverMode", state)
|
||||
ui_state.params.put_bool("ExperimentalMode", False)
|
||||
ui_state.params.put_bool("LongitudinalManeuverMode", False)
|
||||
self._long_maneuver_toggle.set_checked(False)
|
||||
restart_needed_callback()
|
||||
278
iqpilot/selfdrive/ui/mici/layouts/settings/device.py
Normal file
278
iqpilot/selfdrive/ui/mici/layouts/settings/device.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigCircleButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog, BigConfirmationDialog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_pairing_dialog import PairingDialog
|
||||
from iqpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
|
||||
from iqpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import multilang, tr
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer
|
||||
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
|
||||
|
||||
|
||||
class ReviewTermsPage(TermsPage, NavScroller):
|
||||
"""TermsPage with NavWidget swipe-to-dismiss for reviewing in device settings."""
|
||||
def __init__(self):
|
||||
super().__init__(on_accept=self.dismiss, on_decline=self.dismiss)
|
||||
self._continue_button.set_visible(False)
|
||||
self._back_button.set_visible(False)
|
||||
|
||||
|
||||
class ReviewTrainingGuide(TrainingGuide):
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
device.set_override_interactive_timeout(300)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
device.set_override_interactive_timeout(None)
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
|
||||
|
||||
class MiciFccModal(NavRawScrollPanel):
|
||||
def __init__(self, file_path: str | None = None, text: str | None = None):
|
||||
super().__init__()
|
||||
self._content = HtmlRenderer(file_path=file_path, text=text)
|
||||
self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
content_height = self._content.get_total_height(int(rect.width))
|
||||
content_height += self._fcc_logo.height + 20
|
||||
|
||||
scroll_content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height)
|
||||
scroll_offset = round(self._scroll_panel.update(rect, scroll_content_rect.height))
|
||||
|
||||
fcc_pos = rl.Vector2(rect.x + 20, rect.y + 20 + scroll_offset)
|
||||
|
||||
scroll_content_rect.y += scroll_offset + self._fcc_logo.height + 20
|
||||
self._content.render(scroll_content_rect)
|
||||
|
||||
rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.Texture, exit_on_confirm: bool = True, red: bool = False):
|
||||
if not ui_state.engaged:
|
||||
def confirm_callback():
|
||||
# Check engaged again in case it changed while the dialog was open
|
||||
# TODO: if true, we stay on the dialog if not exit_on_confirm until normal onroad timeout
|
||||
if not ui_state.engaged:
|
||||
callback()
|
||||
|
||||
gui_app.push_widget(BigConfirmationDialog(tr("slide to\n{}").format(action_text.lower()), icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red))
|
||||
else:
|
||||
gui_app.push_widget(BigDialog("", tr("Disengage to {}").format(action_text)))
|
||||
|
||||
|
||||
class EngagedConfirmationCircleButton(BigCircleButton):
|
||||
def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True,
|
||||
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, red, icon_offset)
|
||||
self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
|
||||
class EngagedConfirmationButton(BigButton):
|
||||
def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None],
|
||||
exit_on_confirm: bool = True, red: bool = False):
|
||||
super().__init__(text, "", icon)
|
||||
self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
|
||||
class ForceOffroadButton(BigButton):
|
||||
def __init__(self):
|
||||
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 64, 64)
|
||||
super().__init__(tr("force\noffroad"), "", self._offroad_icon)
|
||||
self.set_press_effect_enabled(False)
|
||||
self._label.set_font_size(40)
|
||||
self._label.set_line_height(0.95)
|
||||
self.set_click_callback(self._on_click)
|
||||
self._sync_from_params()
|
||||
|
||||
def _forced(self) -> bool:
|
||||
return ui_state.params.get_bool("IQAlwaysOffroad")
|
||||
|
||||
def _on_click(self):
|
||||
forced = self._forced()
|
||||
action = tr("disable force offroad") if forced else tr("force offroad")
|
||||
_engaged_confirmation_click(lambda: ui_state.params.put_bool("IQAlwaysOffroad", not forced),
|
||||
action, self._offroad_icon, exit_on_confirm=False)
|
||||
|
||||
def _sync_from_params(self):
|
||||
self.set_text(tr("disable\nforce offroad") if self._forced() else tr("force\noffroad"))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._sync_from_params()
|
||||
|
||||
|
||||
class DeviceInfoLayoutMici(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
params = Params()
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
self._dongle_id_label = UnifiedLabel(tr("device ID"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False)
|
||||
|
||||
self._serial_number_label = UnifiedLabel(tr("serial"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False)
|
||||
|
||||
def _render(self, _):
|
||||
self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self._dongle_id_label.render()
|
||||
|
||||
self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self._dongle_id_text_label.render()
|
||||
|
||||
self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self._serial_number_label.render()
|
||||
|
||||
self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
self._serial_number_text_label.render()
|
||||
|
||||
|
||||
class PairBigButton(BigButton):
|
||||
"""Konn3kt connect button: logo + live connection-status dot. Uses the new accent box style."""
|
||||
KONN3KT_ONLINE_NS = 80_000_000_000 # 80 seconds in nanoseconds
|
||||
STATUS_ONLINE = rl.Color(0x86, 0xFF, 0x4E, 255)
|
||||
STATUS_OFFLINE = rl.Color(0xC9, 0x22, 0x31, 255)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("konn3kt", tr("pair in app"), gui_app.texture("icons_mici/settings/konn3kt_icon.png", 56, 56))
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 64
|
||||
|
||||
def _is_konn3kt_online(self) -> bool:
|
||||
last_ping = ui_state.sm['deviceState'].lastAthenaPingTime
|
||||
return last_ping != 0 and (time.monotonic_ns() - last_ping) < self.KONN3KT_ONLINE_NS
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired():
|
||||
self.set_value(tr("online") if self._is_konn3kt_online() else tr("offline"))
|
||||
else:
|
||||
self.set_value(tr("pair in app"))
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
if ui_state.prime_state.is_paired():
|
||||
color = self.STATUS_ONLINE if self._is_konn3kt_online() else self.STATUS_OFFLINE
|
||||
rl.draw_circle(int(self._rect.x + 30), int(self._rect.y + 30), 9, color)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
if ui_state.prime_state.is_paired():
|
||||
return
|
||||
dlg: BigDialog | PairingDialog
|
||||
if not system_time_valid():
|
||||
dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing."))
|
||||
elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID):
|
||||
dlg = BigDialog("", tr("Device must be registered with Konn3kt to pair."))
|
||||
else:
|
||||
dlg = PairingDialog()
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class DeviceLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._fcc_dialog: HtmlModal | None = None
|
||||
|
||||
def power_off_callback():
|
||||
ui_state.params.put_bool("DoShutdown", True)
|
||||
|
||||
def reboot_callback():
|
||||
ui_state.params.put_bool("DoReboot", True)
|
||||
|
||||
def reset_calibration_callback():
|
||||
params = ui_state.params
|
||||
params.remove("CalibrationParams")
|
||||
params.remove("LiveTorqueParameters")
|
||||
params.remove("LiveParameters")
|
||||
params.remove("LiveParametersV2")
|
||||
params.remove("LiveDelay")
|
||||
params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
reset_calibration_btn = EngagedConfirmationButton(tr("reset calibration"), tr("reset"), gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64),
|
||||
reset_calibration_callback)
|
||||
|
||||
reboot_btn = EngagedConfirmationCircleButton(tr("reboot"), gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
|
||||
reboot_callback, exit_on_confirm=False)
|
||||
|
||||
self._power_off_btn = EngagedConfirmationCircleButton(tr("power off"), gui_app.texture("icons_mici/settings/device/power.png", 64, 66),
|
||||
power_off_callback, exit_on_confirm=False, red=True)
|
||||
self._power_off_btn.set_visible(lambda: not ui_state.ignition)
|
||||
|
||||
regulatory_btn = BigButton(tr("regulatory info"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
|
||||
regulatory_btn.set_click_callback(self._on_regulatory)
|
||||
|
||||
driver_cam_btn = BigButton(tr("driver\ncamera preview"), "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64))
|
||||
driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog()))
|
||||
driver_cam_btn.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
review_training_guide_btn = BigButton(tr("review\ntraining guide"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
|
||||
review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTrainingGuide(completed_callback=lambda: gui_app.pop_widgets_to(self))))
|
||||
review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
terms_btn = BigButton(tr("terms &\nconditions"), "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64))
|
||||
terms_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTermsPage()))
|
||||
|
||||
language_btn = BigButton(tr("change\nlanguage"), multilang.codes.get(multilang.language, ""),
|
||||
gui_app.texture("icons_mici/settings/device/language.png", 64, 64))
|
||||
language_btn.set_click_callback(self._on_change_language)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
DeviceInfoLayoutMici(),
|
||||
PairBigButton(),
|
||||
review_training_guide_btn,
|
||||
driver_cam_btn,
|
||||
language_btn,
|
||||
terms_btn,
|
||||
regulatory_btn,
|
||||
reset_calibration_btn,
|
||||
ForceOffroadButton(),
|
||||
reboot_btn,
|
||||
self._power_off_btn,
|
||||
])
|
||||
|
||||
def _on_change_language(self):
|
||||
names = list(multilang.languages.keys())
|
||||
if not names:
|
||||
return
|
||||
current = multilang.codes.get(multilang.language)
|
||||
dlg = BigMultiOptionDialog(names, current if current in names else names[0], right_btn="check",
|
||||
right_btn_callback=lambda: self._apply_language(dlg.get_selected_option()))
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _apply_language(self, name: str):
|
||||
code = multilang.languages.get(name)
|
||||
if code and code != multilang.language:
|
||||
multilang.change_language(code)
|
||||
|
||||
def _on_regulatory(self):
|
||||
if not self._fcc_dialog:
|
||||
self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "iqpilot/selfdrive/assets/offroad/mici_fcc.html"))
|
||||
gui_app.push_widget(self._fcc_dialog)
|
||||
56
iqpilot/selfdrive/ui/mici/layouts/settings/display.py
Normal file
56
iqpilot/selfdrive/ui/mici/layouts/settings/display.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.hardware import HARDWARE
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
_BRIGHT_PERCENTS = [f"{p}%" for p in range(5, 101, 5)]
|
||||
_DISPLAY_BRIGHT_VALUES = [0] + list(range(5, 101, 5))
|
||||
|
||||
_ONROAD_BRIGHT_OPTIONS = ["auto", "auto dark"] + _BRIGHT_PERCENTS
|
||||
_ONROAD_BRIGHT_VALUES = list(range(len(_ONROAD_BRIGHT_OPTIONS)))
|
||||
|
||||
_DELAY_OPTIONS = ["15s", "30s", "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", "10m"]
|
||||
_DELAY_VALUES = [15, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600]
|
||||
|
||||
_INTERACT_SUFFIX = ["10s", "20s", "30s", "40s", "50s", "1m", "70s", "80s", "90s", "100s", "110s", "2m"]
|
||||
_INTERACT_VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
|
||||
|
||||
|
||||
class DisplayLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._force_mici = BigParamControl(tr("force mici UI"), "ForceSmallUI")
|
||||
self._display_bright = MappedParamToggle(tr("display brightness"), "Brightness",
|
||||
[tr("default")] + _BRIGHT_PERCENTS, _DISPLAY_BRIGHT_VALUES)
|
||||
self._onroad_bright = MappedParamToggle(tr("driving brightness"), "OnroadScreenOffBrightness",
|
||||
[tr("auto"), tr("auto dark")] + _BRIGHT_PERCENTS, _ONROAD_BRIGHT_VALUES)
|
||||
self._delay = MappedParamToggle(tr("brightness delay"), "OnroadScreenOffTimer",
|
||||
_DELAY_OPTIONS, _DELAY_VALUES)
|
||||
self._interact = MappedParamToggle(tr("interactivity"), "InteractivityTimeout",
|
||||
[tr("default")] + _INTERACT_SUFFIX, _INTERACT_VALUES)
|
||||
|
||||
self._items = [self._display_bright, self._onroad_bright, self._delay, self._interact]
|
||||
if HARDWARE.get_device_type() != "mici":
|
||||
self._items.insert(0, self._force_mici)
|
||||
self._scroller.add_widgets(self._items)
|
||||
|
||||
def _refresh(self):
|
||||
for w in self._items:
|
||||
w.refresh()
|
||||
bval = int(float(ui_state.params.get("OnroadScreenOffBrightness", return_default=True) or 0))
|
||||
self._delay.set_enabled(bval not in (0, 1))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
bval = int(float(ui_state.params.get("OnroadScreenOffBrightness", return_default=True) or 0))
|
||||
self._delay.set_enabled(bval not in (0, 1))
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._refresh()
|
||||
89
iqpilot/selfdrive/ui/mici/layouts/settings/drive_history.py
Normal file
89
iqpilot/selfdrive/ui/mici/layouts/settings/drive_history.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.common.api import api_get
|
||||
from iqpilot.common.constants import CV
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.ui.lib.api_helpers import get_token
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
|
||||
_ACCENT = rl.Color(0x3A, 0xDD, 0xC6, 255)
|
||||
_CARD_BG = rl.Color(26, 27, 30, 255)
|
||||
_LABEL = rl.Color(150, 150, 150, 255)
|
||||
|
||||
|
||||
class TripsLayoutMici(NavWidget):
|
||||
PARAM_KEY = "ApiCache_DriveStats"
|
||||
UPDATE_INTERVAL = 30
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._stats = self._params.get(self.PARAM_KEY) or {}
|
||||
self._bold = gui_app.font(FontWeight.BOLD)
|
||||
self._medium = gui_app.font(FontWeight.MEDIUM)
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._update_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def __del__(self):
|
||||
self._running = False
|
||||
|
||||
def _fetch(self):
|
||||
try:
|
||||
dongle_id = self._params.get("DongleId")
|
||||
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
|
||||
return
|
||||
resp = api_get(f"v1.1/devices/{dongle_id}/stats", access_token=get_token(dongle_id))
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
self._stats = data
|
||||
self._params.put(self.PARAM_KEY, data)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"trips: failed to fetch drive stats: {e}")
|
||||
|
||||
def _update_loop(self):
|
||||
while self._running:
|
||||
if not ui_state.started and device._awake:
|
||||
self._fetch()
|
||||
time.sleep(self.UPDATE_INTERVAL)
|
||||
|
||||
def _render_group(self, x, y, w, h, title, data, is_metric):
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.16, 8, _CARD_BG)
|
||||
rl.draw_text_ex(self._bold, title, rl.Vector2(x + 22, y + 12), 24, 0, _ACCENT)
|
||||
|
||||
routes = int(data.get("routes", 0) or 0)
|
||||
distance = data.get("distance", 0) or 0
|
||||
dist = int(distance * CV.MPH_TO_KPH) if is_metric else int(distance)
|
||||
hours = int((data.get("minutes", 0) or 0) / 60)
|
||||
cols = [(str(routes), "drives"), (str(dist), "km" if is_metric else "mi"), (str(hours), "hours")]
|
||||
|
||||
col_w = w / 3
|
||||
for i, (val, lbl) in enumerate(cols):
|
||||
cx = x + col_w * i + col_w / 2
|
||||
vs = measure_text_cached(self._bold, val, 46)
|
||||
rl.draw_text_ex(self._bold, val, rl.Vector2(cx - vs.x / 2, y + h / 2 - 28), 46, 0, rl.WHITE)
|
||||
ls = measure_text_cached(self._medium, lbl, 22)
|
||||
rl.draw_text_ex(self._medium, lbl, rl.Vector2(cx - ls.x / 2, y + h / 2 + 24), 22, 0, _LABEL)
|
||||
|
||||
def _render(self, _):
|
||||
rect = self._rect
|
||||
is_metric = self._params.get_bool("IsMetric")
|
||||
stats = self._stats if isinstance(self._stats, dict) else {}
|
||||
pad = 12
|
||||
h = (rect.height - 3 * pad) / 2
|
||||
w = rect.width - 2 * pad
|
||||
x = rect.x + pad
|
||||
self._render_group(x, rect.y + pad, w, h, "ALL TIME", stats.get("all", {}), is_metric)
|
||||
self._render_group(x, rect.y + 2 * pad + h, w, h, "PAST WEEK", stats.get("week", {}), is_metric)
|
||||
161
iqpilot/selfdrive/ui/mici/layouts/settings/iq_widgets.py
Normal file
161
iqpilot/selfdrive/ui/mici/layouts/settings/iq_widgets.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.common.params import Params, UnknownKeyName
|
||||
from iqpilot.selfdrive.longitudinal_settings import (
|
||||
LONGITUDINAL_MODE_DYNAMIC,
|
||||
LONGITUDINAL_MODE_PILOT,
|
||||
PERSONALITY_VALUES,
|
||||
apply_longitudinal_mode,
|
||||
get_follow_distance_state,
|
||||
get_longitudinal_mode,
|
||||
set_valid_personality,
|
||||
)
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigMultiToggle, BigToggle, BigParamControl
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class SafeParamControl(BigParamControl):
|
||||
"""BigParamControl that tolerates a param missing from the COMPILED params registry.
|
||||
|
||||
A key added to params_keys.h only exists at runtime once params_pyx.so is rebuilt; a
|
||||
.py-only / stale prebuilt deploy leaves get_bool/put_bool raising UnknownKeyName, which
|
||||
would crash the UI on construction. Default to `default_on` for display and no-op the
|
||||
write instead of crashing — mirrors the plannerd defensive read in long_mpc.py.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, param: str, default_on: bool = True, toggle_callback=None):
|
||||
self._default_on = default_on
|
||||
BigToggle.__init__(self, text, "", toggle_callback=toggle_callback)
|
||||
self.param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self._safe_get())
|
||||
|
||||
def _safe_get(self) -> bool:
|
||||
try:
|
||||
return self.params.get_bool(self.param)
|
||||
except UnknownKeyName:
|
||||
return self._default_on
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self._safe_get())
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
BigToggle._handle_mouse_release(self, mouse_pos)
|
||||
try:
|
||||
self.params.put_bool(self.param, self._checked)
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
|
||||
class MappedParamToggle(BigMultiToggle):
|
||||
"""Multi-option toggle whose options map to arbitrary param values (int or float, drum-style).
|
||||
|
||||
Up to PILL_LIMIT options render as the stock vertical pill column; more options would
|
||||
overflow the box, so they instead show the current value as a sub-label and cycle on tap.
|
||||
"""
|
||||
PILL_LIMIT = 4
|
||||
|
||||
def __init__(self, text: str, param: str, options: list[str], values: list | None = None):
|
||||
super().__init__(text, options)
|
||||
self._param = param
|
||||
self._values = values if values is not None else list(range(len(options)))
|
||||
self._params = Params()
|
||||
self.refresh()
|
||||
|
||||
def _value_only(self) -> bool:
|
||||
return len(self._options) > self.PILL_LIMIT
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
if self._value_only():
|
||||
return BigButton._width_hint(self)
|
||||
return super()._width_hint()
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
if self._value_only():
|
||||
BigButton._draw_content(self, btn_x, btn_y, btn_width, btn_height)
|
||||
else:
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
def refresh(self):
|
||||
try:
|
||||
raw = self._params.get(self._param, return_default=True)
|
||||
except UnknownKeyName:
|
||||
raw = self._values[0]
|
||||
try:
|
||||
cur = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
cur = float(self._values[0])
|
||||
idx = min(range(len(self._values)), key=lambda i: abs(float(self._values[i]) - cur))
|
||||
self.set_value(self._options[idx])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
idx = self._options.index(self.value)
|
||||
try:
|
||||
self._params.put(self._param, self._values[idx])
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
|
||||
class FollowDistanceSelector(BigMultiToggle):
|
||||
OPTIONS = ["aggressive", "standard", "relaxed", "stock"]
|
||||
|
||||
def __init__(self):
|
||||
self._display_options = [tr(option) for option in self.OPTIONS]
|
||||
super().__init__(tr("Follow Distance"), self._display_options)
|
||||
self._params = Params()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
selection, enabled = get_follow_distance_state(self._params)
|
||||
self.set_value(self._display_options[3 if selection is None else selection])
|
||||
self.set_enabled(enabled)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
if get_longitudinal_mode(self._params) not in (LONGITUDINAL_MODE_DYNAMIC, LONGITUDINAL_MODE_PILOT):
|
||||
return
|
||||
BigButton._handle_mouse_release(self, mouse_pos)
|
||||
selection, _ = get_follow_distance_state(self._params)
|
||||
if selection is None:
|
||||
self.refresh()
|
||||
return
|
||||
next_selection = PERSONALITY_VALUES[(PERSONALITY_VALUES.index(selection) + 1) % len(PERSONALITY_VALUES)]
|
||||
set_valid_personality(self._params, next_selection)
|
||||
self.set_value(self._display_options[next_selection])
|
||||
|
||||
|
||||
class IQModeSelector(BigMultiToggle):
|
||||
"""Longitudinal mode selector: Stock ACC / IQ.Chill / IQ.Dynamic / IQ.Pilot.
|
||||
|
||||
A single tap cycles to the next mode and applies the matching param combo immediately.
|
||||
"""
|
||||
OPTIONS = ["Stock ACC", "IQ.Chill", "IQ.Dynamic", "IQ.Pilot"]
|
||||
|
||||
def __init__(self, mode_callback=None):
|
||||
self._display_options = [tr(option) for option in self.OPTIONS]
|
||||
super().__init__(tr("IQ Mode"), self._display_options)
|
||||
self._params = Params()
|
||||
self._mode_callback = mode_callback
|
||||
self.refresh()
|
||||
|
||||
def _index(self) -> int:
|
||||
return get_longitudinal_mode(self._params)
|
||||
|
||||
def is_dynamic(self) -> bool:
|
||||
return self._index() == 2
|
||||
|
||||
def refresh(self):
|
||||
self.set_value(self._display_options[self._index()])
|
||||
|
||||
def _apply(self, idx: int):
|
||||
apply_longitudinal_mode(self._params, idx)
|
||||
self._params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
nxt = (self._index() + 1) % len(self.OPTIONS)
|
||||
self._apply(nxt)
|
||||
self.set_value(self._display_options[nxt])
|
||||
if self._mode_callback:
|
||||
self._mode_callback()
|
||||
581
iqpilot/selfdrive/ui/mici/layouts/settings/models.py
Normal file
581
iqpilot/selfdrive/ui/mici/layouts/settings/models.py
Normal file
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.cereal import custom
|
||||
|
||||
from iqpilot.system.ui.iqwidgets.widgets.helpers.glyphs import draw_star
|
||||
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl, GreyBigButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigConfirmationDialog
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
|
||||
|
||||
from iqpilot.selfdrive.iqmodeld.models.helpers import select_default_model, is_default_bundle
|
||||
from iqpilot.selfdrive.iqmodeld.models.runners.model_runner import CUSTOM_MODEL_PATH
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
_DELAY_OPTIONS = ["0.05s", "0.10s", "0.15s", "0.20s", "0.25s", "0.30s", "0.35s", "0.40s", "0.45s", "0.50s"]
|
||||
_DELAY_VALUES = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50]
|
||||
|
||||
_LANE_TURN_VALUES = [15.0, 19.0, 20.0]
|
||||
|
||||
_DL = custom.IQModelManager.DownloadStatus
|
||||
_ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle"
|
||||
_DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex"
|
||||
_RUNNER_CACHE_KEY = "ModelRunnerTypeCache"
|
||||
|
||||
|
||||
def _display_model_name(bundle) -> str:
|
||||
return bundle.internalName if getattr(bundle, "internalName", "") else bundle.displayName
|
||||
|
||||
|
||||
def _big_options() -> list[tuple[str, str]]:
|
||||
try:
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import big_models
|
||||
return big_models(ui_state.params)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _big_label(key: str) -> str:
|
||||
for name, display in _big_options():
|
||||
if name == key:
|
||||
return display
|
||||
return key or "lebrowski"
|
||||
|
||||
|
||||
def _refresh_big_catalog() -> None:
|
||||
def worker():
|
||||
try:
|
||||
from iqpilot.selfdrive.iqmodeld.emac_model_meta import refresh_catalog
|
||||
refresh_catalog(ui_state.params)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
class _ModelSelectPanel(NavScroller):
|
||||
"""A throwaway scroller panel (folder list or bundle list) pushed onto the nav stack."""
|
||||
def __init__(self, items):
|
||||
super().__init__()
|
||||
self._scroller.add_widgets(items)
|
||||
|
||||
|
||||
class _ModelButton(BigButton):
|
||||
"""A bundle in the model list: single tap selects (download), double tap toggles favorite.
|
||||
|
||||
A golden star is drawn in the corner when the model is favorited.
|
||||
"""
|
||||
THRESHOLD = 0.4
|
||||
_STAR_GOLD = rl.Color(0xFF, 0xC1, 0x07, 255)
|
||||
|
||||
def __init__(self, bundle, on_select, on_favorite, is_favorite):
|
||||
super().__init__(bundle.displayName)
|
||||
self._bundle = bundle
|
||||
self._on_select = on_select
|
||||
self._on_favorite = on_favorite
|
||||
self._is_favorite = is_favorite
|
||||
self._pending_t = 0.0
|
||||
self._pending_pos = None
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
now = time.monotonic()
|
||||
if self._pending_pos is not None and now - self._pending_t < self.THRESHOLD:
|
||||
self._pending_pos = None
|
||||
self._pending_t = 0.0
|
||||
self._is_favorite = self._on_favorite(self._bundle)
|
||||
return
|
||||
self._pending_t = now
|
||||
self._pending_pos = mouse_pos
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self._pending_pos is not None and time.monotonic() - self._pending_t >= self.THRESHOLD:
|
||||
self._pending_pos = None
|
||||
self._on_select(self._bundle)
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
if self._is_favorite:
|
||||
cx = self._rect.x + self._rect.width - 46
|
||||
cy = self._rect.y + 46
|
||||
draw_star(cx, cy, 24, True, self._STAR_GOLD)
|
||||
|
||||
|
||||
class ModelsLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._last_cache_t = 0.0
|
||||
self._download_status = None
|
||||
self._prev_download_status = None
|
||||
self._clear_icon = gui_app.texture("icons_mici/settings/developer_icon.png", 56, 56)
|
||||
self._redownload_icon = gui_app.texture("icons_mici/settings/device/update.png", 56, 56, keep_aspect_ratio=True)
|
||||
self._reset_icon = gui_app.texture("icons_mici/wheel.png", 56, 56)
|
||||
|
||||
self._current = BigButton(tr("active model"))
|
||||
self._current.set_click_callback(self._show_folders)
|
||||
|
||||
self._big = BigButton(tr("big model"))
|
||||
self._big.set_click_callback(self._show_big_models)
|
||||
|
||||
self._cancel = BigButton(tr("stop download"))
|
||||
self._cancel.set_click_callback(self._cancel_model_request)
|
||||
self._cancel.set_visible(self._is_downloading)
|
||||
|
||||
self._redownload = BigButton(tr("redownload model"))
|
||||
self._redownload.set_click_callback(self._confirm_redownload_model)
|
||||
self._redownload.set_enabled(self._can_redownload)
|
||||
|
||||
self._refresh = BigButton(tr("reload model list"))
|
||||
self._refresh.set_click_callback(self._reload_model_lists)
|
||||
|
||||
self._supercombo = GreyBigButton(tr("combined model"))
|
||||
self._supercombo.set_visible(False)
|
||||
self._vision = GreyBigButton(tr("vision weights"))
|
||||
self._vision.set_visible(False)
|
||||
self._policy = GreyBigButton(tr("policy weights"))
|
||||
self._policy.set_visible(False)
|
||||
|
||||
self._clear = BigButton(tr("purge model cache"))
|
||||
self._clear.set_click_callback(self._confirm_clear_cache)
|
||||
self._clear.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
self._steer_delay = BigParamControl(tr("self-tuning steer delay"), "IQLiveSteerDelay")
|
||||
self._sw_delay = MappedParamToggle(tr("manual delay offset"), "IQSoftwareSteerDelay", _DELAY_OPTIONS, _DELAY_VALUES)
|
||||
self._sw_delay.set_visible(lambda: not self._steer_delay._checked)
|
||||
|
||||
self._lane_turn = BigParamControl(tr("low-speed turn planning"), "IQLaneTurnDesire")
|
||||
self._lane_speed = MappedParamToggle(tr("lane turn speed"), "IQLaneTurnValue", [tr("slow"), tr("normal"), tr("fast")], _LANE_TURN_VALUES)
|
||||
self._lane_speed.set_visible(lambda: self._lane_turn._checked)
|
||||
|
||||
self._main_items = [self._current, self._big, self._cancel, self._supercombo, self._vision, self._policy, self._redownload, self._refresh, self._clear,
|
||||
self._steer_delay, self._sw_delay, self._lane_turn, self._lane_speed]
|
||||
self._scroller.add_widgets(self._main_items)
|
||||
|
||||
@property
|
||||
def model_manager(self):
|
||||
return ui_state.sm["iqModelManager"]
|
||||
|
||||
@staticmethod
|
||||
def _has_download_request() -> bool:
|
||||
try:
|
||||
return int(ui_state.params.get(_DOWNLOAD_INDEX_KEY)) >= 0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _has_active_bundle_param() -> bool:
|
||||
return bool(ui_state.params.get(_ACTIVE_BUNDLE_KEY))
|
||||
|
||||
def _has_model_request(self) -> bool:
|
||||
return self._has_download_request()
|
||||
|
||||
def _is_downloading(self) -> bool:
|
||||
try:
|
||||
return bool(self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == _DL.downloading)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _calculate_cache_size() -> float:
|
||||
if os.path.exists(CUSTOM_MODEL_PATH):
|
||||
return sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, f)) for f in os.listdir(CUSTOM_MODEL_PATH)) / (1024 ** 2)
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _bundle_index(bundle) -> int | None:
|
||||
try:
|
||||
return int(getattr(bundle, "index", -1))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _bundle_matches(cls, left, right) -> bool:
|
||||
if left is None or right is None:
|
||||
return False
|
||||
|
||||
left_index = cls._bundle_index(left)
|
||||
right_index = cls._bundle_index(right)
|
||||
if left_index is not None and right_index is not None and left_index == right_index:
|
||||
return True
|
||||
|
||||
for attr in ("ref", "internalName", "displayName"):
|
||||
left_value = getattr(left, attr, None)
|
||||
if left_value and left_value == getattr(right, attr, None):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _safe_model_path(filename: str) -> str | None:
|
||||
if not filename or os.path.basename(filename) != filename:
|
||||
return None
|
||||
|
||||
root = os.path.realpath(CUSTOM_MODEL_PATH)
|
||||
path = os.path.realpath(os.path.join(root, filename))
|
||||
try:
|
||||
if os.path.commonpath([root, path]) != root:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
|
||||
def _remove_bundle_files(self, bundle) -> None:
|
||||
for model in getattr(bundle, "models", []) or []:
|
||||
for artifact in (getattr(model, "metadata", None), getattr(model, "artifact", None)):
|
||||
filename = getattr(artifact, "fileName", "") if artifact is not None else ""
|
||||
path = self._safe_model_path(filename)
|
||||
if path is None:
|
||||
continue
|
||||
for candidate in (path, f"{path}.download"):
|
||||
try:
|
||||
if os.path.isfile(candidate):
|
||||
os.remove(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _group_folders(self, bundles):
|
||||
folders: dict = {}
|
||||
for bundle in bundles:
|
||||
folder = next((ov.value for ov in bundle.overrides if ov.key == "folder"), "")
|
||||
folders.setdefault(folder, []).append(bundle)
|
||||
return folders
|
||||
|
||||
@staticmethod
|
||||
def _read_favorites() -> set:
|
||||
favs = ui_state.params.get("IQModelFavorites")
|
||||
return set(favs.split(';')) if favs else set()
|
||||
|
||||
def _toggle_favorite(self, bundle) -> bool:
|
||||
favs = self._read_favorites()
|
||||
if bundle.ref in favs:
|
||||
favs.discard(bundle.ref)
|
||||
else:
|
||||
favs.add(bundle.ref)
|
||||
ui_state.params.put("IQModelFavorites", ';'.join(sorted(favs)))
|
||||
return bundle.ref in favs
|
||||
|
||||
def _confirm_clear_cache(self):
|
||||
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nclear cache"), self._clear_icon,
|
||||
lambda: ui_state.params.put_bool("ModelManager_ClearCache", True),
|
||||
red=True))
|
||||
|
||||
def _redownload_target_bundle(self):
|
||||
try:
|
||||
selected = self.model_manager.selectedBundle
|
||||
if selected and selected.status == _DL.failed:
|
||||
return selected
|
||||
active = self.model_manager.activeBundle
|
||||
if self._has_active_bundle_param() and active and active.ref:
|
||||
return active
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _redownload_target_index(self) -> int | None:
|
||||
target = self._redownload_target_bundle()
|
||||
if not target:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(target.index)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
for bundle in self.model_manager.availableBundles:
|
||||
if bundle.ref and bundle.ref == target.ref:
|
||||
return int(bundle.index)
|
||||
if bundle.internalName and bundle.internalName == target.internalName:
|
||||
return int(bundle.index)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _can_redownload(self) -> bool:
|
||||
return bool(ui_state.is_offroad() and not self._is_downloading() and not self._has_model_request() and self._redownload_target_index() is not None)
|
||||
|
||||
def _cancel_model_request(self):
|
||||
ui_state.params.remove(_DOWNLOAD_INDEX_KEY)
|
||||
|
||||
def _confirm_redownload_model(self):
|
||||
index = self._redownload_target_index()
|
||||
if index is None:
|
||||
return
|
||||
|
||||
def _redownload():
|
||||
target = self._redownload_target_bundle()
|
||||
if target is not None:
|
||||
self._remove_bundle_files(target)
|
||||
if self._bundle_matches(getattr(self.model_manager, "activeBundle", None), target):
|
||||
ui_state.params.remove(_ACTIVE_BUNDLE_KEY)
|
||||
ui_state.params.remove(_RUNNER_CACHE_KEY)
|
||||
ui_state.params.put(_DOWNLOAD_INDEX_KEY, index)
|
||||
self._redownload.set_value(tr("queued"))
|
||||
|
||||
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nredownload"), self._redownload_icon, _redownload, red=True))
|
||||
|
||||
def _show_folders(self):
|
||||
bundles = list(self.model_manager.availableBundles)
|
||||
favorites = self._read_favorites()
|
||||
btns = []
|
||||
|
||||
default_btn = BigButton(tr("Default (CD210)"))
|
||||
default_btn.set_click_callback(self._select_default)
|
||||
btns.append(default_btn)
|
||||
|
||||
if favorites and (fav_bundles := [b for b in bundles if b.ref in favorites]):
|
||||
fav_btn = BigButton(tr("Favorites"), str(len(fav_bundles)))
|
||||
fav_btn.set_click_callback(lambda fb=fav_bundles: self._show_bundles(fb))
|
||||
btns.append(fav_btn)
|
||||
|
||||
folders = self._group_folders(bundles)
|
||||
for folder in sorted(folders, key=lambda f: max((b.index for b in folders[f]), default=-1), reverse=True):
|
||||
name = folder if folder else "Other"
|
||||
folder_bundles = sorted(folders[folder], key=lambda b: b.index, reverse=True)
|
||||
if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)):
|
||||
name += f" ({m.group(1)})"
|
||||
btn = BigButton(name)
|
||||
btn.set_click_callback(lambda fb=folder_bundles: self._show_bundles(fb))
|
||||
btns.append(btn)
|
||||
|
||||
gui_app.push_widget(_ModelSelectPanel(btns))
|
||||
|
||||
def _show_bundles(self, bundles):
|
||||
favorites = self._read_favorites()
|
||||
btns = [_ModelButton(b, self._select_model, self._toggle_favorite, b.ref in favorites) for b in bundles]
|
||||
gui_app.push_widget(_ModelSelectPanel(btns))
|
||||
|
||||
def _reload_model_lists(self):
|
||||
ui_state.params.put("ModelManager_LastSyncTime", 0)
|
||||
_refresh_big_catalog()
|
||||
|
||||
def _show_big_models(self):
|
||||
_refresh_big_catalog()
|
||||
options = _big_options()
|
||||
off = BigButton(tr("Off"))
|
||||
off.set_click_callback(lambda: self._select_big(None))
|
||||
btns = [off]
|
||||
for key, display in options:
|
||||
btn = BigButton(display)
|
||||
btn.set_click_callback(lambda k=key: self._select_big(k))
|
||||
btns.append(btn)
|
||||
gui_app.push_widget(_ModelSelectPanel(btns))
|
||||
|
||||
def _select_big(self, key):
|
||||
if key is None:
|
||||
ui_state.params.put_bool("IQEmacEnabled", False)
|
||||
else:
|
||||
ui_state.params.put("IQEmacModel", key)
|
||||
ui_state.params.put_bool("IQEmacEnabled", True)
|
||||
gui_app.pop_widgets_to(self)
|
||||
|
||||
def _big_setup_progress(self) -> float | None:
|
||||
p = ui_state.params
|
||||
if p.get_bool("IQEmacEnabled"):
|
||||
raw = p.get("MacModelDownloadProgress")
|
||||
loading = not p.get_bool("MacModelReady")
|
||||
else:
|
||||
raw = p.get("UsbGpuSetupProgress")
|
||||
loading = p.get_bool("UsbGpuLoading") and not p.get_bool("UsbGpuCompiled")
|
||||
if not loading:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, min(1.0, float(raw)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _big_model_value(self) -> str:
|
||||
p = ui_state.params
|
||||
dock = bool(getattr(ui_state.sm["deviceState"], "egpuDockPresent", False))
|
||||
if not p.get_bool("IQEmacEnabled") and not dock:
|
||||
return tr("Off")
|
||||
key = p.get("IQEmacModel")
|
||||
key = key.decode() if isinstance(key, bytes) else (key or "")
|
||||
label = _big_label(key)
|
||||
progress = self._big_setup_progress()
|
||||
if progress is not None and progress < 1.0:
|
||||
return f"{label} {int(progress * 100)}%"
|
||||
return label
|
||||
|
||||
def _generation_changed(self, bundle) -> bool:
|
||||
try:
|
||||
active = self.model_manager.activeBundle
|
||||
return bool(active and active.ref and bundle.generation != active.generation)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _select_model(self, bundle):
|
||||
ui_state.params.put(_DOWNLOAD_INDEX_KEY, bundle.index)
|
||||
cb = self._show_reset_calibration_prompt if self._generation_changed(bundle) else lambda: None
|
||||
gui_app.pop_widgets_to(self, callback=cb)
|
||||
|
||||
def _select_default(self):
|
||||
try:
|
||||
active = self.model_manager.activeBundle
|
||||
had_custom_model = bool(active and active.ref and not is_default_bundle(active))
|
||||
except Exception:
|
||||
had_custom_model = False
|
||||
|
||||
select_default_model(ui_state.params)
|
||||
gui_app.pop_widgets_to(self, callback=self._show_reset_calibration_prompt if had_custom_model else (lambda: None))
|
||||
|
||||
def _show_reset_calibration_prompt(self):
|
||||
def _reset():
|
||||
ui_state.params.remove("CalibrationParams")
|
||||
ui_state.params.remove("LiveTorqueParameters")
|
||||
gui_app.push_widget(BigConfirmationDialog(tr("slide to\nreset calibration"), self._reset_icon, _reset))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
self._handle_bundle_download_progress()
|
||||
self._current.set_value(self._current_model_value())
|
||||
self._current.set_enabled(ui_state.is_offroad())
|
||||
self._big.set_value(self._big_model_value())
|
||||
target = self._redownload_target_bundle()
|
||||
self._redownload.set_value(_display_model_name(target) if target else "")
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_cache_t > 1.0:
|
||||
self._last_cache_t = now
|
||||
self._clear.set_value(f"{self._calculate_cache_size():.1f} MB")
|
||||
|
||||
self._update_steer_delay_subtext()
|
||||
|
||||
def _progress_target_bundle(self):
|
||||
try:
|
||||
selected = self.model_manager.selectedBundle
|
||||
active = self.model_manager.activeBundle
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if selected and (selected.status == _DL.downloading or selected.status == _DL.failed):
|
||||
return selected
|
||||
return active if self._has_active_bundle_param() else None
|
||||
|
||||
def _handle_bundle_download_progress(self):
|
||||
labels = {
|
||||
custom.IQModelManager.Model.Type.supercombo: self._supercombo,
|
||||
custom.IQModelManager.Model.Type.vision: self._vision,
|
||||
custom.IQModelManager.Model.Type.policy: self._policy,
|
||||
}
|
||||
for label in labels.values():
|
||||
label.set_visible(False)
|
||||
label.set_value("")
|
||||
|
||||
self._cancel.set_visible(False)
|
||||
|
||||
bundle = self._progress_target_bundle()
|
||||
if not bundle:
|
||||
self._download_status = None
|
||||
self._prev_download_status = None
|
||||
return
|
||||
|
||||
self._download_status = bundle.status
|
||||
status_changed = self._download_status != self._prev_download_status
|
||||
self._prev_download_status = self._download_status
|
||||
|
||||
self._cancel.set_visible(bool(getattr(self.model_manager, "selectedBundle", None)) and self._has_download_request())
|
||||
|
||||
if self._download_status not in (_DL.downloading, _DL.failed):
|
||||
return
|
||||
|
||||
if self._download_status == _DL.downloading:
|
||||
try:
|
||||
from iqpilot.selfdrive.ui.ui_state import device
|
||||
device._reset_interactive_timeout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for model in bundle.models:
|
||||
label = labels.get(getattr(model.type, "raw", model.type))
|
||||
if label is None:
|
||||
continue
|
||||
label.set_visible(True)
|
||||
label.set_value(self._download_label_text(bundle, model, status_changed))
|
||||
|
||||
def _download_label_text(self, bundle, model, status_changed: bool) -> str:
|
||||
p = model.artifact.downloadProgress
|
||||
if p.status == _DL.downloading:
|
||||
return f"{int(p.progress)}% downloading {_display_model_name(bundle)}"
|
||||
if p.status in (_DL.downloaded, _DL.cached):
|
||||
if self._download_status == _DL.downloading:
|
||||
return f"{_display_model_name(bundle)} ready"
|
||||
return f"{_display_model_name(bundle)} {'downloaded' if status_changed else 'ready'}"
|
||||
if p.status == _DL.failed:
|
||||
return f"download failed {_display_model_name(bundle)}"
|
||||
return f"pending {_display_model_name(bundle)}"
|
||||
|
||||
def _current_model_value(self) -> str:
|
||||
bundle = self._progress_target_bundle()
|
||||
if not bundle:
|
||||
return self._active_model_name()
|
||||
|
||||
if self._download_status == _DL.downloading:
|
||||
return self._download_progress_text(bundle)
|
||||
if self._download_status == _DL.failed:
|
||||
return f"failed: {_display_model_name(bundle)}"
|
||||
return self._active_model_name()
|
||||
|
||||
def _update_steer_delay_subtext(self):
|
||||
if self._steer_delay._checked:
|
||||
try:
|
||||
self._steer_delay.set_value(f"measured {ui_state.sm['lateralDelay'].lateralDelay:.3f} s")
|
||||
except Exception:
|
||||
self._steer_delay.set_value("")
|
||||
return
|
||||
try:
|
||||
sw = float(ui_state.params.get("IQSoftwareSteerDelay", return_default=True))
|
||||
except (TypeError, ValueError):
|
||||
sw = 0.2
|
||||
if ui_state.CP is not None:
|
||||
self._steer_delay.set_value(f"total {ui_state.CP.steerActuatorDelay + sw:.2f} s")
|
||||
else:
|
||||
self._steer_delay.set_value(f"+{sw:.2f} s offset")
|
||||
|
||||
def _active_model_name(self) -> str:
|
||||
if not self._has_active_bundle_param():
|
||||
return "Default (CD210)"
|
||||
|
||||
try:
|
||||
active = self.model_manager.activeBundle
|
||||
if is_default_bundle(active):
|
||||
return active.displayName or "Default (CD210)"
|
||||
if active and active.ref:
|
||||
return _display_model_name(active)
|
||||
except Exception:
|
||||
pass
|
||||
return "Default (CD210)"
|
||||
|
||||
def _download_progress_text(self, bundle=None) -> str:
|
||||
bundle = bundle or getattr(self.model_manager, "selectedBundle", None)
|
||||
if not bundle:
|
||||
return "downloading..."
|
||||
try:
|
||||
parts = []
|
||||
for model in bundle.models:
|
||||
p = model.artifact.downloadProgress
|
||||
if p.status == _DL.downloading:
|
||||
parts.append(f"{int(p.progress)}%")
|
||||
elif p.status in (_DL.downloaded, _DL.cached):
|
||||
parts.append("ready")
|
||||
elif p.status == _DL.failed:
|
||||
parts.append("failed")
|
||||
return f"{_display_model_name(bundle)} {' '.join(parts)}".strip() or "downloading..."
|
||||
except Exception:
|
||||
return "downloading..."
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for w in (self._steer_delay, self._sw_delay, self._lane_turn, self._lane_speed):
|
||||
w.refresh()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
189
iqpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
Normal file
189
iqpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.system.hardware.base import Profile
|
||||
from iqpilot.system.hardware.tici.esim_manager import EsimManager, EsimUiState, get_esim_manager
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets import DialogResult, NavWidget
|
||||
from iqpilot.system.ui.widgets.esim_scanner import EsimQrScannerDialog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.button import NeonBigButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigMultiOptionDialog, BigConfirmationDialogV2
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class EsimUIMici(NavWidget):
|
||||
def __init__(self, back_callback: Callable):
|
||||
super().__init__()
|
||||
self._manager: EsimManager = get_esim_manager()
|
||||
self._state = EsimUiState()
|
||||
self._callback_registered = False
|
||||
self._scroller = Scroller([], snap_items=False)
|
||||
self._rebuild_scroller()
|
||||
self.set_back_callback(back_callback)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
if not self._callback_registered:
|
||||
self._manager.add_callback(self._on_state_update)
|
||||
self._callback_registered = True
|
||||
self._manager.refresh_profiles()
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
if self._callback_registered:
|
||||
self._manager.remove_callback(self._on_state_update)
|
||||
self._callback_registered = False
|
||||
|
||||
def _is_busy(self) -> bool:
|
||||
return self._state.busy
|
||||
|
||||
def _status_text(self) -> str:
|
||||
if self._state.message:
|
||||
return self._state.message
|
||||
return self._state.state.value
|
||||
|
||||
def _on_state_update(self, state: EsimUiState):
|
||||
self._state = state
|
||||
self._rebuild_scroller()
|
||||
|
||||
def _show_choice_dialog(self, title: str, options: list[str], callback: Callable[[str], None]) -> None:
|
||||
if not options:
|
||||
return
|
||||
dlg = BigMultiOptionDialog(
|
||||
options,
|
||||
options[0],
|
||||
right_btn="check",
|
||||
right_btn_callback=lambda: callback(dlg.get_selected_option()),
|
||||
)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _rebuild_scroller(self):
|
||||
widgets = []
|
||||
|
||||
status_btn = NeonBigButton(tr("status"), chips=[self._status_text()])
|
||||
status_btn.set_enabled(False)
|
||||
widgets.append(status_btn)
|
||||
|
||||
refresh_btn = NeonBigButton(tr("refresh profiles"))
|
||||
refresh_btn.set_enabled(lambda: not self._is_busy())
|
||||
refresh_btn.set_click_callback(lambda: self._manager.refresh_profiles())
|
||||
widgets.append(refresh_btn)
|
||||
|
||||
add_btn = NeonBigButton(tr("add profile"), chips=[tr("scan qr / enter code")])
|
||||
add_btn.set_enabled(lambda: not self._is_busy())
|
||||
add_btn.set_click_callback(self._on_add_profile)
|
||||
widgets.append(add_btn)
|
||||
|
||||
profiles = self._state.profiles or []
|
||||
for p in profiles:
|
||||
widgets.append(self._make_profile_button(p))
|
||||
|
||||
self._scroller = Scroller(widgets, snap_items=False)
|
||||
|
||||
def _make_profile_button(self, profile: Profile) -> NeonBigButton:
|
||||
title = profile.nickname if profile.nickname else profile.iccid
|
||||
provider = profile.provider or tr("provider unknown")
|
||||
value = f"{provider}{' • ' + tr('active') if profile.enabled else ''}"
|
||||
btn = NeonBigButton(title, chips=[value])
|
||||
btn.set_enabled(lambda: not self._is_busy())
|
||||
btn.set_click_callback(lambda profile=profile: self._on_profile_selected(profile))
|
||||
return btn
|
||||
|
||||
def _on_add_profile(self):
|
||||
options = [tr("scan qr"), tr("enter code")]
|
||||
|
||||
def _selected(option: str):
|
||||
if option == options[0]:
|
||||
self._scan_qr()
|
||||
elif option == options[1]:
|
||||
self._manual_entry()
|
||||
|
||||
self._show_choice_dialog(tr("add esim profile"), options, _selected)
|
||||
|
||||
def _scan_qr(self):
|
||||
scanner = EsimQrScannerDialog()
|
||||
self._manager.set_scanning_state(True)
|
||||
|
||||
def _done(result: int):
|
||||
self._manager.set_scanning_state(False)
|
||||
if result != DialogResult.CONFIRM or not scanner.code:
|
||||
return
|
||||
self._prompt_nickname_and_add(scanner.code)
|
||||
|
||||
gui_app.set_modal_overlay(scanner, _done)
|
||||
|
||||
def _manual_entry(self):
|
||||
dlg = BigInputDialog(tr("enter LPA activation code..."), "", minimum_length=1,
|
||||
confirm_callback=lambda code: self._prompt_nickname_and_add(code.strip()) if code.strip() else None)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _prompt_nickname_and_add(self, code: str):
|
||||
dlg = BigInputDialog(tr("optional nickname..."), "", minimum_length=0,
|
||||
confirm_callback=lambda nickname: self._manager.add_profile(code, nickname.strip() or None))
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _on_profile_selected(self, profile: Profile):
|
||||
options = []
|
||||
if not profile.enabled:
|
||||
options.append(tr("activate"))
|
||||
options.append(tr("rename"))
|
||||
if self._manager.is_comma_profile(profile.iccid):
|
||||
options.append(tr("remove comma psim"))
|
||||
elif not profile.enabled:
|
||||
options.append(tr("delete"))
|
||||
|
||||
def _selected(option: str):
|
||||
if option == tr("activate"):
|
||||
self._manager.switch_profile(profile.iccid)
|
||||
elif option == tr("rename"):
|
||||
self._rename_profile(profile)
|
||||
elif option == tr("remove comma psim"):
|
||||
self._remove_comma_profile()
|
||||
elif option == tr("delete"):
|
||||
self._manager.delete_profile(profile.iccid)
|
||||
|
||||
self._show_choice_dialog(tr("profile actions"), options, _selected)
|
||||
|
||||
def _rename_profile(self, profile: Profile):
|
||||
dlg = BigInputDialog(tr("rename profile..."), profile.nickname or "", minimum_length=1,
|
||||
confirm_callback=lambda nickname: self._manager.rename_profile(profile.iccid, nickname.strip()) if nickname.strip() else None)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _remove_comma_profile(self):
|
||||
dlg = BigDialog(
|
||||
tr("Warning"),
|
||||
tr("This will permanently wipe the Comma pSIM profile from the SIM."),
|
||||
right_btn="check",
|
||||
right_btn_callback=self._remove_comma_profile_final_warning,
|
||||
)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _remove_comma_profile_final_warning(self):
|
||||
dlg = BigDialog(
|
||||
tr("Final Warning"),
|
||||
tr("You must use your own eSIM profile after this. You cannot use Comma Prime again unless you buy a new SIM from comma."),
|
||||
right_btn="check",
|
||||
right_btn_callback=self._confirm_remove_comma_profile,
|
||||
)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _confirm_remove_comma_profile(self):
|
||||
dlg = BigConfirmationDialogV2(
|
||||
tr("slide to remove\ncomma psim"),
|
||||
"icons_mici/settings/network/new/trash.png",
|
||||
red=True,
|
||||
confirm_callback=self._manager.bootstrap,
|
||||
)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if not self._manager.is_supported():
|
||||
from iqpilot.system.ui.widgets.label import gui_label
|
||||
gui_label(rect, tr("Insert the original comma SIM card that came with the device to use eSIM"), 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
return
|
||||
self._scroller.render(rect)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller, draw_scroller_edge_fades, draw_scroller_page_slider
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUIMici
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl, BigMultiToggle
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigInputDialog
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType
|
||||
from iqpilot.system.hardware.tici.esim_manager import get_esim_manager
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class NetworkPanelType(IntEnum):
|
||||
NONE = 0
|
||||
WIFI = 1
|
||||
ESIM = 2
|
||||
|
||||
|
||||
class NetworkLayoutMici(NavWidget):
|
||||
CALLBACK_INTERVAL_FRAMES = 3
|
||||
|
||||
def __init__(self, back_callback: Callable):
|
||||
super().__init__()
|
||||
|
||||
self._current_panel = NetworkPanelType.WIFI
|
||||
self._callback_frame = 0
|
||||
self._esim_profile_count: str | None = None
|
||||
self._esim_profile_frame = 0
|
||||
|
||||
self._wifi_manager = WifiManager()
|
||||
self._wifi_manager.set_active(False)
|
||||
self._wifi_ui = WifiUIMici(self._wifi_manager)
|
||||
self._esim_ui = EsimUIMici(back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE))
|
||||
self._esim_manager = get_esim_manager()
|
||||
|
||||
self._wifi_manager.add_callbacks(
|
||||
networks_updated=self._on_network_updated,
|
||||
)
|
||||
|
||||
# ******** Tethering ********
|
||||
def tethering_toggle_callback(checked: bool):
|
||||
self._tethering_toggle_btn.set_enabled(False)
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
self._wifi_manager.set_tethering_active(checked)
|
||||
|
||||
self._tethering_checked = False
|
||||
self._tethering_toggle_btn = BigButton(tr("tethering"), tr("disabled"))
|
||||
self._tethering_toggle_btn.set_click_callback(lambda: self._on_tethering_clicked(tethering_toggle_callback))
|
||||
|
||||
def tethering_password_callback(password: str):
|
||||
if password:
|
||||
self._wifi_manager.set_tethering_password(password)
|
||||
|
||||
def tethering_password_clicked():
|
||||
tethering_password = self._wifi_manager.tethering_password
|
||||
dlg = BigInputDialog(tr("enter password..."), tethering_password, minimum_length=8,
|
||||
confirm_callback=tethering_password_callback)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
self._tethering_password_btn = BigButton(tr("tethering password"))
|
||||
self._tethering_password_btn.set_click_callback(tethering_password_clicked)
|
||||
|
||||
# ******** IP Address ********
|
||||
self._ip_address_btn = BigButton(tr("IP Address"), tr("Not connected"))
|
||||
|
||||
# ******** Network Metered ********
|
||||
self._metered_options = [tr("default"), tr("metered"), tr("unmetered")]
|
||||
|
||||
def network_metered_callback(value: str):
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
metered = {
|
||||
self._metered_options[0]: MeteredType.UNKNOWN,
|
||||
self._metered_options[1]: MeteredType.YES,
|
||||
self._metered_options[2]: MeteredType.NO
|
||||
}.get(value, MeteredType.UNKNOWN)
|
||||
self._wifi_manager.set_current_network_metered(metered)
|
||||
|
||||
# TODO: signal for current network metered type when changing networks, this is wrong until you press it once
|
||||
# TODO: disable when not connected
|
||||
self._network_metered_btn = BigMultiToggle(tr("network usage"), self._metered_options, select_callback=network_metered_callback)
|
||||
self._network_metered_btn.set_enabled(False)
|
||||
|
||||
wifi_button = BigButton(tr("wi-fi"))
|
||||
wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
|
||||
self._esim_button = BigButton(tr("eSIM"), tr("manage profiles"))
|
||||
self._esim_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.ESIM))
|
||||
self._esim_button.set_visible(lambda: self._esim_manager.is_supported())
|
||||
|
||||
# ******** Advanced settings ********
|
||||
# ******** Roaming toggle ********
|
||||
self._roaming_btn = BigParamControl(tr("enable roaming"), "GsmRoaming", toggle_callback=self._toggle_roaming)
|
||||
|
||||
# ******** APN settings ********
|
||||
self._apn_btn = BigButton(tr("apn settings"))
|
||||
self._apn_btn.set_click_callback(self._edit_apn)
|
||||
|
||||
# ******** Cellular metered toggle ********
|
||||
self._cellular_metered_btn = BigParamControl(tr("cellular metered"), "GsmMetered", toggle_callback=self._toggle_cellular_metered)
|
||||
|
||||
# Main scroller ----------------------------------
|
||||
self._scroller = Scroller([
|
||||
wifi_button,
|
||||
self._esim_button,
|
||||
self._network_metered_btn,
|
||||
self._tethering_toggle_btn,
|
||||
self._tethering_password_btn,
|
||||
# /* Advanced settings
|
||||
self._roaming_btn,
|
||||
self._apn_btn,
|
||||
self._cellular_metered_btn,
|
||||
# */
|
||||
self._ip_address_btn,
|
||||
], snap_items=False)
|
||||
|
||||
# Set initial config
|
||||
roaming_enabled = ui_state.params.get_bool("GsmRoaming")
|
||||
metered = ui_state.params.get_bool("GsmMetered")
|
||||
self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered)
|
||||
|
||||
# Set up back navigation
|
||||
self.set_back_callback(back_callback)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
# Only allow swipe-to-dismiss back to settings when no sub-panel (eSIM) is open.
|
||||
return self._current_panel == NetworkPanelType.NONE
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# konn3kt has no managed cellular SIM, so always expose the GSM/APN settings.
|
||||
show_cell_settings = True
|
||||
self._wifi_manager.set_ipv4_forward(show_cell_settings)
|
||||
self._roaming_btn.set_visible(show_cell_settings)
|
||||
self._apn_btn.set_visible(show_cell_settings)
|
||||
self._cellular_metered_btn.set_visible(show_cell_settings)
|
||||
|
||||
self._esim_profile_frame += 1
|
||||
if self._esim_profile_frame % 30 == 0:
|
||||
esim_profiles = (self._esim_manager.get_state().profiles or []) if self._esim_manager.is_supported() else []
|
||||
count = f"{len(esim_profiles)} profiles"
|
||||
if count != self._esim_profile_count:
|
||||
self._esim_profile_count = count
|
||||
self._esim_button.set_value(count)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._current_panel = NetworkPanelType.NONE
|
||||
self._esim_profile_frame = 0
|
||||
self._esim_profile_count = None
|
||||
self._roaming_btn.refresh()
|
||||
self._cellular_metered_btn.refresh()
|
||||
self._scroller.show_event()
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
if self._current_panel == NetworkPanelType.ESIM:
|
||||
self._esim_ui.hide_event()
|
||||
|
||||
def _toggle_roaming(self, checked: bool):
|
||||
self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered"))
|
||||
|
||||
def _edit_apn(self):
|
||||
def update_apn(apn: str):
|
||||
apn = apn.strip()
|
||||
if apn == "":
|
||||
ui_state.params.remove("GsmApn")
|
||||
else:
|
||||
ui_state.params.put("GsmApn", apn)
|
||||
|
||||
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered"))
|
||||
|
||||
current_apn = ui_state.params.get("GsmApn") or ""
|
||||
dlg = BigInputDialog(tr("enter APN"), current_apn, minimum_length=0, confirm_callback=update_apn)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _toggle_cellular_metered(self, checked: bool):
|
||||
self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked)
|
||||
|
||||
def _on_tethering_clicked(self, toggle_callback):
|
||||
self._tethering_checked = not self._tethering_checked
|
||||
self._tethering_toggle_btn.set_value(tr("enabled") if self._tethering_checked else tr("disabled"))
|
||||
toggle_callback(self._tethering_checked)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
# Update tethering state
|
||||
tethering_active = self._wifi_manager.is_tethering_active()
|
||||
self._tethering_toggle_btn.set_enabled(True)
|
||||
self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
|
||||
self._tethering_checked = tethering_active
|
||||
self._tethering_toggle_btn.set_value(tr("enabled") if tethering_active else tr("disabled"))
|
||||
|
||||
# Update IP address
|
||||
self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or tr("Not connected"))
|
||||
|
||||
# Update network metered
|
||||
self._network_metered_btn.set_value(
|
||||
{
|
||||
MeteredType.UNKNOWN: self._metered_options[0],
|
||||
MeteredType.YES: self._metered_options[1],
|
||||
MeteredType.NO: self._metered_options[2]
|
||||
}.get(self._wifi_manager.current_network_metered, self._metered_options[0]))
|
||||
|
||||
def _switch_to_panel(self, panel_type: NetworkPanelType):
|
||||
if panel_type == NetworkPanelType.ESIM:
|
||||
if not self._esim_manager.is_supported():
|
||||
return
|
||||
self._esim_ui.show_event()
|
||||
elif self._current_panel == NetworkPanelType.ESIM:
|
||||
self._esim_ui.hide_event()
|
||||
|
||||
self._current_panel = panel_type
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rec(rect, rl.BLACK)
|
||||
if self._callback_frame % self.CALLBACK_INTERVAL_FRAMES == 0:
|
||||
self._wifi_manager.process_callbacks()
|
||||
self._callback_frame += 1
|
||||
|
||||
if self._current_panel == NetworkPanelType.ESIM:
|
||||
self._esim_ui.render(rect)
|
||||
else:
|
||||
self._scroller.render(rect)
|
||||
draw_scroller_edge_fades(rect)
|
||||
draw_scroller_page_slider(self._scroller, rect)
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Small standalone check for the mici WiFi menu sort order.
|
||||
|
||||
Run with:
|
||||
uv run python selfdrive/ui/mici/layouts/settings/network/test_wifi_sort.py
|
||||
"""
|
||||
|
||||
from iqpilot.system.ui.lib.wifi_manager import Network, SecurityType, wifi_network_sort_key
|
||||
|
||||
|
||||
def _network(ssid: str, strength: int, connected: bool = False) -> Network:
|
||||
return Network(ssid, strength, connected, SecurityType.WPA2, True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cafe = _network("cafe", 35)
|
||||
home = _network("home", 75)
|
||||
connected = _network("connected", 20, connected=True)
|
||||
missing_saved = _network("missing-saved", 90)
|
||||
zero_strength = _network("zero-strength", 0)
|
||||
|
||||
entries = [
|
||||
(cafe, False),
|
||||
(missing_saved, True),
|
||||
(zero_strength, False),
|
||||
(connected, False),
|
||||
(home, False),
|
||||
]
|
||||
|
||||
ordered = [network.ssid for network, missing in sorted(entries, key=lambda entry: wifi_network_sort_key(*entry))]
|
||||
assert ordered == ["connected", "home", "cafe", "missing-saved", "zero-strength"], ordered
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
443
iqpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py
Normal file
443
iqpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
import math
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigInputDialog, BigConfirmationDialog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, LABEL_COLOR
|
||||
from iqpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid, wifi_network_sort_key
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
def _wifi_network_signature(networks: list[Network]) -> tuple[tuple[str, int, bool, int, bool], ...]:
|
||||
return tuple(sorted((n.ssid, n.strength, n.is_connected, int(n.security_type), n.is_saved) for n in networks))
|
||||
|
||||
|
||||
class LoadingAnimation(Widget):
|
||||
RADIUS = 8
|
||||
SPACING = 24
|
||||
Y_MAG = 11.2
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, self.SPACING * 2 + self.RADIUS * 2, self.RADIUS * 2 + int(self.Y_MAG)))
|
||||
|
||||
def _render(self, _):
|
||||
base_x = int(self._rect.x + self._rect.width / 2)
|
||||
base_y = int(self._rect.y + self._rect.height - self.RADIUS)
|
||||
for i in range(3):
|
||||
x = base_x + (i - 1) * self.SPACING
|
||||
y = int(base_y + min(math.sin((rl.get_time() - i * 0.2) * 4) * self.Y_MAG, 0))
|
||||
alpha = int(np.interp(base_y - y, [0, self.Y_MAG], [255 * 0.45, 255 * 0.9]))
|
||||
rl.draw_circle(x, y, self.RADIUS, rl.Color(255, 255, 255, alpha))
|
||||
|
||||
|
||||
class WifiIcon(Widget):
|
||||
def __init__(self, network: Network):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 48 + 5, 36 + 5))
|
||||
self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 48, 42)
|
||||
self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 48, 36)
|
||||
self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 48, 36)
|
||||
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 48, 36)
|
||||
self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 21, 27)
|
||||
self._network = network
|
||||
self._network_missing = False
|
||||
|
||||
def update_network(self, network: Network):
|
||||
self._network = network
|
||||
|
||||
def set_network_missing(self, missing: bool):
|
||||
self._network_missing = missing
|
||||
|
||||
def _render(self, _):
|
||||
strength = round(self._network.strength / 100 * 2)
|
||||
if self._network_missing:
|
||||
strength_icon = self._wifi_slash_txt
|
||||
elif strength == 2:
|
||||
strength_icon = self._wifi_full_txt
|
||||
elif strength == 1:
|
||||
strength_icon = self._wifi_medium_txt
|
||||
else:
|
||||
strength_icon = self._wifi_low_txt
|
||||
rl.draw_texture_ex(strength_icon, (self._rect.x, self._rect.y + self._rect.height - strength_icon.height), 0.0, 1.0, rl.WHITE)
|
||||
if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED):
|
||||
lock_x = self._rect.x + self._rect.width - self._lock_txt.width
|
||||
lock_y = self._rect.y + self._rect.height - self._lock_txt.height + 6
|
||||
rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class ForgetButton(Widget):
|
||||
MARGIN = 12
|
||||
|
||||
def __init__(self, forget_network: Callable):
|
||||
super().__init__()
|
||||
self._forget_network = forget_network
|
||||
self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84)
|
||||
self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35)
|
||||
self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
dlg = BigConfirmationDialog(tr("slide to\nforget"), gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64),
|
||||
self._forget_network, red=True)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _render(self, _):
|
||||
bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
|
||||
rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2,
|
||||
self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE)
|
||||
trash_x = self._rect.x + (self._rect.width - self._trash_txt.width) / 2
|
||||
trash_y = self._rect.y + (self._rect.height - self._trash_txt.height) / 2
|
||||
rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class DisconnectButton(Widget):
|
||||
MARGIN = 12
|
||||
RADIUS = 42
|
||||
# the only round button art is the destructive red one, and dropping a connection is not destructive
|
||||
BG = rl.Color(56, 56, 61, 255)
|
||||
BG_PRESSED = rl.Color(84, 84, 90, 255)
|
||||
|
||||
def __init__(self, disconnect_network: Callable):
|
||||
super().__init__()
|
||||
self._disconnect_network = disconnect_network
|
||||
self._slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 38, 38)
|
||||
self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
dlg = BigConfirmationDialog(tr("slide to\ndisconnect"), gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 54),
|
||||
self._disconnect_network)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _render(self, _):
|
||||
center = rl.Vector2(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2)
|
||||
rl.draw_circle_v(center, self.RADIUS, self.BG_PRESSED if self.is_pressed else self.BG)
|
||||
rl.draw_texture_ex(self._slash_txt, (center.x - self._slash_txt.width / 2, center.y - self._slash_txt.height / 2),
|
||||
0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class WifiButton(BigButton):
|
||||
LABEL_PADDING = 98
|
||||
LABEL_WIDTH = 402 - 98 - 28
|
||||
SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2
|
||||
|
||||
def __init__(self, network: Network, wifi_manager: WifiManager, connecting_ssid: Callable[[], str | None]):
|
||||
super().__init__(normalize_ssid(network.ssid), scroll=True)
|
||||
self._network = network
|
||||
self._wifi_manager = wifi_manager
|
||||
self._connecting_ssid = connecting_ssid
|
||||
self._wifi_icon = WifiIcon(network)
|
||||
self._forget_btn = ForgetButton(self._forget_network)
|
||||
self._disconnect_btn = DisconnectButton(self._disconnect_network)
|
||||
self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32)
|
||||
self._network_missing = False
|
||||
self._network_forgetting = False
|
||||
self._network_disconnecting = False
|
||||
self._wrong_password = False
|
||||
|
||||
@property
|
||||
def network(self) -> Network:
|
||||
return self._network
|
||||
|
||||
def update_network(self, network: Network):
|
||||
self._network = network
|
||||
self._wifi_icon.update_network(network)
|
||||
self._network_missing = False
|
||||
self._wifi_icon.set_network_missing(False)
|
||||
if self._is_connected or self._is_connecting:
|
||||
self._wrong_password = False
|
||||
|
||||
@property
|
||||
def network_forgetting(self) -> bool:
|
||||
return self._network_forgetting
|
||||
|
||||
@property
|
||||
def network_missing(self) -> bool:
|
||||
return self._network_missing
|
||||
|
||||
def _forget_network(self):
|
||||
if self._network_forgetting:
|
||||
return
|
||||
self._network_forgetting = True
|
||||
self._wifi_manager.forget_connection(self._network.ssid)
|
||||
|
||||
def _disconnect_network(self):
|
||||
if self._network_disconnecting:
|
||||
return
|
||||
self._network_disconnecting = True
|
||||
self._wifi_manager.disconnect_connection(self._network.ssid)
|
||||
|
||||
def on_forgotten(self):
|
||||
self._network_forgetting = False
|
||||
|
||||
def on_disconnected(self):
|
||||
self._network_disconnecting = False
|
||||
|
||||
def set_network_missing(self, missing: bool):
|
||||
self._network_missing = missing
|
||||
self._wifi_icon.set_network_missing(missing)
|
||||
|
||||
def set_wrong_password(self):
|
||||
self._wrong_password = True
|
||||
self.trigger_shake()
|
||||
|
||||
@property
|
||||
def _is_saved(self) -> bool:
|
||||
return self._network.is_saved
|
||||
|
||||
@property
|
||||
def _is_connecting(self) -> bool:
|
||||
return self._connecting_ssid() == self._network.ssid
|
||||
|
||||
@property
|
||||
def _is_connected(self) -> bool:
|
||||
return self._network.is_connected
|
||||
|
||||
@property
|
||||
def _is_tethering(self) -> bool:
|
||||
return getattr(self._network, "is_tethering", False)
|
||||
|
||||
@property
|
||||
def _show_forget_btn(self) -> bool:
|
||||
if self._is_tethering or self._network_forgetting or self._show_disconnect_btn:
|
||||
return False
|
||||
return (self._is_saved and not self._wrong_password) or self._is_connecting
|
||||
|
||||
@property
|
||||
def _show_disconnect_btn(self) -> bool:
|
||||
# 402 units of row cannot hold both buttons plus the status word, so the slot is contextual:
|
||||
# disconnect while connected, forget once it is only saved
|
||||
if self._is_tethering or self._network_forgetting or self._network_disconnecting:
|
||||
return False
|
||||
return self._is_connected
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect):
|
||||
return
|
||||
if self._show_disconnect_btn and rl.check_collision_point_rec(mouse_pos, self._disconnect_btn.rect):
|
||||
return
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
return 48
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed and not self._disconnect_btn.is_pressed)
|
||||
self._forget_btn.set_touch_valid_callback(touch_callback)
|
||||
self._disconnect_btn.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if any((self._network_missing, self._is_connecting, self._is_connected, self._network_forgetting,
|
||||
self._network_disconnecting, self._network.security_type == SecurityType.UNSUPPORTED)):
|
||||
self.set_enabled(False)
|
||||
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.585)))
|
||||
self._sub_label.set_font_weight(FontWeight.ROMAN)
|
||||
if self._network_forgetting:
|
||||
self.set_value(tr("forgetting..."))
|
||||
elif self._network_disconnecting:
|
||||
self.set_value(tr("disconnecting..."))
|
||||
elif self._is_connecting:
|
||||
self.set_value(tr("starting...") if self._is_tethering else tr("connecting..."))
|
||||
elif self._is_connected:
|
||||
self.set_value(tr("tethering") if self._is_tethering else tr("connected"))
|
||||
elif self._network_missing:
|
||||
self.set_value(tr("not in range"))
|
||||
else:
|
||||
self.set_value(tr("unsupported"))
|
||||
else:
|
||||
self.set_value(tr("wrong password") if self._wrong_password else tr("connect"))
|
||||
self.set_enabled(True)
|
||||
self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
self._sub_label.set_font_weight(FontWeight.SEMI_BOLD)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
self._label.set_color(LABEL_COLOR)
|
||||
label_rect = rl.Rectangle(btn_x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING,
|
||||
self.LABEL_WIDTH, btn_height - self.LABEL_VERTICAL_PADDING * 2)
|
||||
self._label.render(label_rect)
|
||||
|
||||
if self.value:
|
||||
sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
|
||||
label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING
|
||||
sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0) \
|
||||
- (self._disconnect_btn.rect.width if self._show_disconnect_btn else 0)
|
||||
sub_label_height = self._sub_label.get_content_height(sub_label_w)
|
||||
if self._is_connected and not self._network_forgetting and not self._network_disconnecting:
|
||||
check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2)
|
||||
rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)))
|
||||
sub_label_x += self._check_txt.width + 14
|
||||
sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height)
|
||||
self._sub_label.render(sub_label_rect)
|
||||
|
||||
self._wifi_icon.render(rl.Rectangle(self._rect.x + 30, btn_y + 30, self._wifi_icon.rect.width, self._wifi_icon.rect.height))
|
||||
|
||||
btn_right = self._rect.x + self._rect.width
|
||||
if self._show_forget_btn:
|
||||
self._forget_btn.render(rl.Rectangle(
|
||||
btn_right - self._forget_btn.rect.width,
|
||||
btn_y + self._rect.height - self._forget_btn.rect.height,
|
||||
self._forget_btn.rect.width, self._forget_btn.rect.height))
|
||||
btn_right -= self._forget_btn.rect.width
|
||||
|
||||
if self._show_disconnect_btn:
|
||||
self._disconnect_btn.render(rl.Rectangle(
|
||||
btn_right - self._disconnect_btn.rect.width,
|
||||
btn_y + self._rect.height - self._disconnect_btn.rect.height,
|
||||
self._disconnect_btn.rect.width, self._disconnect_btn.rect.height))
|
||||
|
||||
|
||||
class ScanningButton(BigButton):
|
||||
def __init__(self, is_scanning: Callable[[], bool]):
|
||||
super().__init__("", tr("searching for networks"))
|
||||
self.set_enabled(False)
|
||||
self._loading_animation = LoadingAnimation()
|
||||
self._is_scanning = is_scanning
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
if not self._is_scanning():
|
||||
return
|
||||
anim = self._loading_animation
|
||||
anim.set_position(btn_x + btn_width - anim.rect.width - 40, btn_y + btn_height - anim.rect.height - 30)
|
||||
anim.render()
|
||||
|
||||
|
||||
class WifiUIMici(NavScroller):
|
||||
CALLBACK_INTERVAL_FRAMES = 3
|
||||
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self._scanning_btn = ScanningButton(lambda: self._wifi_manager.is_scanning)
|
||||
self._networks: dict[str, Network] = {}
|
||||
self._network_signature: tuple[tuple[str, int, bool, int, bool], ...] = ()
|
||||
self._connecting: str | None = None
|
||||
self._callback_frame = 0
|
||||
self._wifi_manager.add_callbacks(
|
||||
need_auth=self._on_need_auth,
|
||||
activated=self._on_activated,
|
||||
forgotten=self._on_forgotten,
|
||||
networks_updated=self._on_network_updated,
|
||||
disconnected=self._on_disconnected,
|
||||
)
|
||||
|
||||
def _connecting_ssid(self) -> str | None:
|
||||
return self._connecting
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._wifi_manager.set_active(True)
|
||||
self._callback_frame = 0
|
||||
self._update_buttons(re_sort=True)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
self._wifi_manager.set_active(False)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
signature = _wifi_network_signature(networks)
|
||||
if signature == self._network_signature:
|
||||
return
|
||||
self._network_signature = signature
|
||||
self._networks = {n.ssid: n for n in networks}
|
||||
self._update_buttons()
|
||||
|
||||
def _on_activated(self):
|
||||
self._connecting = None
|
||||
|
||||
def _on_disconnected(self):
|
||||
self._connecting = None
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton):
|
||||
btn.on_disconnected()
|
||||
|
||||
def _on_forgotten(self, ssid=None):
|
||||
self._connecting = None
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton) and (ssid is None or btn.network.ssid == ssid):
|
||||
btn.on_forgotten()
|
||||
|
||||
def _update_buttons(self, re_sort: bool = False):
|
||||
sorted_networks = sorted(self._networks.values(), key=wifi_network_sort_key)
|
||||
existing = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)}
|
||||
for network in sorted_networks:
|
||||
if network.ssid in existing:
|
||||
existing[network.ssid].update_network(network)
|
||||
else:
|
||||
btn = WifiButton(network, self._wifi_manager, self._connecting_ssid)
|
||||
btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid))
|
||||
self._scroller.add_widget(btn)
|
||||
|
||||
current_ssids = set(self._networks)
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton):
|
||||
btn.set_network_missing(btn.network.ssid not in current_ssids)
|
||||
|
||||
if re_sort or sorted_networks:
|
||||
order = {btn.network.ssid: idx for idx, btn in enumerate(self._scroller.items) if isinstance(btn, WifiButton)}
|
||||
wifi_buttons = [btn for btn in self._scroller.items if isinstance(btn, WifiButton)]
|
||||
other_items = [btn for btn in self._scroller.items if not isinstance(btn, WifiButton) and btn is not self._scanning_btn]
|
||||
wifi_buttons.sort(key=lambda btn: (*wifi_network_sort_key(btn.network, btn.network_missing), order[btn.network.ssid]))
|
||||
self._scroller.items[:] = [*wifi_buttons, *other_items]
|
||||
|
||||
items = self._scroller.items
|
||||
if self._scanning_btn in items:
|
||||
items.append(items.pop(items.index(self._scanning_btn)))
|
||||
else:
|
||||
self._scroller.add_widget(self._scanning_btn)
|
||||
|
||||
def _connect_with_password(self, ssid: str, password: str):
|
||||
self._connecting = ssid
|
||||
self._wifi_manager.connect_to_network(ssid, password)
|
||||
self._move_network_to_front(ssid)
|
||||
|
||||
def _connect_to_network(self, ssid: str):
|
||||
network = self._networks.get(ssid)
|
||||
if network is None:
|
||||
cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
|
||||
return
|
||||
if network.is_saved:
|
||||
self._connecting = ssid
|
||||
self._wifi_manager.activate_connection(ssid)
|
||||
elif network.security_type == SecurityType.OPEN:
|
||||
self._connecting = ssid
|
||||
self._wifi_manager.connect_to_network(ssid, "")
|
||||
else:
|
||||
self._on_need_auth(ssid, False)
|
||||
return
|
||||
self._move_network_to_front(ssid)
|
||||
|
||||
def _on_need_auth(self, ssid, incorrect_password=True):
|
||||
if incorrect_password:
|
||||
self._connecting = None
|
||||
for btn in self._scroller.items:
|
||||
if isinstance(btn, WifiButton) and btn.network.ssid == ssid:
|
||||
btn.set_wrong_password()
|
||||
break
|
||||
return
|
||||
dlg = BigInputDialog(tr("enter password..."), "", minimum_length=8,
|
||||
confirm_callback=lambda _password: self._connect_with_password(ssid, _password))
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _move_network_to_front(self, ssid: str | None):
|
||||
idx = next((i for i, btn in enumerate(self._scroller.items)
|
||||
if isinstance(btn, WifiButton) and btn.network.ssid == ssid), None) if ssid else None
|
||||
if idx is not None and idx > 0:
|
||||
self._scroller.move_item(idx, 0)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self._callback_frame % self.CALLBACK_INTERVAL_FRAMES == 0:
|
||||
self._wifi_manager.process_callbacks()
|
||||
self._callback_frame += 1
|
||||
131
iqpilot/selfdrive/ui/mici/layouts/settings/settings.py
Normal file
131
iqpilot/selfdrive/ui/mici/layouts/settings/settings.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.steering import SteeringLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.cruise import CruiseLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.visuals import VisualsLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.models import ModelsLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.display import DisplayLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.drive_history import TripsLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.vehicle import VehicleLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.dashcam import DashcamLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class SettingsBigButton(BigButton):
|
||||
def _get_label_font_size(self):
|
||||
return 64
|
||||
|
||||
|
||||
class CruiseModeButton(SettingsBigButton):
|
||||
"""Cruise menu button whose icon reflects the active longitudinal mode."""
|
||||
_ICON_SIZE = 60
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(tr("cruise"), "", gui_app.texture("icons_mici/speedometer.png", self._ICON_SIZE, self._ICON_SIZE))
|
||||
self._p = Params()
|
||||
self._icons = [
|
||||
gui_app.texture("icons_mici/speedometer.png", self._ICON_SIZE, self._ICON_SIZE),
|
||||
gui_app.texture("icons_mici/iqstandard_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
|
||||
gui_app.texture("icons_mici/iqdynamic_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
|
||||
gui_app.texture("icons_mici/experimental_mode_mici.png", self._ICON_SIZE, self._ICON_SIZE),
|
||||
]
|
||||
|
||||
def _mode_index(self) -> int:
|
||||
if not self._p.get_bool("AlphaLongitudinalEnabled"):
|
||||
return 0
|
||||
if not self._p.get_bool("ExperimentalMode"):
|
||||
return 1
|
||||
return 2 if self._p.get_bool("IQDynamicMode") else 3
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self.set_icon(self._icons[self._mode_index()])
|
||||
|
||||
|
||||
class SettingsLayout(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
|
||||
toggles_panel = TogglesLayoutMici()
|
||||
toggles_btn = SettingsBigButton(tr("toggles"), "", gui_app.texture("icons_mici/settings.png", 64, 64))
|
||||
toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel))
|
||||
|
||||
steering_panel = SteeringLayoutMici()
|
||||
steering_btn = SettingsBigButton(tr("steering"), "", gui_app.texture("icons_mici/wheel.png", 64, 64))
|
||||
steering_btn.set_click_callback(lambda: gui_app.push_widget(steering_panel))
|
||||
|
||||
cruise_panel = CruiseLayoutMici()
|
||||
cruise_btn = CruiseModeButton()
|
||||
cruise_btn.set_click_callback(lambda: gui_app.push_widget(cruise_panel))
|
||||
|
||||
visuals_panel = VisualsLayoutMici()
|
||||
visuals_btn = SettingsBigButton(tr("visuals"), "", gui_app.texture("icons_mici/onroad/eye_fill.png", 64, 46))
|
||||
visuals_btn.set_click_callback(lambda: gui_app.push_widget(visuals_panel))
|
||||
|
||||
models_panel = ModelsLayoutMici()
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("icons_mici/models.png", 60, 60))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
display_panel = DisplayLayoutMici()
|
||||
display_btn = SettingsBigButton(tr("display"), "", gui_app.texture("icons_mici/settings/brightness.png", 62, 62))
|
||||
display_btn.set_click_callback(lambda: gui_app.push_widget(display_panel))
|
||||
|
||||
trips_panel = TripsLayoutMici()
|
||||
trips_btn = SettingsBigButton(tr("trips"), "", gui_app.texture("icons_mici/settings/trips.png", 62, 56))
|
||||
trips_btn.set_click_callback(lambda: gui_app.push_widget(trips_panel))
|
||||
|
||||
vehicle_panel = VehicleLayoutMici()
|
||||
vehicle_btn = SettingsBigButton(tr("vehicle"), "", gui_app.texture("icons_mici/settings/vehicle.png", 70, 56))
|
||||
vehicle_btn.set_click_callback(lambda: gui_app.push_widget(vehicle_panel))
|
||||
|
||||
dashcam_panel = DashcamLayoutMici()
|
||||
dashcam_btn = SettingsBigButton(tr("dashcam"), "", gui_app.texture("icons_mici/settings/camera.png", 64, 56))
|
||||
dashcam_btn.set_click_callback(lambda: gui_app.push_widget(dashcam_panel))
|
||||
|
||||
network_panel = NetworkLayoutMici(back_callback=lambda: gui_app.pop_widget())
|
||||
network_btn = SettingsBigButton(tr("network"), "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56))
|
||||
network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel))
|
||||
|
||||
|
||||
device_panel = DeviceLayoutMici()
|
||||
device_btn = SettingsBigButton(tr("device"), "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58))
|
||||
device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel))
|
||||
|
||||
software_panel = SoftwareLayoutMici()
|
||||
software_btn = SettingsBigButton(tr("software"), "", gui_app.texture("icons_mici/settings/sd_card.png", 60, 72))
|
||||
software_btn.set_click_callback(lambda: gui_app.push_widget(software_panel))
|
||||
|
||||
developer_panel = DeveloperLayoutMici()
|
||||
developer_btn = SettingsBigButton(tr("developer"), "", gui_app.texture("icons_mici/settings/developer_icon.png", 64, 60))
|
||||
developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
device_btn,
|
||||
network_btn,
|
||||
PairBigButton(),
|
||||
models_btn,
|
||||
software_btn,
|
||||
steering_btn,
|
||||
cruise_btn,
|
||||
visuals_btn,
|
||||
display_btn,
|
||||
dashcam_btn,
|
||||
vehicle_btn,
|
||||
toggles_btn,
|
||||
trips_btn,
|
||||
developer_btn,
|
||||
])
|
||||
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
365
iqpilot/selfdrive/ui/mici/layouts/settings/software.py
Normal file
365
iqpilot/selfdrive/ui/mici/layouts/settings/software.py
Normal file
@@ -0,0 +1,365 @@
|
||||
import os
|
||||
import threading
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_dialog import BigDialog
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond
|
||||
|
||||
|
||||
def _split_description(desc: str) -> tuple[str, str, str, str] | None:
|
||||
# UpdaterCurrentDescription/UpdaterNewDescription format: "version / branch / commit / date"
|
||||
parts = [p.strip() for p in desc.split(" / ")]
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
version, branch, commit, date = parts
|
||||
return version, branch, commit, date
|
||||
|
||||
|
||||
class UpdaterState(IntEnum):
|
||||
IDLE = 0
|
||||
WAITING_FOR_UPDATER = 1
|
||||
UPDATER_RESPONDING = 2
|
||||
|
||||
|
||||
class SoftwareInfoLayoutMici(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 360, 180))
|
||||
|
||||
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
|
||||
max_width = int(self._rect.width - 20)
|
||||
self._version_label = UnifiedLabel(tr("version"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self._version_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
|
||||
|
||||
self._branch_label = UnifiedLabel(tr("branch"), 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
|
||||
self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color,
|
||||
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
|
||||
|
||||
def _update_state(self):
|
||||
desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "")
|
||||
if desc is not None:
|
||||
version, branch, commit, date = desc
|
||||
self._version_text_label.set_text(f"{version} ({date})")
|
||||
self._branch_text_label.set_text(f"{branch} ({commit})")
|
||||
else:
|
||||
self._version_text_label.set_text(ui_state.params.get("Version") or "N/A")
|
||||
self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A")
|
||||
|
||||
def _render(self, _):
|
||||
self._version_label.set_position(self._rect.x + 20, self._rect.y - 10)
|
||||
self._version_label.render()
|
||||
|
||||
self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
|
||||
self._version_text_label.render()
|
||||
|
||||
self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
|
||||
self._branch_label.render()
|
||||
|
||||
self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
|
||||
self._branch_text_label.render()
|
||||
|
||||
|
||||
class CheckUpdateButton(BigButton):
|
||||
def __init__(self):
|
||||
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
|
||||
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
|
||||
super().__init__(tr("check for update"), "", self._txt_update_icon)
|
||||
self.set_press_effect_enabled(False)
|
||||
|
||||
self._waiting_for_updater_t: float | None = None
|
||||
self._hide_value_t: float | None = None
|
||||
self._state: UpdaterState = UpdaterState.IDLE
|
||||
|
||||
ui_state.add_offroad_transition_callback(self.offroad_transition)
|
||||
|
||||
def offroad_transition(self):
|
||||
if ui_state.is_offroad():
|
||||
self.set_enabled(True)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
if not system_time_valid():
|
||||
dlg = BigDialog("", tr("Please connect to Wi-Fi to update."))
|
||||
gui_app.push_widget(dlg)
|
||||
return
|
||||
|
||||
self.set_enabled(False)
|
||||
self._state = UpdaterState.WAITING_FOR_UPDATER
|
||||
self.set_icon(self._txt_update_icon)
|
||||
|
||||
def run():
|
||||
if self.get_value() == "download update":
|
||||
os.system("pkill -SIGHUP -f system.updated.updated")
|
||||
else:
|
||||
os.system("pkill -SIGUSR1 -f system.updated.updated")
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
def set_value(self, value: str):
|
||||
super().set_value(value)
|
||||
if value:
|
||||
self.set_text("")
|
||||
else:
|
||||
self.set_text(tr("check for update"))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if ui_state.started:
|
||||
self.set_enabled(False)
|
||||
return
|
||||
|
||||
updater_state = ui_state.params.get("UpdaterState") or ""
|
||||
failed_count = ui_state.params.get("UpdateFailedCount") or 0
|
||||
failed = int(failed_count) > 0
|
||||
update_available = ui_state.params.get_bool("UpdateAvailable")
|
||||
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
|
||||
cur_desc = ui_state.params.get("UpdaterCurrentDescription") or ""
|
||||
new_desc = ui_state.params.get("UpdaterNewDescription") or ""
|
||||
|
||||
# Ignore a stale failure marker once the updater is idle and already agrees there is
|
||||
# nothing new to install. Otherwise mici can get stuck on "failed to update" forever
|
||||
# even after a later successful fetch.
|
||||
stale_failure = failed and updater_state == "idle" and not fetch_available and not update_available and cur_desc == new_desc
|
||||
|
||||
if self._state == UpdaterState.WAITING_FOR_UPDATER:
|
||||
self.set_rotate_icon(True)
|
||||
if updater_state != "idle":
|
||||
self._state = UpdaterState.UPDATER_RESPONDING
|
||||
|
||||
# Recover from updater not responding (time invalid shortly after boot)
|
||||
if self._waiting_for_updater_t is None:
|
||||
self._waiting_for_updater_t = rl.get_time()
|
||||
|
||||
if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT:
|
||||
self.set_rotate_icon(False)
|
||||
self.set_value(tr("updater failed\nto respond"))
|
||||
self._state = UpdaterState.IDLE
|
||||
self._hide_value_t = rl.get_time()
|
||||
|
||||
elif self._state == UpdaterState.UPDATER_RESPONDING:
|
||||
if updater_state == "idle":
|
||||
self.set_rotate_icon(False)
|
||||
self._state = UpdaterState.IDLE
|
||||
self._hide_value_t = rl.get_time()
|
||||
else:
|
||||
if self.get_value() != tr(updater_state):
|
||||
self.set_value(tr(updater_state))
|
||||
|
||||
elif self._state == UpdaterState.IDLE:
|
||||
self.set_rotate_icon(False)
|
||||
if failed and not stale_failure:
|
||||
self.set_enabled(True) # allow retry when failure came from updater param
|
||||
if self.get_value() != tr("failed to update"):
|
||||
self.set_value(tr("failed to update"))
|
||||
|
||||
elif fetch_available:
|
||||
self.set_enabled(True)
|
||||
if self.get_value() != tr("download update"):
|
||||
self.set_value(tr("download update"))
|
||||
|
||||
elif self._hide_value_t is not None:
|
||||
self.set_enabled(True)
|
||||
if self.get_value() == tr("checking..."):
|
||||
self.set_value(tr("up to date"))
|
||||
self.set_icon(self._txt_up_to_date_icon)
|
||||
|
||||
# Hide previous text after short amount of time (up to date or failed)
|
||||
if rl.get_time() - self._hide_value_t > 3.0:
|
||||
self._hide_value_t = None
|
||||
self.set_value("")
|
||||
self.set_icon(self._txt_update_icon)
|
||||
else:
|
||||
self.set_enabled(True)
|
||||
if self.get_value() != "":
|
||||
self.set_value("")
|
||||
|
||||
if self._state != UpdaterState.WAITING_FOR_UPDATER:
|
||||
self._waiting_for_updater_t = None
|
||||
|
||||
|
||||
class InstallUpdateButton(BigButton):
|
||||
def __init__(self):
|
||||
super().__init__(tr("install update"), "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
|
||||
self.set_press_effect_enabled(False)
|
||||
self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable"))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
desc = _split_description(ui_state.params.get("UpdaterNewDescription") or "")
|
||||
value = f"{desc[0]} ({desc[1]})" if desc is not None else ""
|
||||
if self.get_value() != value:
|
||||
self.set_value(value)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
self.set_enabled(False)
|
||||
|
||||
def run():
|
||||
ui_state.params.put_bool("DoReboot", True)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
class InstallModePage(NavScroller):
|
||||
MODES = [
|
||||
("download_only", "predownload only"),
|
||||
("download_and_install", "predownload + preinstall"),
|
||||
]
|
||||
|
||||
def __init__(self, on_select: Callable[[str], None]):
|
||||
super().__init__()
|
||||
|
||||
current_mode = ui_state.params.get("UpdaterInstallMode") or "download_and_install"
|
||||
check_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
|
||||
|
||||
buttons = []
|
||||
for mode, label in self.MODES:
|
||||
btn = BigButton(tr(label), "", check_icon if mode == current_mode else None, scroll=True)
|
||||
btn.set_click_callback(lambda m=mode: self.dismiss(lambda: on_select(m)))
|
||||
buttons.append(btn)
|
||||
self._scroller.add_widgets(buttons)
|
||||
|
||||
|
||||
class InstallModeButton(BigButton):
|
||||
MODE_LABELS = {
|
||||
"download_only": "predownload only",
|
||||
"download_and_install": "predownload + preinstall",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(tr("update\ninstall mode"), "")
|
||||
self.set_press_effect_enabled(False)
|
||||
self._label.set_font_size(40)
|
||||
self._label.set_line_height(0.95)
|
||||
self.set_click_callback(self._on_click)
|
||||
self.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
def _current_mode(self) -> str:
|
||||
mode = ui_state.params.get("UpdaterInstallMode") or "download_and_install"
|
||||
if mode not in self.MODE_LABELS:
|
||||
return "download_and_install"
|
||||
return mode
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self.set_value(tr(self.MODE_LABELS[self._current_mode()]))
|
||||
|
||||
def _on_click(self):
|
||||
gui_app.push_widget(InstallModePage(self._on_select))
|
||||
|
||||
def _on_select(self, mode: str):
|
||||
ui_state.params.put("UpdaterInstallMode", mode)
|
||||
self.set_value(tr(self.MODE_LABELS[self._current_mode()]))
|
||||
|
||||
|
||||
class BranchSelectPage(NavScroller):
|
||||
def __init__(self, on_select: Callable[[str], None]):
|
||||
super().__init__()
|
||||
|
||||
params = ui_state.params
|
||||
current_git_branch = params.get("GitBranch") or ""
|
||||
branches_str = params.get("UpdaterAvailableBranches") or ""
|
||||
branches = [b for b in branches_str.split(",") if b]
|
||||
|
||||
for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]:
|
||||
if b in branches:
|
||||
branches.remove(b)
|
||||
branches.insert(0, b)
|
||||
|
||||
current_target = params.get("UpdaterTargetBranch") or ""
|
||||
check_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
|
||||
|
||||
buttons = []
|
||||
for branch in branches:
|
||||
btn = BigButton(branch, "", check_icon if branch == current_target else None, scroll=True)
|
||||
btn.set_click_callback(lambda b=branch: self.dismiss(lambda: on_select(b)))
|
||||
buttons.append(btn)
|
||||
self._scroller.add_widgets(buttons)
|
||||
|
||||
|
||||
class TargetBranchButton(BigButton):
|
||||
def __init__(self):
|
||||
super().__init__(tr("target branch"), ui_state.params.get("UpdaterTargetBranch") or "")
|
||||
self.set_press_effect_enabled(False)
|
||||
self.set_click_callback(self._on_click)
|
||||
self.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
|
||||
self.set_enabled(lambda: ui_state.is_offroad())
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
target = ui_state.params.get("UpdaterTargetBranch") or ""
|
||||
if self.get_value() != target:
|
||||
self.set_value(target)
|
||||
|
||||
def _on_click(self):
|
||||
gui_app.push_widget(BranchSelectPage(self._on_select))
|
||||
|
||||
def _on_select(self, branch: str):
|
||||
ui_state.params.put("UpdaterTargetBranch", branch)
|
||||
self.set_value(branch)
|
||||
os.system("pkill -SIGUSR1 -f system.updated.updated")
|
||||
|
||||
|
||||
class DisableUpdatesButton(BigButton):
|
||||
def __init__(self):
|
||||
super().__init__(tr("automatic\nupdates"), tr("on"))
|
||||
self.set_enabled(lambda: ui_state.is_offroad())
|
||||
self.set_press_effect_enabled(False)
|
||||
self._label.set_font_size(40)
|
||||
self._label.set_line_height(0.95)
|
||||
self.set_click_callback(self._on_pressed)
|
||||
|
||||
def _on_pressed(self):
|
||||
disabled = ui_state.params.get_bool("DisableUpdates")
|
||||
ui_state.params.put_bool("DisableUpdates", not disabled)
|
||||
self._sync_from_params()
|
||||
|
||||
def _sync_from_params(self):
|
||||
disabled = ui_state.params.get_bool("DisableUpdates")
|
||||
self.set_value(tr("off") if disabled else tr("on"))
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._sync_from_params()
|
||||
|
||||
|
||||
class SoftwareLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def uninstall_openpilot_callback():
|
||||
ui_state.params.put_bool("DoUninstall", True)
|
||||
|
||||
uninstall_openpilot_btn = EngagedConfirmationButton(tr("uninstall IQ.Pilot"), tr("uninstall"),
|
||||
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
|
||||
uninstall_openpilot_callback, exit_on_confirm=False)
|
||||
uninstall_openpilot_btn.set_press_effect_enabled(False)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
SoftwareInfoLayoutMici(),
|
||||
CheckUpdateButton(),
|
||||
InstallUpdateButton(),
|
||||
InstallModeButton(),
|
||||
DisableUpdatesButton(),
|
||||
TargetBranchButton(),
|
||||
uninstall_openpilot_btn,
|
||||
])
|
||||
154
iqpilot/selfdrive/ui/mici/layouts/settings/steering.py
Normal file
154
iqpilot/selfdrive/ui/mici/layouts/settings/steering.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.cereal import car
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.controls.lib.helpers.lane_change import AutoLaneChangeMode
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
def _aol_modes() -> list[str]:
|
||||
return [tr("stay engaged"), tr("standby"), tr("disengage")]
|
||||
|
||||
|
||||
class SabBrakeToggle(BigParamControl):
|
||||
"""Driver-intervention toggle backed by AolSteeringMode == 2."""
|
||||
def __init__(self):
|
||||
super().__init__(tr("Driver Intervention Handling"), "AolEnabled")
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(int(self.params.get("AolSteeringMode", return_default=True)) == 2)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
super(BigParamControl, self)._handle_mouse_release(mouse_pos)
|
||||
enabled = self._checked
|
||||
current_mode = int(self.params.get("AolSteeringMode", return_default=True))
|
||||
if enabled:
|
||||
self.params.put("AolSteeringMode", 2)
|
||||
elif current_mode == 2:
|
||||
self.params.put("AolSteeringMode", 1)
|
||||
|
||||
|
||||
def _has_limited_sab_options() -> bool:
|
||||
brand = ""
|
||||
if ui_state.is_offroad():
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle:
|
||||
brand = bundle.get("brand", "")
|
||||
if not brand:
|
||||
brand = ui_state.CP.brand if ui_state.CP else ""
|
||||
return brand == "rivian"
|
||||
|
||||
|
||||
class SabSettingsPanel(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._main_cruise = BigParamControl(tr("Availability While Cruise Changes"), "AolMainCruiseAllowed")
|
||||
self._brake = SabBrakeToggle()
|
||||
self._mode = MappedParamToggle(tr("Brake Response Mode"), "AolSteeringMode",
|
||||
_aol_modes(), [0, 1, 2])
|
||||
self._steer_override = BigParamControl(tr("Pause While You Steer"), "AolPauseOnSteeringOverride")
|
||||
self._scroller.add_widgets([self._main_cruise, self._brake, self._mode, self._steer_override])
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
limited = _has_limited_sab_options()
|
||||
if limited:
|
||||
ui_state.params.remove("AolMainCruiseAllowed")
|
||||
ui_state.params.put_bool("AolUnifiedEngagementMode", True)
|
||||
ui_state.params.put("AolSteeringMode", 2)
|
||||
offroad = ui_state.is_offroad()
|
||||
for w in (self._main_cruise, self._brake, self._mode):
|
||||
w.refresh()
|
||||
w.set_enabled(offroad and not limited)
|
||||
self._steer_override.refresh()
|
||||
self._steer_override.set_enabled(offroad)
|
||||
|
||||
|
||||
class LaneChangePanel(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._timer = MappedParamToggle(tr("Auto Lane Change"), "IQLaneChangeTimer",
|
||||
[tr("off"), tr("nudge"), tr("no nudge"), "0.5 s", "1 s", "2 s", "3 s"],
|
||||
[-1, 0, 1, 2, 3, 4, 5])
|
||||
self._bsm_delay = BigParamControl(tr("Delay with Blind Spot"), "IQLaneChangeBsmDelay")
|
||||
self._edge_guard = BigParamControl(tr("Lane Edge Guard"), "IQEdgeGuard")
|
||||
self._edge_guard.set_value(tr("Blocks lane changes when a road edge is detected on the target side."))
|
||||
self._continuous = BigParamControl(tr("Continuous Changes"), "LaneChangeContinuous")
|
||||
self._scroller.add_widgets([self._timer, self._bsm_delay, self._edge_guard, self._continuous])
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._timer.refresh()
|
||||
enable_bsm = bool(ui_state.CP and ui_state.CP.enableBsm)
|
||||
if not enable_bsm and ui_state.params.get_bool("IQLaneChangeBsmDelay"):
|
||||
ui_state.params.remove("IQLaneChangeBsmDelay")
|
||||
self._bsm_delay.refresh()
|
||||
self._bsm_delay.set_enabled(
|
||||
enable_bsm and int(ui_state.params.get("IQLaneChangeTimer", return_default=True)) > AutoLaneChangeMode.NUDGE
|
||||
)
|
||||
self._edge_guard.refresh()
|
||||
self._continuous.refresh()
|
||||
|
||||
|
||||
class SteeringLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._sab_panel = SabSettingsPanel()
|
||||
self._lc_panel = LaneChangePanel()
|
||||
|
||||
self._aol = BigParamControl(tr("AOL"), "AolEnabled", toggle_callback=self._on_aol_toggled)
|
||||
self._sab_settings_button = BigButton(tr("steering assistance behavior"))
|
||||
self._sab_settings_button.set_click_callback(lambda: gui_app.push_widget(self._sab_panel))
|
||||
self._lane_change = BigButton(tr("lane change"))
|
||||
self._lane_change.set_click_callback(lambda: gui_app.push_widget(self._lc_panel))
|
||||
self._nnff = BigParamControl(tr("Neural Net FF"), "NeuralNetworkFeedForward", toggle_callback=self._on_nnff_toggled)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._aol, self._sab_settings_button, self._lane_change,
|
||||
self._nnff,
|
||||
])
|
||||
|
||||
def _aol_mode_str(self) -> str:
|
||||
try:
|
||||
return _aol_modes()[int(ui_state.params.get("AolSteeringMode", return_default=True))]
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return _aol_modes()[0]
|
||||
|
||||
def _on_aol_toggled(self, checked: bool):
|
||||
if checked:
|
||||
ui_state.params.put_bool("AolUnifiedEngagementMode", True)
|
||||
|
||||
def _on_nnff_toggled(self, checked: bool):
|
||||
return None
|
||||
|
||||
def _refresh(self):
|
||||
offroad = ui_state.is_offroad()
|
||||
self._aol.refresh()
|
||||
self._aol.set_value(self._aol_mode_str())
|
||||
self._nnff.refresh()
|
||||
|
||||
steering_supported = (ui_state.CP is not None and
|
||||
ui_state.CP.steerControlType != car.CarParams.SteerControlType.angle)
|
||||
if not steering_supported:
|
||||
ui_state.params.remove("NeuralNetworkFeedForward")
|
||||
self._nnff.refresh()
|
||||
|
||||
self._aol.set_enabled(offroad)
|
||||
self._sab_settings_button.set_enabled(offroad and self._aol._checked)
|
||||
self._nnff.set_enabled(offroad and steering_supported)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._refresh()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._refresh()
|
||||
48
iqpilot/selfdrive/ui/mici/layouts/settings/toggles.py
Normal file
48
iqpilot/selfdrive/ui/mici/layouts/settings/toggles.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class TogglesLayoutMici(NavScroller):
|
||||
"""Equivalent to the BIG UI toggles page, minus cruise items (personality / speed limit /
|
||||
longitudinal control live in Cruise) and dashcam items (dashcam / driver-cam / mic live in Dashcam)."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
ui_state.params.put_bool("OpenpilotEnabledToggle", True)
|
||||
|
||||
disengage = BigParamControl(tr("disengage on accelerator"), "DisengageOnAccelerator")
|
||||
ldw = BigParamControl(tr("lane departure warnings"), "IsLdwEnabled")
|
||||
is_metric = BigParamControl(tr("use metric units"), "IsMetric")
|
||||
auto_units = BigParamControl(tr("set units from location"), "IQAutoUnits", toggle_callback=self._auto_units_callback)
|
||||
|
||||
self._scroller.add_widgets([disengage, ldw, is_metric, auto_units])
|
||||
|
||||
self._refresh_toggles = (
|
||||
("DisengageOnAccelerator", disengage),
|
||||
("IsLdwEnabled", ldw),
|
||||
("IsMetric", is_metric),
|
||||
("IQAutoUnits", auto_units),
|
||||
)
|
||||
|
||||
if ui_state.params.get_bool("ShowDebugInfo"):
|
||||
gui_app.set_show_touches(True)
|
||||
gui_app.set_show_fps(True)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._update_toggles()
|
||||
|
||||
def _auto_units_callback(self, state: bool):
|
||||
if state:
|
||||
ui_state.params.remove("IQAutoUnitsRegion")
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
227
iqpilot/selfdrive/ui/mici/layouts/settings/vehicle.py
Normal file
227
iqpilot/selfdrive/ui/mici/layouts/settings/vehicle.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.selfdrive.car.vehicle_catalog import load_catalog
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigButton, BigParamControl
|
||||
from iqpilot.selfdrive.ui.mici.layouts.settings.iq_widgets import MappedParamToggle
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
def _ascii_safe(text: str) -> str:
|
||||
return unicodedata.normalize("NFD", text).encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
|
||||
def _load_platforms() -> dict:
|
||||
try:
|
||||
return load_catalog()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class _PickerRow(Widget):
|
||||
HEIGHT = 92
|
||||
|
||||
def __init__(self, label: str, on_tap):
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._on_tap = on_tap
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, self.HEIGHT))
|
||||
self._font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self._on_tap(self._label)
|
||||
|
||||
def _render(self, _):
|
||||
color = rl.Color(255, 255, 255, 255) if self.is_pressed else rl.Color(255, 255, 255, 200)
|
||||
ts = measure_text_cached(self._font, self._label, 46)
|
||||
rl.draw_text_ex(self._font, self._label, rl.Vector2(self._rect.x + 44, self._rect.y + (self.HEIGHT - ts.y) / 2), 46, 0, color)
|
||||
|
||||
|
||||
class _VerticalPicker(NavScroller):
|
||||
def __init__(self, options: list[str], on_pick):
|
||||
super().__init__(horizontal=False, snap_items=False, pad_start=20, pad_end=20)
|
||||
self._on_pick = on_pick
|
||||
rows = [_PickerRow(o, self._pick) for o in options]
|
||||
for row in rows:
|
||||
row.set_touch_valid_callback(lambda: self._scroller.scroll_panel.is_touch_valid())
|
||||
self._scroller.add_widgets(rows)
|
||||
|
||||
def _pick(self, option: str):
|
||||
gui_app.pop_widget()
|
||||
self._on_pick(option)
|
||||
|
||||
|
||||
class VehicleLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._platforms = _load_platforms()
|
||||
|
||||
self._vehicle_btn = BigButton(tr("vehicle"))
|
||||
self._vehicle_btn.set_click_callback(self._on_vehicle_clicked)
|
||||
|
||||
self._toyota_long = BigParamControl(tr("enforce factory long."), "IQToyotaFactoryLong",
|
||||
toggle_callback=self._on_toyota_long)
|
||||
self._hyundai_tuning = MappedParamToggle(tr("hyundai long. tuning"), "IQHyundaiLongTune",
|
||||
[tr("off"), tr("dynamic"), tr("predictive")], [0, 1, 2])
|
||||
self._subaru_snag = BigParamControl(tr("creep from standstill (beta)"), "IQSubaruCreepAssist")
|
||||
self._subaru_manual = BigParamControl(tr("stop and go manual brake"), "IQSubaruCreepAssistManualBrake")
|
||||
self._vw_pq_hca = BigParamControl(tr("PQ HCA status 7 mode"), "pqhca5or7Toggle")
|
||||
self._vw_lateral = BigParamControl(tr("lateral when cruise faulted"), "AllowLateralWhenLongUnavailable")
|
||||
self._vw_mqb_acc_resume = BigParamControl(tr("MQB ACC resume"), "iqMqbAccResume")
|
||||
self._vw_mqb_steering_lockout = BigParamControl(tr("MQB steering lockout"), "iqMqbSteeringLockout")
|
||||
self._vw_curvature_controller = BigParamControl(tr("curvature controller"), "EnableCurvatureController")
|
||||
self._vw_smooth_steer = BigParamControl(tr("smooth steering"), "EnableSmoothSteer")
|
||||
self._tesla_vtb = BigParamControl(tr("virtual torque blending"), "IQTeslaTorqueBlend")
|
||||
self._tesla_fsd_visualization = BigParamControl(tr("FSD visuals"), "IQTeslaFsdVisualization")
|
||||
|
||||
self._brand_widgets = {
|
||||
"toyota": [self._toyota_long],
|
||||
"hyundai": [self._hyundai_tuning],
|
||||
"subaru": [self._subaru_snag, self._subaru_manual],
|
||||
"volkswagen": [self._vw_pq_hca, self._vw_lateral, self._vw_mqb_acc_resume, self._vw_mqb_steering_lockout,
|
||||
self._vw_curvature_controller, self._vw_smooth_steer],
|
||||
"tesla": [self._tesla_vtb, self._tesla_fsd_visualization],
|
||||
}
|
||||
self._all_brand_widgets = [w for ws in self._brand_widgets.values() for w in ws]
|
||||
|
||||
self._scroller.add_widgets([self._vehicle_btn] + self._all_brand_widgets)
|
||||
|
||||
def _get_current_brand(self) -> str:
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle:
|
||||
return bundle.get("brand", "")
|
||||
if ui_state.CP:
|
||||
return getattr(ui_state.CP, "brand", "")
|
||||
return ""
|
||||
|
||||
def _vw_flags(self):
|
||||
try:
|
||||
from iqdbc.car.volkswagen.values import CAR
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle and (platform := bundle.get("platform")):
|
||||
return CAR[platform].config.flags
|
||||
if ui_state.CP:
|
||||
return ui_state.CP.flags
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _uses_vw_hca_status_toggle(self) -> bool:
|
||||
from iqdbc.car.volkswagen.values import VolkswagenFlags
|
||||
return bool(self._vw_flags() & (VolkswagenFlags.PQ | VolkswagenFlags.MLB))
|
||||
|
||||
def _is_vw_mqb(self) -> bool:
|
||||
from iqdbc.car.volkswagen.values import VolkswagenFlags
|
||||
flags = self._vw_flags()
|
||||
return not bool(flags & (VolkswagenFlags.PQ | VolkswagenFlags.MLB | VolkswagenFlags.MEB | VolkswagenFlags.MEB_GEN2 | VolkswagenFlags.MQB_EVO))
|
||||
|
||||
def _is_vw_curvature_car(self) -> bool:
|
||||
from iqdbc.car.volkswagen.values import VolkswagenFlags
|
||||
return bool(self._vw_flags() & (VolkswagenFlags.MEB | VolkswagenFlags.MQB_EVO))
|
||||
|
||||
def _supports_vw_lateral_when_faulted(self) -> bool:
|
||||
from iqdbc.car.volkswagen.values import VolkswagenFlags
|
||||
# PQ, MEB, MQB_EVO and base MQB all implement cruiseFaultLateralMode in carstate.py.
|
||||
# MLB does not.
|
||||
return not bool(self._vw_flags() & VolkswagenFlags.MLB)
|
||||
|
||||
def _pretty_name(self, platform: str) -> str:
|
||||
for name, v in self._platforms.items():
|
||||
if v.get("platform") == platform:
|
||||
make = v.get("make", "")
|
||||
if make and name.lower().startswith(make.lower() + " "):
|
||||
return name[len(make) + 1:]
|
||||
return name
|
||||
return _ascii_safe(platform).replace("_", " ").title()
|
||||
|
||||
def _vehicle_status(self) -> str:
|
||||
bundle = ui_state.params.get("CarPlatformBundle")
|
||||
if bundle:
|
||||
name = _ascii_safe(bundle.get("name", "?"))
|
||||
make = bundle.get("make", "")
|
||||
if make and name.lower().startswith(make.lower() + " "):
|
||||
name = name[len(make) + 1:]
|
||||
return name[:1].upper() + name[1:]
|
||||
if ui_state.CP and ui_state.CP.carFingerprint not in ("", "MOCK"):
|
||||
return self._pretty_name(ui_state.CP.carFingerprint)
|
||||
return "tap to select"
|
||||
|
||||
def _on_vehicle_clicked(self):
|
||||
if ui_state.params.get("CarPlatformBundle"):
|
||||
ui_state.params.remove("CarPlatformBundle")
|
||||
self._refresh()
|
||||
else:
|
||||
self._open_make_picker()
|
||||
|
||||
def _open_make_picker(self):
|
||||
makes = sorted({v.get("make", "") for v in self._platforms.values() if v.get("make")})
|
||||
label_to_make = {_ascii_safe(m): m for m in makes}
|
||||
gui_app.push_widget(_VerticalPicker(list(label_to_make.keys()),
|
||||
lambda lbl: self._open_model_picker(label_to_make.get(lbl, ""))))
|
||||
|
||||
def _open_model_picker(self, make: str):
|
||||
if not make:
|
||||
return
|
||||
prefix = make + " "
|
||||
label_to_key: dict = {}
|
||||
for key in sorted(p for p, v in self._platforms.items() if v.get("make") == make):
|
||||
label = key[len(prefix):] if key.lower().startswith(prefix.lower()) else key
|
||||
label_to_key[_ascii_safe(label)] = key
|
||||
gui_app.push_widget(_VerticalPicker(list(label_to_key.keys()),
|
||||
lambda lbl: self._select_platform(label_to_key.get(lbl, ""))))
|
||||
|
||||
def _select_platform(self, key: str):
|
||||
if key and (data := self._platforms.get(key)):
|
||||
ui_state.params.put("CarPlatformBundle", {**data, "name": key})
|
||||
gui_app.pop_widgets_to(self)
|
||||
self._refresh()
|
||||
|
||||
def _on_toyota_long(self, checked: bool):
|
||||
if checked and ui_state.params.get_bool("AlphaLongitudinalEnabled"):
|
||||
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def _refresh(self):
|
||||
self._vehicle_btn.set_value(self._vehicle_status())
|
||||
|
||||
brand = self._get_current_brand()
|
||||
offroad = ui_state.is_offroad()
|
||||
uses_hca_status_toggle = self._uses_vw_hca_status_toggle()
|
||||
is_mqb = self._is_vw_mqb()
|
||||
supports_lateral_when_faulted = self._supports_vw_lateral_when_faulted()
|
||||
is_curvature_car = self._is_vw_curvature_car()
|
||||
visible = set(self._brand_widgets.get(brand, []))
|
||||
for w in self._all_brand_widgets:
|
||||
show = w in visible
|
||||
if w is self._vw_pq_hca:
|
||||
show = show and uses_hca_status_toggle
|
||||
elif w in (self._vw_mqb_acc_resume, self._vw_mqb_steering_lockout):
|
||||
show = show and is_mqb
|
||||
elif w is self._vw_lateral:
|
||||
show = show and supports_lateral_when_faulted
|
||||
elif w in (self._vw_curvature_controller, self._vw_smooth_steer):
|
||||
show = show and is_curvature_car
|
||||
w.set_visible(show)
|
||||
if show:
|
||||
w.refresh()
|
||||
for w in (self._toyota_long, self._subaru_snag, self._subaru_manual, self._tesla_vtb, self._tesla_fsd_visualization):
|
||||
w.set_enabled(offroad)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._vehicle_btn.set_value(self._vehicle_status())
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._refresh()
|
||||
26
iqpilot/selfdrive/ui/mici/layouts/settings/visuals.py
Normal file
26
iqpilot/selfdrive/ui/mici/layouts/settings/visuals.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigParamControl
|
||||
from iqpilot.system.ui.widgets.scroller import NavScroller
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class VisualsLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._blind_spot = BigParamControl(tr("Blind Spot Warnings"), "IQBlindSpotAlerts")
|
||||
self._steering_arc = BigParamControl(tr("Steering Effort Arc"), "IQSteerEffortArc")
|
||||
self._road_name = BigParamControl(tr("Road Name"), "IQRoadNameOverlay")
|
||||
self._turn_signals = BigParamControl(tr("Turn Signals"), "IQBlinkerIndicators")
|
||||
self._accel_bar = BigParamControl(tr("Acceleration Bar"), "IQAccelMeter")
|
||||
|
||||
self._toggles = [self._blind_spot, self._steering_arc, self._road_name,
|
||||
self._turn_signals, self._accel_bar]
|
||||
self._scroller.add_widgets(self._toggles)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for w in self._toggles:
|
||||
w.refresh()
|
||||
12
iqpilot/selfdrive/ui/mici/onroad/__init__.py
Normal file
12
iqpilot/selfdrive/ui/mici/onroad/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import pyray as rl
|
||||
|
||||
SIDE_PANEL_WIDTH = 60
|
||||
|
||||
|
||||
def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
|
||||
h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
|
||||
h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
|
||||
dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta
|
||||
return rl.color_from_hsv((h0 + f * dh) % 360,
|
||||
s0 + f * (s1 - s0),
|
||||
v0 + f * (v1 - v0))
|
||||
369
iqpilot/selfdrive/ui/mici/onroad/alert_renderer.py
Normal file
369
iqpilot/selfdrive/ui/mici/onroad/alert_renderer.py
Normal file
@@ -0,0 +1,369 @@
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import NamedTuple
|
||||
import pyray as rl
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from iqpilot.cereal import messaging, log, car
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from iqpilot.system.hardware import TICI
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
AlertStatus = log.SelfdriveState.AlertStatus
|
||||
|
||||
ALERT_MARGIN = 18
|
||||
|
||||
ALERT_FONT_SMALL = 66 - 50
|
||||
ALERT_FONT_BIG = 88 - 40
|
||||
|
||||
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
|
||||
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
|
||||
|
||||
# Constants
|
||||
ALERT_COLORS = {
|
||||
AlertStatus.normal: rl.Color(0, 0, 0, 255),
|
||||
AlertStatus.userPrompt: rl.Color(255, 115, 0, 255),
|
||||
AlertStatus.critical: rl.Color(255, 0, 21, 255),
|
||||
}
|
||||
|
||||
TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class IconSide(StrEnum):
|
||||
left = 'left'
|
||||
right = 'right'
|
||||
|
||||
|
||||
class IconLayout(NamedTuple):
|
||||
texture: rl.Texture
|
||||
side: IconSide
|
||||
margin_x: int
|
||||
margin_y: int
|
||||
|
||||
|
||||
class AlertLayout(NamedTuple):
|
||||
text_rect: rl.Rectangle
|
||||
icon: IconLayout | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Alert:
|
||||
text1: str = ""
|
||||
text2: str = ""
|
||||
size: int = 0
|
||||
status: int = 0
|
||||
visual_alert: int = car.CarControl.HUDControl.VisualAlert.none
|
||||
alert_type: str = ""
|
||||
|
||||
|
||||
# Pre-defined alert instances
|
||||
ALERT_STARTUP_PENDING = Alert(
|
||||
text1="IQ.Pilot Unavailable",
|
||||
text2="Waiting to start",
|
||||
size=AlertSize.mid,
|
||||
status=AlertStatus.normal,
|
||||
)
|
||||
|
||||
ALERT_CRITICAL_TIMEOUT = Alert(
|
||||
text1="TAKE CONTROL IMMEDIATELY",
|
||||
text2="System Unresponsive",
|
||||
size=AlertSize.full,
|
||||
status=AlertStatus.critical,
|
||||
)
|
||||
|
||||
ALERT_CRITICAL_REBOOT = Alert(
|
||||
text1="System Unresponsive",
|
||||
text2="Reboot Device",
|
||||
size=AlertSize.full,
|
||||
status=AlertStatus.critical,
|
||||
)
|
||||
|
||||
|
||||
class AlertRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86,
|
||||
letter_spacing=-0.02)
|
||||
self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86,
|
||||
letter_spacing=0.025)
|
||||
|
||||
self._prev_alert: Alert | None = None
|
||||
self._text_gen_time = 0
|
||||
self._alert_text2_gen = ''
|
||||
|
||||
# animation filters
|
||||
# TODO: use 0.1 but with proper alert height calculation
|
||||
self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._turn_signal_timer = 0.0
|
||||
self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
|
||||
self._last_icon_side: IconSide | None = None
|
||||
|
||||
self._load_icons()
|
||||
|
||||
def _load_icons(self):
|
||||
self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96)
|
||||
self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 104, 96)
|
||||
self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150)
|
||||
self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 134, 150)
|
||||
|
||||
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
|
||||
"""Generate the current alert based on selfdrive state."""
|
||||
ss = sm['selfdriveState']
|
||||
|
||||
# Check if selfdriveState messages have stopped arriving
|
||||
if not sm.updated['selfdriveState']:
|
||||
recv_frame = sm.recv_frame['selfdriveState']
|
||||
time_since_onroad = time.monotonic() - ui_state.started_time
|
||||
|
||||
# 1. Never received selfdriveState since going onroad
|
||||
waiting_for_startup = recv_frame < ui_state.started_frame
|
||||
if waiting_for_startup and time_since_onroad > 5:
|
||||
return ALERT_STARTUP_PENDING
|
||||
|
||||
# 2. Lost communication with selfdriveState after receiving it
|
||||
if TICI and not waiting_for_startup:
|
||||
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
|
||||
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
|
||||
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
|
||||
return ALERT_CRITICAL_TIMEOUT
|
||||
return ALERT_CRITICAL_REBOOT
|
||||
|
||||
# No alert if size is none
|
||||
if ss.alertSize == 0:
|
||||
return None
|
||||
|
||||
event_name = ss.alertType.split('/')[0] if ss.alertType else ''
|
||||
if event_name in {'selfdrivedLagging', 'commIssue', 'commIssueAvgFreq'}:
|
||||
return None
|
||||
|
||||
# Return current alert
|
||||
ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
|
||||
visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
|
||||
self._prev_alert = ret
|
||||
return ret
|
||||
|
||||
def will_render(self) -> tuple[Alert | None, bool]:
|
||||
alert = self.get_alert(ui_state.sm)
|
||||
return alert or self._prev_alert, alert is None
|
||||
|
||||
def _icon_helper(self, alert: Alert) -> AlertLayout:
|
||||
icon_side = None
|
||||
txt_icon = None
|
||||
icon_margin_x = 20
|
||||
icon_margin_y = 18
|
||||
|
||||
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
|
||||
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
|
||||
|
||||
if event_name == 'preLaneChangeLeft':
|
||||
icon_side = IconSide.left
|
||||
txt_icon = self._txt_turn_signal_left
|
||||
icon_margin_x = 2
|
||||
icon_margin_y = 5
|
||||
|
||||
elif event_name == 'preLaneChangeRight':
|
||||
icon_side = IconSide.right
|
||||
txt_icon = self._txt_turn_signal_right
|
||||
icon_margin_x = 2
|
||||
icon_margin_y = 5
|
||||
|
||||
elif event_name == 'laneChange':
|
||||
icon_side = self._last_icon_side
|
||||
txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right
|
||||
icon_margin_x = 2
|
||||
icon_margin_y = 5
|
||||
|
||||
elif event_name == 'laneChangeBlocked':
|
||||
CS = ui_state.sm['carState']
|
||||
if CS.leftBlinker:
|
||||
icon_side = IconSide.left
|
||||
elif CS.rightBlinker:
|
||||
icon_side = IconSide.right
|
||||
else:
|
||||
icon_side = self._last_icon_side
|
||||
txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right
|
||||
icon_margin_x = 8
|
||||
icon_margin_y = 0
|
||||
|
||||
else:
|
||||
self._turn_signal_timer = 0.0
|
||||
|
||||
self._last_icon_side = icon_side
|
||||
|
||||
# create text rect based on icon presence
|
||||
text_x = self._rect.x + ALERT_MARGIN
|
||||
text_width = self._rect.width - ALERT_MARGIN
|
||||
if icon_side == 'left':
|
||||
text_x = self._rect.x + self._txt_turn_signal_right.width
|
||||
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
|
||||
elif icon_side == 'right':
|
||||
text_x = self._rect.x + ALERT_MARGIN
|
||||
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
|
||||
|
||||
text_rect = rl.Rectangle(
|
||||
text_x,
|
||||
self._alert_y_filter.x,
|
||||
text_width,
|
||||
self._rect.height,
|
||||
)
|
||||
icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None
|
||||
return AlertLayout(text_rect, icon_layout)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
alert = self.get_alert(ui_state.sm)
|
||||
|
||||
# Animate fade and slide in/out
|
||||
self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y)
|
||||
self._alpha_filter.update(0 if alert is None else 1)
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.onroad_brightness_handle_alerts(ui_state.started, alert)
|
||||
|
||||
if alert is None:
|
||||
# If still animating out, keep the previous alert
|
||||
if self._alpha_filter.x > 0.01 and self._prev_alert is not None:
|
||||
alert = self._prev_alert
|
||||
else:
|
||||
self._prev_alert = None
|
||||
return False
|
||||
|
||||
self._draw_background(alert)
|
||||
|
||||
alert_layout = self._icon_helper(alert)
|
||||
self._draw_text(alert, alert_layout)
|
||||
self._draw_icons(alert_layout)
|
||||
|
||||
return True
|
||||
|
||||
def _draw_icons(self, alert_layout: AlertLayout) -> None:
|
||||
if alert_layout.icon is None:
|
||||
return
|
||||
|
||||
# re-derive dt every frame: this filter is constructed at offroad startup (60fps) but onroad
|
||||
# runs at a lower target_fps, and a dt frozen from construction decays far too slowly against
|
||||
# the real frame cadence, so the icon never dims and reads as static-on instead of blinking.
|
||||
self._turn_signal_alpha_filter.dt = 1 / gui_app.target_fps
|
||||
self._turn_signal_alpha_filter.update_alpha(0.3)
|
||||
if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD:
|
||||
self._turn_signal_timer = time.monotonic()
|
||||
self._turn_signal_alpha_filter.x = 255 * 2
|
||||
else:
|
||||
self._turn_signal_alpha_filter.update(255 * 0.2)
|
||||
|
||||
if alert_layout.icon.side == 'left':
|
||||
pos_x = int(self._rect.x + alert_layout.icon.margin_x)
|
||||
else:
|
||||
pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width)
|
||||
|
||||
if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right):
|
||||
icon_alpha = 255
|
||||
else:
|
||||
icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
|
||||
|
||||
rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y),
|
||||
rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
|
||||
|
||||
def _draw_background(self, alert: Alert) -> None:
|
||||
# draw top gradient for alert text at top
|
||||
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
|
||||
color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
|
||||
translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
|
||||
|
||||
small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
|
||||
medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height
|
||||
|
||||
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
|
||||
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
|
||||
|
||||
if event_name == 'preLaneChangeLeft':
|
||||
bg_height = small_alert_height
|
||||
elif event_name == 'preLaneChangeRight':
|
||||
bg_height = small_alert_height
|
||||
elif event_name == 'laneChange':
|
||||
bg_height = small_alert_height
|
||||
elif event_name == 'laneChangeBlocked':
|
||||
bg_height = medium_alert_height
|
||||
else:
|
||||
bg_height = int(self._rect.height)
|
||||
|
||||
solid_height = round(bg_height * 0.2)
|
||||
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color)
|
||||
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width),
|
||||
int(bg_height - solid_height),
|
||||
color, translucent_color)
|
||||
|
||||
def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None:
|
||||
icon_side = alert_layout.icon.side if alert_layout.icon is not None else None
|
||||
|
||||
# TODO: hack
|
||||
alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n')
|
||||
can_draw_second_line = False
|
||||
# TODO: there should be a common way to determine font size based on text length to maximize rect
|
||||
if len(alert_text1) <= 12:
|
||||
can_draw_second_line = True
|
||||
font_size = 92 - 10
|
||||
elif len(alert_text1) <= 16:
|
||||
can_draw_second_line = True
|
||||
font_size = 70
|
||||
else:
|
||||
font_size = 64 - 10
|
||||
|
||||
if icon_side is not None:
|
||||
font_size -= 10
|
||||
|
||||
color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
|
||||
|
||||
text1_y_offset = 11 if font_size >= 70 else 4
|
||||
text_rect1 = rl.Rectangle(
|
||||
alert_layout.text_rect.x,
|
||||
alert_layout.text_rect.y - text1_y_offset,
|
||||
alert_layout.text_rect.width,
|
||||
alert_layout.text_rect.height,
|
||||
)
|
||||
self._alert_text1_label.set_text(alert_text1)
|
||||
self._alert_text1_label.set_text_color(color)
|
||||
self._alert_text1_label.set_font_size(font_size)
|
||||
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
|
||||
self._alert_text1_label.render(text_rect1)
|
||||
|
||||
alert_text2 = alert.text2.lower()
|
||||
|
||||
# randomize chars and length for testing
|
||||
if DEBUG:
|
||||
if time.monotonic() - self._text_gen_time > 0.5:
|
||||
self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
|
||||
self._text_gen_time = time.monotonic()
|
||||
alert_text2 = self._alert_text2_gen or alert_text2
|
||||
|
||||
if can_draw_second_line and alert_text2:
|
||||
last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
|
||||
last_line_h -= 4
|
||||
if len(alert_text2) > 18:
|
||||
small_font_size = 36
|
||||
elif len(alert_text2) > 24:
|
||||
small_font_size = 32
|
||||
else:
|
||||
small_font_size = 40
|
||||
text_rect2 = rl.Rectangle(
|
||||
alert_layout.text_rect.x,
|
||||
last_line_h,
|
||||
alert_layout.text_rect.width,
|
||||
alert_layout.text_rect.height - last_line_h
|
||||
)
|
||||
color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
|
||||
|
||||
self._alert_text2_label.set_text(alert_text2)
|
||||
self._alert_text2_label.set_text_color(color)
|
||||
self._alert_text2_label.set_font_size(small_font_size)
|
||||
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
|
||||
self._alert_text2_label.render(text_rect2)
|
||||
493
iqpilot/selfdrive/ui/mici/onroad/augmented_road_view.py
Normal file
493
iqpilot/selfdrive/ui/mici/onroad/augmented_road_view.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import messaging, car, log
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
||||
from iqpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
|
||||
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from iqpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
|
||||
from iqpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
|
||||
from iqpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
|
||||
from iqpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.common.issue_debug import log_issue_limited
|
||||
from iqpilot.common.filter_simple import BounceFilter
|
||||
from iqpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
|
||||
from iqpilot.common.transformations.orientation import rot_from_euler
|
||||
from iqpilot.selfdrive.locationd.calibration_helpers import get_calibrated_rpy
|
||||
from enum import IntEnum
|
||||
from iqpilot.ui.onroad.augmented_road_view import BORDER_COLORS_IQ
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
from iqpilot.ui.mici.onroad.hud_renderer import IQMiciHudRenderer as HudRenderer
|
||||
from iqpilot.ui.mici.onroad.road_label import RoadNameRendererMici
|
||||
from iqpilot.selfdrive.ui.ui_state import OnroadTimerStatus
|
||||
|
||||
OpState = log.SelfdriveState.OpenpilotState
|
||||
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
|
||||
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
|
||||
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
|
||||
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
|
||||
|
||||
|
||||
class BookmarkState(IntEnum):
|
||||
HIDDEN = 0
|
||||
DRAGGING = 1
|
||||
TRIGGERED = 2
|
||||
|
||||
WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph)
|
||||
ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph)
|
||||
|
||||
CAM_Y_OFFSET = 20
|
||||
MICI_BORDER_COLOR = rl.Color(0x0C, 0x94, 0x96, 0xFF)
|
||||
MICI_BORDER_THICKNESS = 50
|
||||
MICI_BORDER_ROUNDNESS = 0.2 * 1.02
|
||||
MICI_BORDER_BOTTOM_ONLY_HEIGHT = 95
|
||||
MICI_EXPERIMENTAL_ICON_SIZE = 28
|
||||
MICI_EXPERIMENTAL_ICON_SPACING = 8
|
||||
|
||||
|
||||
class BookmarkIcon(Widget):
|
||||
PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
|
||||
FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
|
||||
HIDDEN_OFFSET = -50 # How far offscreen when hidden
|
||||
|
||||
def __init__(self, bookmark_callback):
|
||||
super().__init__()
|
||||
self._bookmark_callback = bookmark_callback
|
||||
self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
|
||||
self._icon_fill = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180)
|
||||
self._active_icon = self._icon
|
||||
self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# State
|
||||
self._interacting = False
|
||||
self._state = BookmarkState.HIDDEN
|
||||
self._swipe_start_x = 0.0
|
||||
self._swipe_current_x = 0.0
|
||||
self._is_swiping = False
|
||||
self._is_swiping_left: bool = False
|
||||
self._triggered_time: float = 0.0
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
"""Check if currently swiping left (for scroller to disable)."""
|
||||
return self._is_swiping_left
|
||||
|
||||
def interacting(self):
|
||||
interacting, self._interacting = self._interacting, False
|
||||
return interacting
|
||||
|
||||
def _update_state(self):
|
||||
if self._state == BookmarkState.DRAGGING:
|
||||
# Allow pulling past activated position with rubber band effect
|
||||
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
||||
swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
|
||||
self._offset_filter.update(swipe_offset)
|
||||
|
||||
elif self._state == BookmarkState.TRIGGERED:
|
||||
# Continue animating to fully visible
|
||||
self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
|
||||
# Stay in TRIGGERED state for 1 second
|
||||
if rl.get_time() - self._triggered_time >= 1.5:
|
||||
self._state = BookmarkState.HIDDEN
|
||||
|
||||
elif self._state == BookmarkState.HIDDEN:
|
||||
self._offset_filter.update(self.HIDDEN_OFFSET)
|
||||
|
||||
if self._offset_filter.x < 1e-3:
|
||||
self._interacting = False
|
||||
self._active_icon = self._icon
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
if not ui_state.started:
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# Store relative position within widget
|
||||
self._swipe_start_x = mouse_event.pos.x
|
||||
self._swipe_current_x = mouse_event.pos.x
|
||||
self._is_swiping = True
|
||||
self._is_swiping_left = False
|
||||
self._state = BookmarkState.DRAGGING
|
||||
self._active_icon = self._icon
|
||||
|
||||
elif mouse_event.left_down and self._is_swiping:
|
||||
self._swipe_current_x = mouse_event.pos.x
|
||||
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
||||
self._is_swiping_left = swipe_offset > 0
|
||||
if self._is_swiping_left:
|
||||
self._interacting = True
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._is_swiping:
|
||||
swipe_distance = self._swipe_start_x - self._swipe_current_x
|
||||
|
||||
# If peeking past threshold, transition to animating to fully visible and bookmark
|
||||
if swipe_distance > self.PEEK_THRESHOLD:
|
||||
self._state = BookmarkState.TRIGGERED
|
||||
self._triggered_time = rl.get_time()
|
||||
self._active_icon = self._icon_fill
|
||||
self._bookmark_callback()
|
||||
else:
|
||||
# Otherwise, transition back to hidden
|
||||
self._state = BookmarkState.HIDDEN
|
||||
|
||||
# Reset swipe state
|
||||
self._is_swiping = False
|
||||
self._is_swiping_left = False
|
||||
|
||||
def _render(self, _):
|
||||
"""Render the bookmark icon."""
|
||||
if self._offset_filter.x > 0:
|
||||
icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x)
|
||||
icon_y = self.rect.y + (self.rect.height - self._active_icon.height) / 2 # Vertically centered
|
||||
rl.draw_texture(self._active_icon, int(icon_x), int(icon_y), rl.WHITE)
|
||||
|
||||
|
||||
class AugmentedRoadView(CameraView):
|
||||
def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
|
||||
super().__init__("camerad", stream_type)
|
||||
self._bookmark_callback = bookmark_callback
|
||||
self._set_placeholder_color(rl.BLACK)
|
||||
|
||||
self.device_camera: DeviceCameraConfig | None = None
|
||||
self.view_from_calib = view_frame_from_device_frame.copy()
|
||||
self.view_from_wide_calib = view_frame_from_device_frame.copy()
|
||||
|
||||
self._matrix_cache_key: tuple | None = None
|
||||
self._cached_matrix: np.ndarray | None = None
|
||||
self._content_rect = rl.Rectangle()
|
||||
self._last_click_time = 0.0
|
||||
|
||||
# Bookmark icon with swipe gesture
|
||||
self._bookmark_icon = BookmarkIcon(bookmark_callback)
|
||||
self._params = Params()
|
||||
self._iq_dynamic_mode: bool = False
|
||||
self._iq_dynamic_refresh: int = 0
|
||||
|
||||
self._model_renderer = ModelRenderer()
|
||||
self._hud_renderer = HudRenderer()
|
||||
self._alert_renderer = AlertRenderer()
|
||||
self._driver_state_renderer = DriverStateRenderer()
|
||||
self._confidence_ball = ConfidenceBall()
|
||||
self._road_name = RoadNameRendererMici() if gui_app.iqpilot_ui() else None
|
||||
self._experimental_txt = gui_app.texture("icons_mici/experimental_mode_mici.png",
|
||||
MICI_EXPERIMENTAL_ICON_SIZE,
|
||||
MICI_EXPERIMENTAL_ICON_SIZE)
|
||||
self._iqdynamic_txt = gui_app.texture("icons_mici/iqdynamic_mode_mici.png",
|
||||
MICI_EXPERIMENTAL_ICON_SIZE,
|
||||
MICI_EXPERIMENTAL_ICON_SIZE)
|
||||
self._iqstandard_txt = gui_app.texture("icons_mici/iqstandard_mode_mici.png",
|
||||
MICI_EXPERIMENTAL_ICON_SIZE,
|
||||
MICI_EXPERIMENTAL_ICON_SIZE)
|
||||
self._offroad_label = UnifiedLabel(tr("start the car to\nuse IQ.Pilot"), 54, FontWeight.DISPLAY,
|
||||
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
# debug
|
||||
self._pm = messaging.PubMaster(['uiDebug'])
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
"""Check if currently swiping left (for scroller to disable)."""
|
||||
return self._bookmark_icon.is_swiping_left()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
# IQDynamicMode only changes from the settings UI; don't pay a Params syscall every frame on
|
||||
# the onroad hot path. Refresh ~1s (60 frames), matching model_renderer's throttled reads.
|
||||
self._iq_dynamic_refresh -= 1
|
||||
if self._iq_dynamic_refresh <= 0:
|
||||
self._iq_dynamic_refresh = 60
|
||||
self._iq_dynamic_mode = self._params.get_bool("IQDynamicMode")
|
||||
|
||||
# update offroad label
|
||||
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
||||
self._offroad_label.set_text(tr("system booting"))
|
||||
else:
|
||||
self._offroad_label.set_text(tr("start the car to\nuse IQ.Pilot"))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
# Don't trigger click callback if bookmark was triggered
|
||||
if not self._bookmark_icon.interacting():
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
def _render(self, _):
|
||||
start_draw = time.monotonic()
|
||||
self._switch_stream_if_needed(ui_state.sm)
|
||||
|
||||
# Update calibration before rendering
|
||||
self._update_calibration()
|
||||
|
||||
# Create inner content area with border padding
|
||||
self._content_rect = rl.Rectangle(
|
||||
self.rect.x,
|
||||
self.rect.y,
|
||||
self.rect.width - SIDE_PANEL_WIDTH,
|
||||
self.rect.height,
|
||||
)
|
||||
|
||||
# Enable scissor mode to clip all rendering within content rectangle boundaries
|
||||
# This creates a rendering viewport that prevents graphics from drawing outside the border
|
||||
rl.begin_scissor_mode(
|
||||
int(self._content_rect.x),
|
||||
int(self._content_rect.y),
|
||||
int(self._content_rect.width),
|
||||
int(self._content_rect.height)
|
||||
)
|
||||
|
||||
# Render the base camera view
|
||||
super()._render(self._content_rect)
|
||||
|
||||
# Draw all UI overlays
|
||||
self._model_renderer.render(self._content_rect)
|
||||
|
||||
alert_to_render, not_animating_out = self._alert_renderer.will_render()
|
||||
|
||||
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
|
||||
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and ui_state.is_onroad() and
|
||||
(ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
|
||||
self._driver_state_renderer.set_should_draw(should_draw_dmoji)
|
||||
self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
|
||||
self._driver_state_renderer.render()
|
||||
|
||||
self._hud_renderer.set_can_draw_top_icons(alert_to_render is None)
|
||||
self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and
|
||||
alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
|
||||
# TODO: have alert renderer draw offroad mici label below
|
||||
if ui_state.started:
|
||||
self._alert_renderer.render(self._content_rect)
|
||||
self._hud_renderer.render(self._content_rect)
|
||||
if self._road_name is not None and alert_to_render is None:
|
||||
self._road_name.update()
|
||||
self._road_name.render(self._content_rect)
|
||||
# don't draw the experimental/IQ.Dynamic icon over alert text (it falls back to the
|
||||
# top-left alert anchor when the DMoji is hidden while disengaged)
|
||||
if alert_to_render is None:
|
||||
self._draw_experimental_icon(should_draw_dmoji)
|
||||
|
||||
# End clipping region
|
||||
rl.end_scissor_mode()
|
||||
|
||||
self._draw_border()
|
||||
|
||||
# Custom UI extension point - add custom overlays here
|
||||
# Use self._content_rect for positioning within camera bounds
|
||||
self._confidence_ball.render(self.rect)
|
||||
|
||||
self._bookmark_icon.render(self.rect)
|
||||
|
||||
draw_time_ms = (time.monotonic() - start_draw) * 1000
|
||||
if draw_time_ms > 40.0:
|
||||
log_issue_limited(
|
||||
"ui_draw_slow_mici",
|
||||
"ui",
|
||||
f"mici onroad draw slow drawTimeMillis={draw_time_ms:.2f} navActive={getattr(ui_state.sm['iqNavState'], 'active', False)}",
|
||||
interval_sec=1.0,
|
||||
)
|
||||
msg = messaging.new_message('uiDebug')
|
||||
msg.uiDebug.drawTimeMillis = draw_time_ms
|
||||
self._pm.send('uiDebug', msg)
|
||||
|
||||
# Draw darkened background and text if not onroad
|
||||
if not ui_state.started:
|
||||
rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175))
|
||||
self._offroad_label.render(self._content_rect)
|
||||
|
||||
def _draw_experimental_icon(self, draw_below_driver_state: bool) -> None:
|
||||
if not ui_state.started:
|
||||
return
|
||||
|
||||
if not ui_state.sm['carParams'].openpilotLongitudinalControl:
|
||||
return
|
||||
|
||||
if ui_state.sm['selfdriveState'].experimentalMode:
|
||||
icon = self._iqdynamic_txt if self._iq_dynamic_mode else self._experimental_txt
|
||||
else:
|
||||
icon = self._iqstandard_txt
|
||||
|
||||
if draw_below_driver_state:
|
||||
pos_x = self._rect.x + 16 + (self._driver_state_renderer.rect.width - icon.width) / 2
|
||||
pos_y = self._rect.y + 10 + self._driver_state_renderer.rect.height + MICI_EXPERIMENTAL_ICON_SPACING
|
||||
else:
|
||||
pos_x = self._rect.x + 18
|
||||
pos_y = self._rect.y + 18
|
||||
|
||||
rl.draw_texture(icon, int(pos_x), int(pos_y), rl.WHITE)
|
||||
|
||||
def _draw_border(self):
|
||||
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, rl.BLACK)
|
||||
|
||||
aol = ui_state.sm["iqState"].aol
|
||||
ss_enabled = ui_state.sm["selfdriveState"].enabled
|
||||
if aol.active and ss_enabled:
|
||||
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, MICI_BORDER_COLOR)
|
||||
self._reblacken_border_edges()
|
||||
elif aol.active and not ss_enabled:
|
||||
clip_y = int(self._content_rect.y + self._content_rect.height - MICI_BORDER_BOTTOM_ONLY_HEIGHT)
|
||||
rl.begin_scissor_mode(int(self._content_rect.x), clip_y,
|
||||
int(self._content_rect.width), MICI_BORDER_BOTTOM_ONLY_HEIGHT)
|
||||
border_color = BORDER_COLORS_IQ[UIStatus.LAT_ONLY] if ui_state.status != UIStatus.OVERRIDE else rl.Color(0x89, 0x92, 0x8D, 0xFF)
|
||||
rl.draw_rectangle_rounded_lines_ex(self._content_rect, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, border_color)
|
||||
rl.end_scissor_mode()
|
||||
self._reblacken_border_edges()
|
||||
|
||||
def _reblacken_border_edges(self):
|
||||
cr = self._content_rect
|
||||
r = int(MICI_BORDER_ROUNDNESS * min(cr.width, cr.height) / 2) + MICI_BORDER_THICKNESS + 6
|
||||
regions = (
|
||||
(cr.x, cr.y, r, r), # top-left corner
|
||||
(cr.x, cr.y + cr.height - r, r, r), # bottom-left corner
|
||||
(cr.x + cr.width - r, cr.y, r + SIDE_PANEL_WIDTH, cr.height), # right edge + both right corners
|
||||
)
|
||||
for rx, ry, rw, rh in regions:
|
||||
rl.begin_scissor_mode(int(rx), int(ry), int(rw), int(rh))
|
||||
rl.draw_rectangle_rounded_lines_ex(cr, MICI_BORDER_ROUNDNESS, 10, MICI_BORDER_THICKNESS, rl.BLACK)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _switch_stream_if_needed(self, sm):
|
||||
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
|
||||
v_ego = sm['carState'].vEgo
|
||||
if v_ego < WIDE_CAM_MAX_SPEED:
|
||||
target = WIDE_CAM
|
||||
elif v_ego > ROAD_CAM_MIN_SPEED:
|
||||
target = ROAD_CAM
|
||||
else:
|
||||
# Hysteresis zone - keep current stream
|
||||
target = self.stream_type
|
||||
else:
|
||||
target = ROAD_CAM
|
||||
|
||||
if self.stream_type != target:
|
||||
self.switch_stream(target)
|
||||
|
||||
def _update_calibration(self):
|
||||
# Update device camera if not already set
|
||||
sm = ui_state.sm
|
||||
if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
||||
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
|
||||
if not sm.seen["extrinsicsCalibration"]:
|
||||
return
|
||||
|
||||
calib = sm['extrinsicsCalibration']
|
||||
calib_rpy = get_calibrated_rpy(calib)
|
||||
if calib_rpy is None:
|
||||
return
|
||||
|
||||
# Update view_from_calib matrix
|
||||
prev_view_from_calib = self.view_from_calib.copy()
|
||||
prev_view_from_wide_calib = self.view_from_wide_calib.copy()
|
||||
device_from_calib = rot_from_euler(calib_rpy)
|
||||
self.view_from_calib = view_frame_from_device_frame @ device_from_calib
|
||||
|
||||
# Update wide calibration if available
|
||||
if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
|
||||
wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
|
||||
self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
|
||||
|
||||
if (not np.allclose(self.view_from_calib, prev_view_from_calib) or
|
||||
not np.allclose(self.view_from_wide_calib, prev_view_from_wide_calib)):
|
||||
self._matrix_cache_key = (0, 0, 0, self.stream_type, 0.0)
|
||||
self._cached_matrix = None
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
# Early-return the cached matrix when nothing that affects it changed. The key deliberately
|
||||
# excludes rect.x/y — those are applied as a draw-time offset in ModelRenderer (below), so the
|
||||
# cache stays hot while the onroad view translates during a scroll/transition (stock PR #37948).
|
||||
cache_key = (
|
||||
ui_state.sm.recv_frame['extrinsicsCalibration'],
|
||||
int(self._content_rect.width),
|
||||
int(self._content_rect.height),
|
||||
self.stream_type,
|
||||
round(ui_state.sm['carState'].vEgo, 1),
|
||||
)
|
||||
if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
|
||||
return self._cached_matrix
|
||||
|
||||
# Get camera configuration
|
||||
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
|
||||
is_wide_camera = self.stream_type == WIDE_CAM
|
||||
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
|
||||
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
|
||||
if is_wide_camera:
|
||||
zoom = 0.7 * 1.5
|
||||
else:
|
||||
zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
|
||||
|
||||
# Calculate transforms for vanishing point
|
||||
inf_point = np.array([1000.0, 0.0, 0.0])
|
||||
calib_transform = intrinsic @ calibration
|
||||
kep = calib_transform @ inf_point
|
||||
|
||||
# Calculate center points and dimensions (rect.x/y are NOT used here — applied at draw time)
|
||||
w, h = self._content_rect.width, self._content_rect.height
|
||||
cx, cy = intrinsic[0, 2], intrinsic[1, 2]
|
||||
|
||||
# Calculate max allowed offsets with margins
|
||||
margin = 5
|
||||
max_x_offset = cx * zoom - w / 2 - margin
|
||||
max_y_offset = cy * zoom - h / 2 - margin
|
||||
|
||||
# Calculate and clamp offsets to prevent out-of-bounds issues
|
||||
try:
|
||||
if abs(kep[2]) > 1e-6:
|
||||
x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
|
||||
y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset)
|
||||
else:
|
||||
x_offset, y_offset = 0, 0
|
||||
except (ZeroDivisionError, OverflowError):
|
||||
x_offset, y_offset = 0, 0
|
||||
|
||||
# Cache the computed transformation matrix to avoid recalculations
|
||||
self._matrix_cache_key = cache_key
|
||||
self._cached_matrix = np.array([
|
||||
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
|
||||
[0, zoom * 2 * cy / h, -y_offset / h * 2],
|
||||
[0, 0, 1.0]
|
||||
])
|
||||
|
||||
# Built WITHOUT rect.x/y so the matrix (and the model_renderer projection it drives) stays
|
||||
# cache-stable while the view slides; ModelRenderer adds (rect.x, rect.y) as a draw-time offset.
|
||||
video_transform = np.array([
|
||||
[zoom, 0.0, (w / 2 - x_offset) - (cx * zoom)],
|
||||
[0.0, zoom, (h / 2 - y_offset) - (cy * zoom)],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
self._model_renderer.set_transform(video_transform @ calib_transform)
|
||||
|
||||
return self._cached_matrix
|
||||
|
||||
def show_event(self):
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
|
||||
|
||||
def hide_event(self):
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("OnRoad Camera View")
|
||||
road_camera_view = AugmentedRoadView(ROAD_CAM)
|
||||
print("***press space to switch camera view***")
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
|
||||
if WIDE_CAM in road_camera_view.available_streams:
|
||||
stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
|
||||
road_camera_view.switch_stream(stream)
|
||||
road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
finally:
|
||||
road_camera_view.close()
|
||||
418
iqpilot/selfdrive/ui/mici/onroad/cameraview.py
Normal file
418
iqpilot/selfdrive/ui/mici/onroad/cameraview.py
Normal file
@@ -0,0 +1,418 @@
|
||||
import platform
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from msgq.visionipc import VisionIpcClient, VisionBuf
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.hardware import EGL_DMA_BUF_SUPPORTED
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
|
||||
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
|
||||
|
||||
VERSION = """
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
"""
|
||||
if platform.system() == "Darwin":
|
||||
VERSION = """
|
||||
#version 330 core
|
||||
"""
|
||||
|
||||
|
||||
VERTEX_SHADER = VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
in vec3 vertexNormal;
|
||||
in vec4 vertexColor;
|
||||
uniform mat4 mvp;
|
||||
out vec2 fragTexCoord;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
fragColor = vertexColor;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
# Choose fragment shader based on platform capabilities
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
FRAME_FRAGMENT_SHADER = """
|
||||
#version 300 es
|
||||
#extension GL_OES_EGL_image_external_essl3 : enable
|
||||
precision mediump float;
|
||||
in vec2 fragTexCoord;
|
||||
uniform samplerExternalOES texture0;
|
||||
out vec4 fragColor;
|
||||
uniform int engaged;
|
||||
uniform int enhance_driver;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture(texture0, fragTexCoord);
|
||||
if (engaged == 1) {
|
||||
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma
|
||||
color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation
|
||||
color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
|
||||
color.rgb = pow(color.rgb, vec3(1.0/1.28));
|
||||
fragColor = vec4(color.rgb, color.a);
|
||||
} else {
|
||||
color.rgb *= 0.85; // 85% opacity
|
||||
}
|
||||
if (enhance_driver == 1) {
|
||||
float brightness = 1.1;
|
||||
color.rgb = color.rgb + 0.15;
|
||||
color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
|
||||
color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
|
||||
color.rgb = pow(color.rgb, vec3(0.8));
|
||||
}
|
||||
fragColor = vec4(color.rgb, color.a);
|
||||
}
|
||||
"""
|
||||
else:
|
||||
FRAME_FRAGMENT_SHADER = VERSION + """
|
||||
in vec2 fragTexCoord;
|
||||
uniform sampler2D texture0;
|
||||
uniform sampler2D texture1;
|
||||
out vec4 fragColor;
|
||||
uniform int engaged;
|
||||
uniform int enhance_driver;
|
||||
|
||||
void main() {
|
||||
float y = texture(texture0, fragTexCoord).r;
|
||||
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
|
||||
vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
|
||||
if (engaged == 1) {
|
||||
float gray = dot(rgb, vec3(0.299, 0.587, 0.114));
|
||||
rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation
|
||||
rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
|
||||
} else {
|
||||
rgb *= 0.85; // 85% opacity
|
||||
}
|
||||
// TODO: the images out of camerad need some more correction and
|
||||
// the ui should apply a gamma curve for the device display
|
||||
if (enhance_driver == 1) {
|
||||
float brightness = 1.1;
|
||||
rgb = rgb + 0.15;
|
||||
rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
|
||||
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
|
||||
rgb = pow(rgb, vec3(0.8));
|
||||
}
|
||||
fragColor = vec4(rgb, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CameraView(Widget):
|
||||
def __init__(self, name: str, stream_type: VisionStreamType):
|
||||
super().__init__()
|
||||
self._name = name
|
||||
# Primary stream
|
||||
self.client = VisionIpcClient(name, stream_type, conflate=True)
|
||||
self._stream_type = stream_type
|
||||
self.available_streams: list[VisionStreamType] = []
|
||||
|
||||
# Target stream for switching
|
||||
self._target_client: VisionIpcClient | None = None
|
||||
self._target_stream_type: VisionStreamType | None = None
|
||||
self._switching: bool = False
|
||||
|
||||
self._texture_needs_update = True
|
||||
self.last_connection_attempt: float = 0.0
|
||||
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
|
||||
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1
|
||||
self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
|
||||
self._engaged_val = rl.ffi.new("int[1]", [1])
|
||||
self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
|
||||
self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0])
|
||||
|
||||
self.frame: VisionBuf | None = None
|
||||
self.texture_y: rl.Texture | None = None
|
||||
self.texture_uv: rl.Texture | None = None
|
||||
|
||||
# EGL resources
|
||||
self.egl_images: dict[int, EGLImage] = {}
|
||||
self.egl_texture: rl.Texture | None = None
|
||||
|
||||
self._placeholder_color: rl.Color | None = None
|
||||
|
||||
# Initialize EGL for zero-copy rendering on comma 3/3X.
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
if not init_egl():
|
||||
raise RuntimeError("Failed to initialize EGL")
|
||||
|
||||
# Create a 1x1 pixel placeholder texture for EGL image binding
|
||||
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
|
||||
self.egl_texture = rl.load_texture_from_image(temp_image)
|
||||
rl.unload_image(temp_image)
|
||||
|
||||
ui_state.add_offroad_transition_callback(self._offroad_transition)
|
||||
|
||||
def _offroad_transition(self):
|
||||
# Reconnect if not first time going onroad
|
||||
if ui_state.is_onroad() and self.frame is not None:
|
||||
# Prevent old frames from showing when going onroad. Qt has a separate thread
|
||||
# which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough
|
||||
# and only clears internal buffers, not the message queue.
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
if self.client:
|
||||
del self.client
|
||||
self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
|
||||
|
||||
def _set_placeholder_color(self, color: rl.Color):
|
||||
"""Set a placeholder color to be drawn when no frame is available."""
|
||||
self._placeholder_color = color
|
||||
|
||||
def switch_stream(self, stream_type: VisionStreamType) -> None:
|
||||
if self._stream_type == stream_type:
|
||||
return
|
||||
|
||||
if self._switching and self._target_stream_type == stream_type:
|
||||
return
|
||||
|
||||
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
|
||||
|
||||
if self._target_client:
|
||||
del self._target_client
|
||||
|
||||
self._target_stream_type = stream_type
|
||||
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
|
||||
self._switching = True
|
||||
|
||||
@property
|
||||
def stream_type(self) -> VisionStreamType:
|
||||
return self._stream_type
|
||||
|
||||
def close(self) -> None:
|
||||
self._clear_textures()
|
||||
|
||||
# Clean up EGL texture
|
||||
if EGL_DMA_BUF_SUPPORTED and self.egl_texture:
|
||||
rl.unload_texture(self.egl_texture)
|
||||
self.egl_texture = None
|
||||
|
||||
# Clean up shader
|
||||
if self.shader and self.shader.id:
|
||||
rl.unload_shader(self.shader)
|
||||
self.shader.id = 0
|
||||
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
self.client = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
if not self.frame:
|
||||
return np.eye(3)
|
||||
|
||||
# Calculate aspect ratios
|
||||
widget_aspect_ratio = rect.width / rect.height
|
||||
frame_aspect_ratio = self.frame.width / self.frame.height
|
||||
|
||||
# Calculate scaling factors to maintain aspect ratio
|
||||
zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
|
||||
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
|
||||
|
||||
return np.array([
|
||||
[zx, 0.0, 0.0],
|
||||
[0.0, zy, 0.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._switching:
|
||||
self._handle_switch()
|
||||
|
||||
if not self._ensure_connection():
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
# Try to get a new buffer without blocking
|
||||
buffer = self.client.recv(timeout_ms=0)
|
||||
if buffer:
|
||||
self._texture_needs_update = True
|
||||
self.frame = buffer
|
||||
elif not self.client.is_connected():
|
||||
# ensure we clear the displayed frame when the connection is lost
|
||||
self.frame = None
|
||||
|
||||
if not self.frame:
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
transform = self._calc_frame_matrix(rect)
|
||||
src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
|
||||
# Flip driver camera horizontally
|
||||
if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
|
||||
src_rect.width = -src_rect.width
|
||||
|
||||
# Calculate scale
|
||||
scale_x = rect.width * transform[0, 0] # zx
|
||||
scale_y = rect.height * transform[1, 1] # zy
|
||||
|
||||
# Calculate base position (centered)
|
||||
x_offset = rect.x + (rect.width - scale_x) / 2
|
||||
y_offset = rect.y + (rect.height - scale_y) / 2
|
||||
|
||||
x_offset += transform[0, 2] * rect.width / 2
|
||||
y_offset += transform[1, 2] * rect.height / 2
|
||||
|
||||
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
|
||||
|
||||
# Render with appropriate method
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
self._render_egl(src_rect, dst_rect)
|
||||
else:
|
||||
self._render_textures(src_rect, dst_rect)
|
||||
|
||||
def _draw_placeholder(self, rect: rl.Rectangle):
|
||||
if self._placeholder_color:
|
||||
rl.draw_rectangle_rec(rect, self._placeholder_color)
|
||||
|
||||
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using EGL for direct buffer access"""
|
||||
if self.frame is None or self.egl_texture is None:
|
||||
return
|
||||
|
||||
idx = self.frame.idx
|
||||
egl_image = self.egl_images.get(idx)
|
||||
|
||||
# Create EGL image if needed
|
||||
if egl_image is None:
|
||||
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
|
||||
if egl_image:
|
||||
self.egl_images[idx] = egl_image
|
||||
else:
|
||||
return
|
||||
|
||||
# Update texture dimensions to match current frame
|
||||
self.egl_texture.width = self.frame.width
|
||||
self.egl_texture.height = self.frame.height
|
||||
|
||||
# Bind the EGL image to our texture
|
||||
bind_egl_image_to_texture(self.egl_texture.id, egl_image)
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
self._update_texture_color_filtering()
|
||||
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using texture copies"""
|
||||
if not self.texture_y or not self.texture_uv or self.frame is None:
|
||||
return
|
||||
|
||||
# Update textures with new frame data
|
||||
if self._texture_needs_update:
|
||||
y_data = self.frame.data[: self.frame.uv_offset]
|
||||
uv_data = self.frame.data[self.frame.uv_offset:]
|
||||
|
||||
rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
|
||||
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
|
||||
self._texture_needs_update = False
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
self._update_texture_color_filtering()
|
||||
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
|
||||
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _update_texture_color_filtering(self):
|
||||
self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
|
||||
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
|
||||
rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
|
||||
|
||||
def _ensure_connection(self) -> bool:
|
||||
if not self.client.is_connected():
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
|
||||
# Throttle connection attempts
|
||||
current_time = rl.get_time()
|
||||
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
|
||||
return False
|
||||
self.last_connection_attempt = current_time
|
||||
|
||||
if not self.client.connect(False) or not self.client.num_buffers:
|
||||
return False
|
||||
|
||||
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
|
||||
self._initialize_textures()
|
||||
self.available_streams = self.client.available_streams(self._name, block=False)
|
||||
|
||||
return True
|
||||
|
||||
def _handle_switch(self) -> None:
|
||||
"""Check if target stream is ready and switch immediately."""
|
||||
if not self._target_client or not self._switching:
|
||||
return
|
||||
|
||||
# Try to connect target if needed
|
||||
if not self._target_client.is_connected():
|
||||
if not self._target_client.connect(False) or not self._target_client.num_buffers:
|
||||
return
|
||||
|
||||
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
|
||||
|
||||
# Check if target has frames ready
|
||||
target_frame = self._target_client.recv(timeout_ms=0)
|
||||
if target_frame:
|
||||
self.frame = target_frame # Update current frame to target frame
|
||||
self._complete_switch()
|
||||
|
||||
def _complete_switch(self) -> None:
|
||||
"""Instantly switch to target stream."""
|
||||
cloudlog.debug(f"Switching to {self._target_stream_type}")
|
||||
# Clean up current resources
|
||||
if self.client:
|
||||
del self.client
|
||||
|
||||
# Switch to target
|
||||
self.client = self._target_client
|
||||
self._stream_type = self._target_stream_type
|
||||
self._texture_needs_update = True
|
||||
|
||||
# Reset state
|
||||
self._target_client = None
|
||||
self._target_stream_type = None
|
||||
self._switching = False
|
||||
|
||||
# Initialize textures for new stream
|
||||
self._initialize_textures()
|
||||
|
||||
def _initialize_textures(self):
|
||||
self._clear_textures()
|
||||
if not EGL_DMA_BUF_SUPPORTED:
|
||||
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
|
||||
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
|
||||
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
|
||||
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
|
||||
|
||||
def _clear_textures(self):
|
||||
if self.texture_y and self.texture_y.id:
|
||||
rl.unload_texture(self.texture_y)
|
||||
self.texture_y = None
|
||||
|
||||
if self.texture_uv and self.texture_uv.id:
|
||||
rl.unload_texture(self.texture_uv)
|
||||
self.texture_uv = None
|
||||
|
||||
# Clean up EGL resources
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
for data in self.egl_images.values():
|
||||
destroy_egl_image(data)
|
||||
self.egl_images = {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("camera view")
|
||||
road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
|
||||
for _ in gui_app.render():
|
||||
road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
86
iqpilot/selfdrive/ui/mici/onroad/confidence_ball.py
Normal file
86
iqpilot/selfdrive/ui/mici/onroad/confidence_ball.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from iqpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
from iqpilot.ui.mici.onroad.confidence_ball import IQConfidenceBall
|
||||
|
||||
|
||||
def draw_circle_gradient(center_x: float, center_y: float, radius: int,
|
||||
top: rl.Color, bottom: rl.Color) -> None:
|
||||
# Draw a square with the gradient
|
||||
rl.draw_rectangle_gradient_v(int(center_x - radius), int(center_y - radius),
|
||||
radius * 2, radius * 2,
|
||||
top, bottom)
|
||||
|
||||
# Paint over square with a ring
|
||||
outer_radius = math.ceil(radius * math.sqrt(2)) + 1
|
||||
rl.draw_ring(rl.Vector2(int(center_x), int(center_y)), radius, outer_radius,
|
||||
0.0, 360.0,
|
||||
20, rl.BLACK)
|
||||
|
||||
|
||||
class ConfidenceBall(Widget, IQConfidenceBall):
|
||||
def __init__(self, demo: bool = False):
|
||||
Widget.__init__(self)
|
||||
IQConfidenceBall.__init__(self)
|
||||
self._demo = demo
|
||||
self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
|
||||
|
||||
def update_filter(self, value: float):
|
||||
self._confidence_filter.update(value)
|
||||
|
||||
def _update_state(self):
|
||||
if self._demo:
|
||||
return
|
||||
|
||||
# animate status dot in from bottom
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
self._confidence_filter.update(-0.5)
|
||||
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
|
||||
self._confidence_filter.update(1 - max(self.get_animate_status_probs() or [1]))
|
||||
else:
|
||||
self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) *
|
||||
(1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1])))
|
||||
|
||||
def _render(self, _):
|
||||
content_rect = rl.Rectangle(
|
||||
self.rect.x + self.rect.width - SIDE_PANEL_WIDTH,
|
||||
self.rect.y,
|
||||
SIDE_PANEL_WIDTH,
|
||||
self.rect.height,
|
||||
)
|
||||
|
||||
status_dot_radius = 24
|
||||
dot_height = (1 - self._confidence_filter.x) * (content_rect.height - 2 * status_dot_radius) + status_dot_radius
|
||||
dot_height = self._rect.y + dot_height
|
||||
|
||||
# confidence zones
|
||||
if ui_state.status == UIStatus.ENGAGED or self._demo:
|
||||
if self._confidence_filter.x > 0.5:
|
||||
top_dot_color = rl.Color(0, 255, 204, 255)
|
||||
bottom_dot_color = rl.Color(0, 255, 38, 255)
|
||||
elif self._confidence_filter.x > 0.2:
|
||||
top_dot_color = rl.Color(255, 200, 0, 255)
|
||||
bottom_dot_color = rl.Color(255, 115, 0, 255)
|
||||
else:
|
||||
top_dot_color = rl.Color(255, 0, 21, 255)
|
||||
bottom_dot_color = rl.Color(255, 0, 89, 255)
|
||||
|
||||
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
|
||||
top_dot_color, bottom_dot_color = self.get_lat_long_dot_colors(self._confidence_filter.x)
|
||||
|
||||
elif ui_state.status == UIStatus.OVERRIDE:
|
||||
top_dot_color = rl.Color(255, 255, 255, 255)
|
||||
bottom_dot_color = rl.Color(82, 82, 82, 255)
|
||||
|
||||
else:
|
||||
top_dot_color = rl.Color(50, 50, 50, 255)
|
||||
bottom_dot_color = rl.Color(13, 13, 13, 255)
|
||||
|
||||
draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius,
|
||||
dot_height, status_dot_radius,
|
||||
top_dot_color, bottom_dot_color)
|
||||
246
iqpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py
Normal file
246
iqpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import log, messaging
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from iqpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
||||
from iqpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from iqpilot.selfdrive.selfdrived.events import EVENTS, ET
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.widgets.label import gui_label
|
||||
|
||||
EventName = log.OnroadEvent.EventName
|
||||
|
||||
EVENT_TO_INT = EventName.schema.enumerants
|
||||
|
||||
|
||||
class DriverCameraView(CameraView):
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle):
|
||||
base = super()._calc_frame_matrix(rect)
|
||||
driver_view_ratio = 1.5
|
||||
base[0, 0] *= driver_view_ratio
|
||||
base[1, 1] *= driver_view_ratio
|
||||
return base
|
||||
|
||||
|
||||
class DriverCameraDialog(NavWidget):
|
||||
def __init__(self, no_escape=False):
|
||||
super().__init__()
|
||||
self._no_escape = no_escape
|
||||
self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER)
|
||||
self.driver_state_renderer = DriverStateRenderer(lines=True)
|
||||
self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
|
||||
self.driver_state_renderer.load_icons()
|
||||
self._pm: messaging.PubMaster | None = None
|
||||
if not no_escape:
|
||||
# TODO: this can grow unbounded, should be given some thought
|
||||
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
|
||||
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
|
||||
|
||||
# Load eye icons
|
||||
self._eye_fill_texture = None
|
||||
self._eye_orange_texture = None
|
||||
self._eye_size = 74
|
||||
self._glasses_texture = None
|
||||
self._glasses_size = 171
|
||||
|
||||
self._load_eye_textures()
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return not self._no_escape
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
self._publish_alert_sound(None)
|
||||
device.set_override_interactive_timeout(300)
|
||||
ui_state.params.remove("DriverTooDistracted")
|
||||
self._pm = messaging.PubMaster(['selfdriveState'])
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
device.set_override_interactive_timeout(None)
|
||||
|
||||
def _handle_mouse_release(self, _):
|
||||
ui_state.params.remove("DriverTooDistracted")
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if self._camera_view:
|
||||
self._camera_view.close()
|
||||
|
||||
def _update_state(self):
|
||||
if self._camera_view:
|
||||
self._camera_view._update_state()
|
||||
# Enable driver state renderer to show Dmoji in preview
|
||||
self.driver_state_renderer.set_should_draw(True)
|
||||
self.driver_state_renderer.set_force_active(True)
|
||||
super()._update_state()
|
||||
|
||||
def _render(self, rect):
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
self._camera_view._render(rect)
|
||||
|
||||
if not self._camera_view.frame:
|
||||
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
rl.end_scissor_mode()
|
||||
self._publish_alert_sound(None)
|
||||
return -1
|
||||
|
||||
driver_data = self._draw_face_detection(rect)
|
||||
if driver_data is not None:
|
||||
self._draw_eyes(rect, driver_data)
|
||||
|
||||
# Position dmoji on opposite side from driver
|
||||
driver_state_rect = (
|
||||
rect.x if self.driver_state_renderer.is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width,
|
||||
rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
|
||||
)
|
||||
self.driver_state_renderer.set_position(*driver_state_rect)
|
||||
self.driver_state_renderer.render()
|
||||
|
||||
# Render driver monitoring alerts
|
||||
self._render_dm_alerts(rect)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
return -1
|
||||
|
||||
def _publish_alert_sound(self, dm_state):
|
||||
"""Publish selfdriveState with only alertSound field set"""
|
||||
if self._pm is None:
|
||||
return
|
||||
|
||||
msg = messaging.new_message('selfdriveState')
|
||||
if dm_state is not None and len(dm_state.events):
|
||||
event_name = EVENT_TO_INT[dm_state.events[0].name]
|
||||
if event_name is not None and event_name in EVENTS and ET.PERMANENT in EVENTS[event_name]:
|
||||
msg.selfdriveState.alertSound = EVENTS[event_name][ET.PERMANENT].audible_alert
|
||||
self._pm.send('selfdriveState', msg)
|
||||
|
||||
def _render_dm_alerts(self, rect: rl.Rectangle):
|
||||
"""Render driver monitoring event names"""
|
||||
dm_state = ui_state.sm["driverMonitoringState"]
|
||||
self._publish_alert_sound(dm_state)
|
||||
|
||||
gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
|
||||
f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
color=rl.Color(0, 0, 0, 180))
|
||||
gui_label(rect, f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
if not dm_state.events:
|
||||
return
|
||||
|
||||
# Show first event (only one should be active at a time)
|
||||
event_name_str = str(dm_state.events[0].name).split('.')[-1]
|
||||
alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
|
||||
|
||||
shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
|
||||
gui_label(shadow_rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
|
||||
alignment=alignment,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
|
||||
color=rl.Color(0, 0, 0, 180))
|
||||
gui_label(rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
|
||||
alignment=alignment,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
def _load_eye_textures(self):
|
||||
"""Lazy load eye textures"""
|
||||
if self._eye_fill_texture is None:
|
||||
self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
|
||||
if self._eye_orange_texture is None:
|
||||
self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
|
||||
if self._glasses_texture is None:
|
||||
self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
|
||||
|
||||
def _draw_face_detection(self, rect: rl.Rectangle):
|
||||
dm_state = ui_state.sm["driverMonitoringState"]
|
||||
driver_data = self.driver_state_renderer.get_driver_data()
|
||||
if not dm_state.faceDetected:
|
||||
return
|
||||
|
||||
# Get face position and orientation
|
||||
face_x, face_y = driver_data.facePosition
|
||||
face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
|
||||
alpha = 0.7
|
||||
if face_std > 0.15:
|
||||
alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
|
||||
|
||||
# use approx instead of distort_points
|
||||
# TODO: replace with distort_points
|
||||
tici_x = 1080.0 - 1714.0 * face_x
|
||||
tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y
|
||||
|
||||
# Tici coords are relative to center, scale offset
|
||||
offset_x = (tici_x - 1080.0) * 1.25
|
||||
offset_y = (tici_y - 540.0) * 1.25
|
||||
|
||||
# Map to mici screen (scale from 2160x1080 to rect dimensions)
|
||||
scale_x = rect.width / 2160.0
|
||||
scale_y = rect.height / 1080.0
|
||||
fbox_x = rect.x + rect.width / 2 + offset_x * scale_x
|
||||
fbox_y = rect.y + rect.height / 2 + offset_y * scale_y
|
||||
box_size = 75
|
||||
line_thickness = 3
|
||||
|
||||
line_color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(
|
||||
rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
|
||||
35.0 / box_size / 2,
|
||||
line_thickness,
|
||||
line_thickness,
|
||||
line_color,
|
||||
)
|
||||
return driver_data
|
||||
|
||||
def _draw_eyes(self, rect: rl.Rectangle, driver_data):
|
||||
# Draw eye indicators based on eye probabilities
|
||||
eye_offset_x = 10
|
||||
eye_offset_y = 10
|
||||
eye_spacing = self._eye_size + 15
|
||||
|
||||
left_eye_x = rect.x + eye_offset_x
|
||||
left_eye_y = rect.y + eye_offset_y
|
||||
left_eye_prob = driver_data.leftEyeProb
|
||||
|
||||
right_eye_x = rect.x + eye_offset_x + eye_spacing
|
||||
right_eye_y = rect.y + eye_offset_y
|
||||
right_eye_prob = driver_data.rightEyeProb
|
||||
|
||||
# Draw eyes with opacity based on probability
|
||||
for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
|
||||
fill_opacity = eye_prob
|
||||
orange_opacity = 1.0 - eye_prob
|
||||
|
||||
rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
|
||||
rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
|
||||
|
||||
# Draw sunglasses indicator based on sunglasses probability
|
||||
# Position glasses centered between the two eyes at top left
|
||||
glasses_x = rect.x + eye_offset_x - 4
|
||||
glasses_y = rect.y
|
||||
glasses_pos = rl.Vector2(glasses_x, glasses_y)
|
||||
glasses_prob = driver_data.sunglassesProb
|
||||
rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Driver Camera View (mici)")
|
||||
|
||||
driver_camera_view = DriverCameraDialog()
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
finally:
|
||||
driver_camera_view.close()
|
||||
213
iqpilot/selfdrive/ui/mici/onroad/driver_state.py
Normal file
213
iqpilot/selfdrive/ui/mici/onroad/driver_state.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
import math
|
||||
from iqpilot.cereal import log
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.selfdrive.monitoring.helpers import face_orientation_from_net
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
|
||||
DEBUG = False
|
||||
ACTIVE_ACCENT = rl.Color(0x0C, 0x94, 0x96, 0xFF)
|
||||
|
||||
LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6)
|
||||
LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3)
|
||||
|
||||
|
||||
class DriverStateRenderer(Widget):
|
||||
BASE_SIZE = 60
|
||||
LINES_ANGLE_INCREMENT = 5
|
||||
LINES_STALE_ANGLES = 3.0 # seconds
|
||||
|
||||
def __init__(self, lines: bool = False, inset: bool = False):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE))
|
||||
self._lines = lines
|
||||
self._inset = inset
|
||||
|
||||
# In line mode, track smoothed angles
|
||||
assert 360 % self.LINES_ANGLE_INCREMENT == 0
|
||||
self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)}
|
||||
|
||||
self._is_active = False
|
||||
self._is_rhd = False
|
||||
self._face_detected = False
|
||||
self._should_draw = False
|
||||
self._force_active = False
|
||||
self._looking_center = False
|
||||
|
||||
self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
|
||||
self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
|
||||
self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False)
|
||||
self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# Load the driver face icons
|
||||
self.load_icons()
|
||||
|
||||
def load_icons(self):
|
||||
cone_and_person_size = round(56 / self.BASE_SIZE * self._rect.width)
|
||||
|
||||
if self._inset:
|
||||
current_inset = (self._rect.width - cone_and_person_size) / 2
|
||||
cone_and_person_size = round(cone_and_person_size - current_inset * 2)
|
||||
|
||||
self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", cone_and_person_size, cone_and_person_size)
|
||||
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size)
|
||||
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", self._rect.width, self._rect.height)
|
||||
|
||||
def set_should_draw(self, should_draw: bool):
|
||||
self._should_draw = should_draw
|
||||
|
||||
@property
|
||||
def should_draw(self):
|
||||
return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
|
||||
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
|
||||
|
||||
def set_force_active(self, force_active: bool):
|
||||
"""Force the dmoji to always appear active (green) regardless of actual state"""
|
||||
self._force_active = force_active
|
||||
|
||||
@property
|
||||
def effective_active(self) -> bool:
|
||||
"""Returns True if dmoji should appear active (either actually active or forced)"""
|
||||
return bool(self._force_active or self._is_active)
|
||||
|
||||
@property
|
||||
def is_rhd(self) -> bool:
|
||||
return self._is_rhd
|
||||
|
||||
def _render(self, _):
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
|
||||
|
||||
rl.draw_texture(self._dm_background,
|
||||
int(self._rect.x),
|
||||
int(self._rect.y),
|
||||
rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
|
||||
|
||||
rl.draw_texture(self._dm_person,
|
||||
int(self._rect.x + (self._rect.width - self._dm_person.width) / 2),
|
||||
int(self._rect.y + (self._rect.height - self._dm_person.height) / 2),
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
|
||||
|
||||
if self.effective_active:
|
||||
source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
|
||||
dest_rect = rl.Rectangle(
|
||||
self._rect.x + self._rect.width / 2,
|
||||
self._rect.y + self._rect.height / 2,
|
||||
self._dm_cone.width,
|
||||
self._dm_cone.height,
|
||||
)
|
||||
|
||||
if not self._lines:
|
||||
rl.draw_texture_pro(
|
||||
self._dm_cone,
|
||||
source_rect,
|
||||
dest_rect,
|
||||
rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
|
||||
self._rotation_filter.x - 90,
|
||||
rl.Color(ACTIVE_ACCENT.r, ACTIVE_ACCENT.g, ACTIVE_ACCENT.b, int(255 * self._fade_filter.x)),
|
||||
)
|
||||
|
||||
else:
|
||||
# remove old angles
|
||||
for angle, f in self._head_angles.items():
|
||||
dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180
|
||||
target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0
|
||||
if not self._face_detected:
|
||||
target = 0.0
|
||||
|
||||
# Reduce all line lengths when looking center
|
||||
if self._looking_center:
|
||||
target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45])
|
||||
|
||||
f.update(target)
|
||||
self._draw_line(angle, f, self._looking_center)
|
||||
|
||||
def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool):
|
||||
line_length = self._rect.width / 6
|
||||
line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length]))
|
||||
line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect
|
||||
center_x = self._rect.x + self._rect.width / 2
|
||||
center_y = self._rect.y + self._rect.height / 2
|
||||
start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle))
|
||||
start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle))
|
||||
end_x = start_x + line_length * math.cos(math.radians(angle))
|
||||
end_y = start_y + line_length * math.sin(math.radians(angle))
|
||||
color = ACTIVE_ACCENT
|
||||
|
||||
if grey:
|
||||
color = rl.Color(166, 166, 166, 255)
|
||||
|
||||
if f.x > 0.01:
|
||||
rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color)
|
||||
|
||||
def get_driver_data(self):
|
||||
sm = ui_state.sm
|
||||
|
||||
dm_state = sm["driverMonitoringState"]
|
||||
self._is_active = dm_state.isActiveMode
|
||||
self._is_rhd = dm_state.isRHD
|
||||
self._face_detected = dm_state.faceDetected
|
||||
|
||||
driverstate = sm["driverStateV2"]
|
||||
driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData
|
||||
return driver_data
|
||||
|
||||
def _update_state(self):
|
||||
# Get monitoring state
|
||||
driver_data = self.get_driver_data()
|
||||
driver_orient = driver_data.faceOrientation
|
||||
|
||||
if len(driver_orient) != 3:
|
||||
return
|
||||
|
||||
# Calibrate orientation so looking straight ahead at the road (instead of at the device) reads
|
||||
# (0, 0), using live calibration. Makes the cone point in the correct direction. (stock PR #37149)
|
||||
sm = ui_state.sm
|
||||
if sm.valid['extrinsicsCalibration'] and len(sm['extrinsicsCalibration'].rpyCalib) == 3:
|
||||
cal_rpy = sm['extrinsicsCalibration'].rpyCalib
|
||||
else:
|
||||
cal_rpy = [0.0, 0.0, 0.0]
|
||||
_, pitch, yaw = face_orientation_from_net(driver_orient, driver_data.facePosition, cal_rpy)
|
||||
yaw = -yaw # undo sign flip in face_orientation_from_net to match UI convention
|
||||
|
||||
pitch = self._pitch_filter.update(pitch)
|
||||
yaw = self._yaw_filter.update(yaw)
|
||||
|
||||
# hysteresis on looking center
|
||||
if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER:
|
||||
self._looking_center = True
|
||||
elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER:
|
||||
self._looking_center = False
|
||||
self._looking_center_filter.update(1 if self._looking_center else 0)
|
||||
|
||||
if DEBUG:
|
||||
pitchd = math.degrees(pitch)
|
||||
yawd = math.degrees(yaw)
|
||||
|
||||
rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED)
|
||||
rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED)
|
||||
|
||||
pitch_x = 100 + pitchd
|
||||
yaw_x = 100 + yawd
|
||||
rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN)
|
||||
rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN)
|
||||
|
||||
# filter head rotation, handling wrap-around (bias pitch up since calib/DM pose isn't exact,
|
||||
# and halve yaw sensitivity)
|
||||
rotation = math.degrees(math.atan2((pitch + math.radians(6)) * 2, yaw))
|
||||
angle_diff = rotation - self._rotation_filter.x
|
||||
angle_diff = ((angle_diff + 180) % 360) - 180
|
||||
self._rotation_filter.update(self._rotation_filter.x + angle_diff)
|
||||
|
||||
if not self.should_draw:
|
||||
self._fade_filter.update(0.0)
|
||||
elif not self.effective_active:
|
||||
self._fade_filter.update(0.35)
|
||||
else:
|
||||
self._fade_filter.update(1.0)
|
||||
279
iqpilot/selfdrive/ui/mici/onroad/hud_renderer.py
Normal file
279
iqpilot/selfdrive/ui/mici/onroad/hud_renderer.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from iqpilot.common.constants import CV
|
||||
from iqpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.raylib_compat import draw_circle_gradient
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.cereal import log
|
||||
|
||||
EventName = log.OnroadEvent.EventName
|
||||
|
||||
# Constants
|
||||
SET_SPEED_NA = 255
|
||||
KM_TO_MILE = 0.621371
|
||||
CRUISE_DISABLED_CHAR = '–'
|
||||
|
||||
SET_SPEED_PERSISTENCE = 2.5 # seconds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontSizes:
|
||||
current_speed: int = 176
|
||||
speed_unit: int = 66
|
||||
max_speed: int = 36
|
||||
set_speed: int = 112
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Colors:
|
||||
WHITE = rl.WHITE
|
||||
WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200)
|
||||
|
||||
|
||||
FONT_SIZES = FontSizes()
|
||||
COLORS = Colors()
|
||||
|
||||
|
||||
class TurnIntent(Widget):
|
||||
FADE_IN_ANGLE = 30 # degrees
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._pre = False
|
||||
self._turn_intent_direction: int = 0
|
||||
|
||||
self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 20)
|
||||
self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 20)
|
||||
|
||||
def _render(self, _):
|
||||
if self._turn_intent_alpha_filter.x > 1e-2:
|
||||
turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left
|
||||
src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height)
|
||||
dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2,
|
||||
turn_intent_texture.width, turn_intent_texture.height)
|
||||
|
||||
origin = (turn_intent_texture.width / 2, self._rect.height / 2)
|
||||
color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x))
|
||||
rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
sm = ui_state.sm
|
||||
|
||||
left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents'])
|
||||
right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents'])
|
||||
if left or right:
|
||||
# pre lane change
|
||||
if not self._pre:
|
||||
self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE
|
||||
|
||||
self._pre = True
|
||||
self._turn_intent_direction = -1 if left else 1
|
||||
self._turn_intent_alpha_filter.update(1)
|
||||
self._turn_intent_rotation_filter.update(0)
|
||||
elif any(e.name == EventName.laneChange for e in sm['onroadEvents']):
|
||||
# fade out and rotate away
|
||||
self._pre = False
|
||||
self._turn_intent_alpha_filter.update(0)
|
||||
|
||||
if self._turn_intent_direction == 0:
|
||||
# unknown. missed pre frame?
|
||||
self._turn_intent_rotation_filter.update(0)
|
||||
else:
|
||||
self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE)
|
||||
else:
|
||||
# didn't complete lane change, just hide
|
||||
self._pre = False
|
||||
self._turn_intent_direction = 0
|
||||
self._turn_intent_alpha_filter.update(0)
|
||||
self._turn_intent_rotation_filter.update(0)
|
||||
|
||||
|
||||
class HudRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
"""Initialize the HUD renderer."""
|
||||
self.is_cruise_set: bool = False
|
||||
self.is_cruise_available: bool = True
|
||||
self.set_speed: float = SET_SPEED_NA
|
||||
self._set_speed_changed_time: float = 0
|
||||
self.speed: float = 0.0
|
||||
self.v_ego_cluster_seen: bool = False
|
||||
self._engaged: bool = False
|
||||
|
||||
self._can_draw_top_icons = True
|
||||
self._show_wheel_critical = False
|
||||
|
||||
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
|
||||
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
|
||||
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
|
||||
|
||||
self._turn_intent = TurnIntent()
|
||||
self._torque_bar = TorqueBar()
|
||||
|
||||
self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
|
||||
self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
|
||||
self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44)
|
||||
|
||||
self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
def set_wheel_critical_icon(self, critical: bool):
|
||||
"""Set the wheel icon to critical or normal state."""
|
||||
self._show_wheel_critical = critical
|
||||
|
||||
def set_can_draw_top_icons(self, can_draw_top_icons: bool):
|
||||
"""Set whether to draw the top part of the HUD."""
|
||||
self._can_draw_top_icons = can_draw_top_icons
|
||||
|
||||
def drawing_top_icons(self) -> bool:
|
||||
# whether we're drawing any top icons currently
|
||||
return bool(self._set_speed_alpha_filter.x > 1e-2)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
"""Update HUD state based on car state and controls state."""
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame["carState"] < ui_state.started_frame:
|
||||
self.is_cruise_set = False
|
||||
self.set_speed = SET_SPEED_NA
|
||||
self.speed = 0.0
|
||||
return
|
||||
|
||||
controls_state = sm['controlsState']
|
||||
car_state = sm['carState']
|
||||
|
||||
v_cruise_cluster = car_state.vCruiseCluster
|
||||
set_speed = (
|
||||
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
|
||||
)
|
||||
engaged = sm['selfdriveState'].enabled
|
||||
if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
|
||||
self._set_speed_changed_time = rl.get_time()
|
||||
self._engaged = engaged
|
||||
self.set_speed = set_speed
|
||||
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
|
||||
self.is_cruise_available = self.set_speed != -1
|
||||
|
||||
v_ego_cluster = car_state.vEgoCluster
|
||||
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
|
||||
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
|
||||
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
self.speed = max(0.0, v_ego * speed_conversion)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
"""Render HUD elements to the screen."""
|
||||
|
||||
self._torque_bar.render(rect)
|
||||
|
||||
if self.is_cruise_set:
|
||||
self._draw_set_speed(rect)
|
||||
|
||||
self._draw_steering_wheel(rect)
|
||||
|
||||
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
|
||||
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
|
||||
|
||||
bsm_detected = self._has_blind_spot_detected() if gui_app.iqpilot_ui() else False
|
||||
|
||||
if self._show_wheel_critical:
|
||||
self._wheel_alpha_filter.update(255)
|
||||
self._wheel_y_filter.update(0)
|
||||
else:
|
||||
if ui_state.status == UIStatus.DISENGAGED or bsm_detected:
|
||||
self._wheel_alpha_filter.update(0)
|
||||
self._wheel_y_filter.update(wheel_txt.height / 2)
|
||||
else:
|
||||
self._wheel_alpha_filter.update(255 * 0.9)
|
||||
self._wheel_y_filter.update(0)
|
||||
|
||||
# pos
|
||||
pos_x = int(rect.x + 21 + wheel_txt.width / 2)
|
||||
pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x)
|
||||
rotation = -ui_state.sm['carState'].steeringAngleDeg
|
||||
|
||||
turn_intent_margin = 25
|
||||
self._turn_intent.render(rl.Rectangle(
|
||||
pos_x - wheel_txt.width / 2 - turn_intent_margin,
|
||||
pos_y - wheel_txt.height / 2 - turn_intent_margin,
|
||||
wheel_txt.width + turn_intent_margin * 2,
|
||||
wheel_txt.height + turn_intent_margin * 2,
|
||||
))
|
||||
|
||||
src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height)
|
||||
dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height)
|
||||
origin = (wheel_txt.width / 2, wheel_txt.height / 2)
|
||||
|
||||
# color and draw
|
||||
color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x))
|
||||
rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color)
|
||||
|
||||
if self._show_wheel_critical:
|
||||
# Draw exclamation point icon
|
||||
EXCLAMATION_POINT_SPACING = 10
|
||||
exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING
|
||||
exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2
|
||||
rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE)
|
||||
|
||||
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
|
||||
"""Draw the MAX speed indicator box."""
|
||||
alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and
|
||||
self._can_draw_top_icons and self._engaged)
|
||||
if alpha < 1e-2:
|
||||
return
|
||||
|
||||
x = rect.x
|
||||
y = rect.y
|
||||
|
||||
# draw drop shadow
|
||||
circle_radius = 162 // 2
|
||||
draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius,
|
||||
rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.BLANK)
|
||||
|
||||
set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
|
||||
max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
|
||||
|
||||
set_speed = self.set_speed
|
||||
if self.is_cruise_set and not ui_state.is_metric:
|
||||
set_speed *= KM_TO_MILE
|
||||
|
||||
set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed))
|
||||
rl.draw_text_ex(
|
||||
self._font_display,
|
||||
set_speed_text,
|
||||
rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4),
|
||||
FONT_SIZES.set_speed,
|
||||
0,
|
||||
set_speed_color,
|
||||
)
|
||||
|
||||
max_text = tr("MAX")
|
||||
rl.draw_text_ex(
|
||||
self._font_semi_bold,
|
||||
max_text,
|
||||
rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4),
|
||||
FONT_SIZES.max_speed,
|
||||
0,
|
||||
max_color,
|
||||
)
|
||||
|
||||
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
|
||||
"""Draw the current vehicle speed and unit."""
|
||||
speed_text = str(round(self.speed))
|
||||
speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
|
||||
speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
|
||||
rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.WHITE)
|
||||
|
||||
unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
|
||||
unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
|
||||
unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
|
||||
rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.WHITE_TRANSLUCENT)
|
||||
501
iqpilot/selfdrive/ui/mici/onroad/model_renderer.py
Normal file
501
iqpilot/selfdrive/ui/mici/onroad/model_renderer.py
Normal file
@@ -0,0 +1,501 @@
|
||||
import colorsys
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import car
|
||||
from dataclasses import dataclass, field
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
|
||||
from iqpilot.ui.onroad.hud_overlays import ChevronMetrics
|
||||
from iqpilot.ui.onroad.lead_confidence import driving_confidence
|
||||
from iqpilot.selfdrive.locationd.calibration_helpers import get_render_path_height
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus, log_param_from_bytes
|
||||
from iqpilot.selfdrive.ui.mici.onroad import blend_colors
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
CLIP_MARGIN = 500
|
||||
MIN_DRAW_DISTANCE = 10.0
|
||||
MAX_DRAW_DISTANCE = 100.0
|
||||
|
||||
THROTTLE_COLORS = [
|
||||
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
|
||||
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
|
||||
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
|
||||
]
|
||||
|
||||
NO_THROTTLE_COLORS = [
|
||||
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
|
||||
rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
|
||||
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
|
||||
]
|
||||
|
||||
LANE_LINE_COLORS = {
|
||||
UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
|
||||
UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
|
||||
UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelPoints:
|
||||
raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
|
||||
projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadVehicle:
|
||||
center: tuple[float, float] | None = None
|
||||
radius: float = 0.0
|
||||
sz: float = 0.0
|
||||
fill_alpha: int = 0
|
||||
|
||||
|
||||
class ModelRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.chevron_metrics = ChevronMetrics()
|
||||
self._lead_orb = gui_app.texture("icons/lead_orb.png", 256, 256)
|
||||
self._longitudinal_control = False
|
||||
self._experimental_mode = False
|
||||
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
|
||||
self._prev_allow_throttle = True
|
||||
self._lane_line_probs = np.zeros(4, dtype=np.float32)
|
||||
self._road_edge_stds = np.zeros(2, dtype=np.float32)
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
self._path_offset_z = HEIGHT_INIT[0]
|
||||
|
||||
# Initialize ModelPoints objects
|
||||
self._path = ModelPoints()
|
||||
self._lane_lines = [ModelPoints() for _ in range(4)]
|
||||
self._road_edges = [ModelPoints() for _ in range(2)]
|
||||
self._acceleration_x = np.empty((0,), dtype=np.float32)
|
||||
|
||||
self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps)
|
||||
|
||||
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# Transform matrix (3x3 for car space to screen space)
|
||||
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
|
||||
self._transform_dirty = True
|
||||
self._clip_region = None
|
||||
|
||||
self._counter = -1
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
|
||||
self._exp_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=[],
|
||||
stops=[],
|
||||
)
|
||||
|
||||
# Get longitudinal control setting from car parameters
|
||||
if (cp := log_param_from_bytes(Params(), "CarParams", car.CarParams)) is not None:
|
||||
self._longitudinal_control = cp.openpilotLongitudinalControl
|
||||
|
||||
def set_transform(self, transform: np.ndarray):
|
||||
self._car_space_transform = transform.astype(np.float32)
|
||||
self._transform_dirty = True
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
driving_confidence.update()
|
||||
|
||||
if self._counter % 180 == 0: # This runs at 60fps, so we query every 3 seconds
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
self._counter += 1
|
||||
|
||||
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
|
||||
|
||||
# Check if data is up-to-date
|
||||
if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or
|
||||
sm.recv_frame["modelV2"] < ui_state.started_frame):
|
||||
return
|
||||
|
||||
# Set up clipping region
|
||||
self._clip_region = rl.Rectangle(
|
||||
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
|
||||
)
|
||||
|
||||
# Update state
|
||||
self._experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
|
||||
live_calib = sm['extrinsicsCalibration']
|
||||
self._path_offset_z = get_render_path_height(live_calib)
|
||||
|
||||
if sm.updated['carParams']:
|
||||
self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
|
||||
|
||||
model = sm['modelV2']
|
||||
radar_state = sm['radarState'] if sm.valid['radarState'] else None
|
||||
lead_one = radar_state.leadOne if radar_state else None
|
||||
render_lead_indicator = self._longitudinal_control and radar_state is not None
|
||||
|
||||
# Update model data when needed
|
||||
model_updated = sm.updated['modelV2']
|
||||
if model_updated or sm.updated['radarState'] or self._transform_dirty:
|
||||
if model_updated:
|
||||
self._update_raw_points(model)
|
||||
|
||||
path_x_array = self._path.raw_points[:, 0]
|
||||
if path_x_array.size == 0:
|
||||
return
|
||||
|
||||
self._update_model(lead_one, path_x_array)
|
||||
if render_lead_indicator:
|
||||
self._update_leads(radar_state, path_x_array)
|
||||
self._transform_dirty = False
|
||||
|
||||
# Draw elements (hide when disengaged)
|
||||
if ui_state.status != UIStatus.DISENGAGED:
|
||||
self._draw_lane_lines()
|
||||
self._draw_path(sm)
|
||||
|
||||
if render_lead_indicator and radar_state:
|
||||
self._draw_lead_indicator()
|
||||
self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles)
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
|
||||
|
||||
for i, lane_line in enumerate(model.laneLines):
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
|
||||
|
||||
for i, road_edge in enumerate(model.roadEdges):
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
|
||||
|
||||
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
|
||||
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
|
||||
self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
|
||||
|
||||
def _update_leads(self, radar_state, path_x_array):
|
||||
"""Update positions of lead vehicles"""
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
leads = [radar_state.leadOne, radar_state.leadTwo]
|
||||
|
||||
for i, lead_data in enumerate(leads):
|
||||
if lead_data and lead_data.status:
|
||||
d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
|
||||
idx = self._get_path_length_idx(path_x_array, d_rel)
|
||||
|
||||
# Get z-coordinate from path at the lead vehicle position
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
|
||||
if point:
|
||||
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
|
||||
|
||||
def _update_model(self, lead, path_x_array):
|
||||
"""Update model visualization data based on model message"""
|
||||
max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
|
||||
max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
|
||||
|
||||
# Update lane lines using raw points
|
||||
line_width_factor = 0.12
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
if i in (1, 2):
|
||||
line_width_factor = 0.16
|
||||
lane_line.projected_points = self._map_line_to_polygon(
|
||||
lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx
|
||||
)
|
||||
|
||||
# Update road edges using raw points
|
||||
for road_edge in self._road_edges:
|
||||
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
|
||||
|
||||
# Update path using raw points
|
||||
if lead and lead.status:
|
||||
lead_d = lead.dRel * 2.0
|
||||
max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
|
||||
|
||||
soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0
|
||||
self._acceleration_x_filter.update(soon_acceleration)
|
||||
self._acceleration_x_filter2.update(soon_acceleration)
|
||||
|
||||
# make path width wider/thinner when initially braking/accelerating
|
||||
if self._experimental_mode and False:
|
||||
high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x
|
||||
y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2])
|
||||
else:
|
||||
y_off = 0.9
|
||||
|
||||
max_idx = self._get_path_length_idx(path_x_array, max_distance)
|
||||
self._path.projected_points = self._map_line_to_polygon(
|
||||
self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False
|
||||
)
|
||||
|
||||
self._update_experimental_gradient()
|
||||
|
||||
def _update_experimental_gradient(self):
|
||||
"""Pre-calculate experimental mode gradient colors"""
|
||||
if not self._experimental_mode:
|
||||
return
|
||||
|
||||
# reconstruct absolute (screen) points so the rect-space cull below stays correct
|
||||
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
max_len = min(len(path_pts) // 2, len(self._acceleration_x))
|
||||
|
||||
segment_colors = []
|
||||
gradient_stops = []
|
||||
|
||||
i = 0
|
||||
while i < max_len:
|
||||
# Some points (screen space) are out of frame (rect space)
|
||||
track_y = path_pts[i][1]
|
||||
if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate color based on acceleration (0 is bottom, 1 is top)
|
||||
lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
|
||||
|
||||
# speed up: 120, slow down: 0
|
||||
path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
|
||||
|
||||
saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
|
||||
lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
|
||||
alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
|
||||
|
||||
# Use HSL to RGB conversion
|
||||
color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
|
||||
|
||||
gradient_stops.append(lin_grad_point)
|
||||
segment_colors.append(color)
|
||||
|
||||
# Skip a point, unless next is last
|
||||
i += 1 + (1 if (i + 2) < max_len else 0)
|
||||
|
||||
# Store the gradient in the path object
|
||||
self._exp_gradient.colors = segment_colors
|
||||
self._exp_gradient.stops = gradient_stops
|
||||
|
||||
def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
|
||||
speed_buff, lead_buff = 10.0, 40.0
|
||||
|
||||
# Calculate fill alpha
|
||||
fill_alpha = 0
|
||||
if d_rel < lead_buff:
|
||||
fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
|
||||
if v_rel < 0:
|
||||
fill_alpha += 255 * (-1 * (v_rel / speed_buff))
|
||||
fill_alpha = min(fill_alpha, 255)
|
||||
|
||||
# Calculate size and position. Distance-scaled orb radius (closer lead -> bigger orb).
|
||||
sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1
|
||||
radius = sz * 1.1
|
||||
# point is in absolute screen coords; clamp against the rect's absolute bounds so the orb stays
|
||||
# fully on-screen (rect-relative bounds mis-placed it when the camera pane is offset, e.g. split nav)
|
||||
x = np.clip(point[0], rect.x + radius, rect.x + rect.width - radius)
|
||||
y = np.clip(point[1], rect.y + radius, rect.y + rect.height - radius)
|
||||
|
||||
return LeadVehicle(center=(float(x), float(y)), radius=float(radius), sz=float(sz), fill_alpha=int(fill_alpha))
|
||||
|
||||
def _get_ll_color(self, prob: float, adjacent: bool, left: bool):
|
||||
alpha = np.clip(prob, 0.0, 0.7)
|
||||
if adjacent:
|
||||
_base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED])
|
||||
color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255))
|
||||
|
||||
# turn adjacent lls orange if torque is high
|
||||
torque = self._torque_filter.x
|
||||
high_torque = abs(torque) > 0.6
|
||||
if high_torque and (left == (torque > 0)):
|
||||
color = blend_colors(
|
||||
color,
|
||||
rl.Color(255, 115, 0, int(alpha * 255)), # orange
|
||||
np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0])
|
||||
)
|
||||
else:
|
||||
color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
color = rl.Color(0, 0, 0, int(alpha * 255))
|
||||
|
||||
return color
|
||||
|
||||
def _draw_lane_lines(self):
|
||||
"""Draw lane lines and road edges"""
|
||||
"""Two closest lines should be green (lane line or road edges)"""
|
||||
# projected_points are origin-relative (rect.x/y kept out of the transform so it stays cached);
|
||||
# translate to the view's screen position here.
|
||||
offset = np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
if lane_line.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1))
|
||||
draw_polygon(self._rect, lane_line.projected_points + offset, color)
|
||||
|
||||
for i, road_edge in enumerate(self._road_edges):
|
||||
if road_edge.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
# if closest lane lines are not confident, make road edges green
|
||||
color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0)
|
||||
draw_polygon(self._rect, road_edge.projected_points + offset, color)
|
||||
|
||||
def _draw_path(self, sm):
|
||||
"""Draw path with dynamic coloring based on mode and throttle state."""
|
||||
if not self._path.projected_points.size:
|
||||
return
|
||||
|
||||
# projected_points are origin-relative; translate to the view's screen position
|
||||
path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32)
|
||||
|
||||
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
|
||||
self._blend_filter.update(int(allow_throttle))
|
||||
|
||||
if self._experimental_mode:
|
||||
# Draw with acceleration coloring
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
|
||||
elif len(self._exp_gradient.colors) > 1:
|
||||
draw_polygon(self._rect, path_pts, gradient=self._exp_gradient)
|
||||
else:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30))
|
||||
else:
|
||||
# Blend throttle/no throttle colors based on transition
|
||||
blend_factor = round(self._blend_filter.x * 100) / 100
|
||||
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
|
||||
gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=blended_colors,
|
||||
stops=[0.0, 0.5, 1.0],
|
||||
)
|
||||
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90))
|
||||
else:
|
||||
draw_polygon(self._rect, path_pts, gradient=gradient)
|
||||
|
||||
def _draw_lead_indicator(self):
|
||||
tint, _ = driving_confidence.colors()
|
||||
src = rl.Rectangle(0, 0, self._lead_orb.width, self._lead_orb.height)
|
||||
for lead in self._lead_vehicles:
|
||||
if lead.center is None:
|
||||
continue
|
||||
cx, cy = lead.center
|
||||
r = lead.radius
|
||||
alpha = int(np.clip(140 + 115 * (lead.fill_alpha / 255.0), 0, 255))
|
||||
dest = rl.Rectangle(cx, cy, r * 2.0, r * 2.0)
|
||||
rl.draw_texture_pro(self._lead_orb, src, dest, rl.Vector2(r, r), 0.0, rl.Color(tint.r, tint.g, tint.b, alpha))
|
||||
|
||||
@staticmethod
|
||||
def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int:
|
||||
"""Get the index corresponding to the given path height"""
|
||||
if len(pos_x_array) == 0:
|
||||
return 0
|
||||
indices = np.where(pos_x_array <= path_height)[0]
|
||||
return indices[-1] if indices.size > 0 else 0
|
||||
|
||||
def _map_to_screen(self, in_x, in_y, in_z):
|
||||
"""Project a point in car space to screen space"""
|
||||
input_pt = np.array([in_x, in_y, in_z])
|
||||
pt = self._car_space_transform @ input_pt
|
||||
|
||||
if abs(pt[2]) < 1e-6:
|
||||
return None
|
||||
|
||||
x, y = pt[0] / pt[2], pt[1] / pt[2]
|
||||
|
||||
clip = self._clip_region
|
||||
if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
|
||||
return None
|
||||
|
||||
return (x, y)
|
||||
|
||||
def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray:
|
||||
"""Convert 3D line to 2D polygon for rendering."""
|
||||
if line.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Slice points and filter non-negative x-coordinates
|
||||
points = line[:max_idx + 1]
|
||||
points = points[points[:, 0] >= 0]
|
||||
if points.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
N = points.shape[0]
|
||||
# Generate left and right 3D points in one array using broadcasting
|
||||
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
|
||||
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
|
||||
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
|
||||
|
||||
# Transform all points to projected space in one operation
|
||||
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
|
||||
proj = proj.reshape(3, 2, N)
|
||||
left_proj = proj[:, 0, :]
|
||||
right_proj = proj[:, 1, :]
|
||||
|
||||
# Filter points where z is sufficiently large
|
||||
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
|
||||
if not np.any(valid_proj):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Compute screen coordinates
|
||||
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
|
||||
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
|
||||
|
||||
# Define clip region bounds
|
||||
clip = self._clip_region
|
||||
x_min, x_max = clip.x, clip.x + clip.width
|
||||
y_min, y_max = clip.y, clip.y + clip.height
|
||||
|
||||
# Filter points within clip region
|
||||
left_in_clip = (
|
||||
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
|
||||
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
|
||||
)
|
||||
right_in_clip = (
|
||||
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
|
||||
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
|
||||
)
|
||||
both_in_clip = left_in_clip & right_in_clip
|
||||
|
||||
if not np.any(both_in_clip):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Select valid and clipped points
|
||||
left_screen = left_screen[:, both_in_clip]
|
||||
right_screen = right_screen[:, both_in_clip]
|
||||
|
||||
# Handle Y-coordinate inversion on hills
|
||||
if not allow_invert and left_screen.shape[1] > 1:
|
||||
y = left_screen[1, :] # y-coordinates
|
||||
keep = y == np.minimum.accumulate(y)
|
||||
if not np.any(keep):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
left_screen = left_screen[:, keep]
|
||||
right_screen = right_screen[:, keep]
|
||||
|
||||
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
|
||||
|
||||
@staticmethod
|
||||
def _hsla_to_color(h, s, l, a):
|
||||
rgb = colorsys.hls_to_rgb(h, l, s)
|
||||
return rl.Color(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255),
|
||||
int(a * 255)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _blend_colors(begin_colors, end_colors, t):
|
||||
if t >= 1.0:
|
||||
return end_colors
|
||||
if t <= 0.0:
|
||||
return begin_colors
|
||||
|
||||
inv_t = 1.0 - t
|
||||
return [rl.Color(
|
||||
int(inv_t * start.r + t * end.r),
|
||||
int(inv_t * start.g + t * end.g),
|
||||
int(inv_t * start.b + t * end.b),
|
||||
int(inv_t * start.a + t * end.a)
|
||||
) for start, end in zip(begin_colors, end_colors, strict=True)]
|
||||
270
iqpilot/selfdrive/ui/mici/onroad/torque_bar.py
Normal file
270
iqpilot/selfdrive/ui/mici/onroad/torque_bar.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import math
|
||||
import time
|
||||
from functools import wraps
|
||||
from collections import OrderedDict
|
||||
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.selfdrive.ui.mici.onroad import blend_colors
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span
|
||||
TORQUE_ANGLE_SPAN = 12.7
|
||||
ANGLE_ARC_MAX_DEG = 45.0
|
||||
TORQUE_REST_OFFSET = 22
|
||||
TORQUE_REST_HEIGHT = 14
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
def quantized_lru_cache(maxsize=128):
|
||||
def decorator(func):
|
||||
cache = OrderedDict()
|
||||
@wraps(func)
|
||||
def wrapper(r_mid, thickness, a0_deg, a1_deg, **kwargs):
|
||||
# Quantize inputs: balanced for smoothness vs cache effectiveness. The arc is computed at
|
||||
# the origin and translated at the call site, so cx/cy are NOT part of the key — that keeps
|
||||
# the cache hot while the bar translates during a scroll/transition (stock PR #37946).
|
||||
key = (round(r_mid),
|
||||
round(thickness), # 1px precision for smoother height transitions
|
||||
round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
|
||||
round(a1_deg * 10) / 10,
|
||||
tuple(sorted(kwargs.items())))
|
||||
|
||||
if key in cache:
|
||||
cache.move_to_end(key)
|
||||
else:
|
||||
if len(cache) >= maxsize:
|
||||
cache.popitem(last=False)
|
||||
|
||||
result = func(r_mid, thickness, a0_deg, a1_deg, **kwargs)
|
||||
cache[key] = result
|
||||
return cache[key]
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@quantized_lru_cache(maxsize=256)
|
||||
def arc_bar_pts(r_mid: float, thickness: float,
|
||||
a0_deg: float, a1_deg: float,
|
||||
*, max_points: int = 100, cap_segs: int = 10,
|
||||
cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray:
|
||||
"""Return Nx2 np.float32 points for a single closed polygon (rounded thick arc), centered at origin.
|
||||
The caller translates the returned points by (cx, cy) so this can stay cached while the bar moves."""
|
||||
|
||||
def get_cap(left: bool, a_deg: float):
|
||||
# end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
|
||||
# quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
|
||||
|
||||
nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
|
||||
tx, ty = -ny, nx # tangent (CCW)
|
||||
|
||||
mx, my = nx * r_mid, ny * r_mid # mid-point at a1 (origin-centered)
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
|
||||
|
||||
ex = mx + nx * (half - cap_radius)
|
||||
ey = my + ny * (half - cap_radius)
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
|
||||
|
||||
# sweep 90° in the local (t,n) frame: from outer edge toward inside
|
||||
if not left:
|
||||
alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1]
|
||||
else:
|
||||
alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1]
|
||||
cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx,
|
||||
ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny]
|
||||
|
||||
# bottom quarter (inner corner) at a1
|
||||
ex2 = mx + nx * (-half + cap_radius)
|
||||
ey2 = my + ny * (-half + cap_radius)
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
|
||||
|
||||
if not left:
|
||||
alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90
|
||||
else:
|
||||
alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1]
|
||||
cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx,
|
||||
ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny]
|
||||
|
||||
# append to the top quarter
|
||||
if not left:
|
||||
cap_end = np.vstack((cap_end, cap_end_bot))
|
||||
else:
|
||||
cap_end = np.vstack((cap_end_bot, cap_end))
|
||||
|
||||
return cap_end
|
||||
|
||||
if a1_deg < a0_deg:
|
||||
a0_deg, a1_deg = a1_deg, a0_deg
|
||||
half = thickness * 0.5
|
||||
|
||||
cap_radius = min(cap_radius, half)
|
||||
|
||||
span = max(1e-3, a1_deg - a0_deg)
|
||||
|
||||
# pick arc segment count from arc length, clamp to shader points[] budget
|
||||
arc_len = r_mid * math.radians(span)
|
||||
arc_segs = max(6, int(arc_len / px_per_seg))
|
||||
max_arc = (max_points - (4 * cap_segs + 3)) // 2
|
||||
arc_segs = max(6, min(arc_segs, max_arc))
|
||||
|
||||
# outer arc a0→a1
|
||||
ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
|
||||
outer = np.c_[np.cos(ang_o) * (r_mid + half),
|
||||
np.sin(ang_o) * (r_mid + half)]
|
||||
|
||||
# end cap at a1
|
||||
cap_end = get_cap(False, a1_deg)
|
||||
|
||||
# inner arc a1→a0
|
||||
ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
|
||||
inner = np.c_[np.cos(ang_i) * (r_mid - half),
|
||||
np.sin(ang_i) * (r_mid - half)]
|
||||
|
||||
# start cap at a0
|
||||
cap_start = get_cap(True, a0_deg)
|
||||
|
||||
pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
|
||||
|
||||
# Rotate to start from middle of cap for proper triangulation
|
||||
pts = np.roll(pts, cap_segs, axis=0)
|
||||
|
||||
if DEBUG:
|
||||
n = len(pts)
|
||||
idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
|
||||
for i, (x, y) in enumerate(pts):
|
||||
j = (i - idx) % n # rotate the gradient
|
||||
t = j / n
|
||||
color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
|
||||
rl.draw_circle(int(x), int(y), 2, color)
|
||||
|
||||
return pts
|
||||
|
||||
|
||||
class TorqueBar(Widget):
|
||||
def __init__(self, demo: bool = False, scale: float = 1.0, always: bool = False):
|
||||
super().__init__()
|
||||
self._demo = demo
|
||||
self._scale = scale
|
||||
self._always = always
|
||||
self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
|
||||
self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
@staticmethod
|
||||
def resting_bottom(rect: rl.Rectangle, scale: float = 1.0) -> float:
|
||||
"""Lower edge of the arc at zero torque, the bar's lowest resting position."""
|
||||
return rect.y + rect.height - TORQUE_REST_OFFSET * scale
|
||||
|
||||
def update_filter(self, value: float):
|
||||
"""Update the torque filter value (for demo mode)."""
|
||||
self._torque_filter.update(value)
|
||||
|
||||
def _update_state(self):
|
||||
if self._demo:
|
||||
return
|
||||
|
||||
# torque line
|
||||
if ui_state.sm['controlsState'].lateralControlState.which() == 'angleState':
|
||||
controls_state = ui_state.sm['controlsState']
|
||||
car_control = ui_state.sm['carControl']
|
||||
|
||||
if not car_control.latActive:
|
||||
self._torque_filter.update(0.0)
|
||||
else:
|
||||
desired_angle = controls_state.lateralControlState.angleState.steeringAngleDesiredDeg
|
||||
angle_offset = ui_state.sm['vehicleParameters'].angleOffsetAverageDeg
|
||||
# Angle-control cars should render the steering arc from the requested angle
|
||||
# directly, not from curvature/lateral acceleration, which collapses at low speed.
|
||||
# Subtract the vehicleParameters angle offset so the bar reads zero when going straight
|
||||
# despite sensor misalignment.
|
||||
self._torque_filter.update(np.clip(-(desired_angle - angle_offset) / ANGLE_ARC_MAX_DEG, -1, 1))
|
||||
else:
|
||||
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
# adjust y pos with torque
|
||||
torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [TORQUE_REST_OFFSET * self._scale, 26 * self._scale])
|
||||
torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [TORQUE_REST_HEIGHT * self._scale, 56 * self._scale])
|
||||
|
||||
# animate alpha and angle span
|
||||
if not self._demo:
|
||||
self._torque_line_alpha_filter.update(ui_state.status not in (UIStatus.DISENGAGED, UIStatus.LONG_ONLY))
|
||||
else:
|
||||
self._torque_line_alpha_filter.update(1.0)
|
||||
|
||||
torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
|
||||
|
||||
# draw curved line polygon torque bar
|
||||
torque_line_radius = 1200 * self._scale
|
||||
top_angle = -90
|
||||
torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN
|
||||
torque_start_angle = top_angle - torque_bg_angle_span / 2
|
||||
torque_end_angle = top_angle + torque_bg_angle_span / 2
|
||||
# centerline radius & center (you already have these values)
|
||||
mid_r = torque_line_radius + torque_line_height / 2
|
||||
|
||||
cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed
|
||||
cy = rect.y + rect.height + torque_line_radius - torque_line_offset
|
||||
# arc_bar_pts is origin-centered + cached; translate to (cx, cy) here so the cache stays hot
|
||||
# while the bar slides during a scroll/transition.
|
||||
offset = np.array([cx, cy], dtype=np.float32)
|
||||
|
||||
# draw bg torque indicator line
|
||||
bg_pts = arc_bar_pts(mid_r, torque_line_height, torque_start_angle, torque_end_angle, cap_radius=7 * self._scale) + offset
|
||||
draw_polygon(rect, bg_pts, color=torque_line_bg_color)
|
||||
|
||||
# draw torque indicator line
|
||||
a0s = top_angle
|
||||
a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x
|
||||
sl_pts = arc_bar_pts(mid_r, torque_line_height, a0s, a1s, cap_radius=7 * self._scale) + offset
|
||||
|
||||
# draw beautiful gradient from center to 65% of the bg torque bar width
|
||||
start_grad_pt = cx / rect.width
|
||||
if self._torque_filter.x < 0:
|
||||
end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width
|
||||
else:
|
||||
end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width
|
||||
|
||||
# Fade to the requested accent colors as we approach max torque.
|
||||
start_color = blend_colors(
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
|
||||
rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow (match stock)
|
||||
max(0, abs(self._torque_filter.x) - 0.75) * 4,
|
||||
)
|
||||
end_color = blend_colors(
|
||||
rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
|
||||
rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange (match stock)
|
||||
max(0, abs(self._torque_filter.x) - 0.75) * 4,
|
||||
)
|
||||
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
|
||||
|
||||
gradient = Gradient(
|
||||
start=(start_grad_pt, 0),
|
||||
end=(end_grad_pt, 0),
|
||||
colors=[
|
||||
start_color,
|
||||
end_color,
|
||||
],
|
||||
stops=[0.0, 1.0],
|
||||
)
|
||||
|
||||
draw_polygon(rect, sl_pts, gradient=gradient)
|
||||
|
||||
# draw center torque bar dot
|
||||
if abs(self._torque_filter.x) < 0.5:
|
||||
dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2
|
||||
rl.draw_circle(int(cx), int(dot_y), (10 // 2 * self._scale),
|
||||
rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x)))
|
||||
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal file
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from enum import Enum
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import MiciLabel
|
||||
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.common.filter_simple import BounceFilter
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
except ImportError:
|
||||
Params = None
|
||||
|
||||
try:
|
||||
from iqpilot.ui.theme import NeonTheme
|
||||
except ImportError:
|
||||
# Fallback theme if iqpilot layer not available
|
||||
class _FallbackTheme:
|
||||
def glow(self, alpha=255): return rl.Color(0, 255, 245, alpha)
|
||||
def glow_mid(self, alpha=130): return rl.Color(0, 255, 245, alpha)
|
||||
def glow_outer(self, alpha=45): return rl.Color(0, 255, 245, alpha)
|
||||
def bg(self): return rl.Color(0, 26, 25, 255)
|
||||
def bg_pressed(self): return rl.Color(0, 33, 32, 255)
|
||||
NeonTheme = _FallbackTheme()
|
||||
|
||||
SCROLLING_SPEED_PX_S = 50
|
||||
COMPLICATION_SIZE = 36
|
||||
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
LABEL_HORIZONTAL_PADDING = 40
|
||||
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
|
||||
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
|
||||
|
||||
|
||||
class ScrollState(Enum):
|
||||
PRE_SCROLL = 0
|
||||
SCROLLING = 1
|
||||
POST_SCROLL = 2
|
||||
|
||||
|
||||
class BigCircleButton(Widget):
|
||||
def __init__(self, icon: str, red: bool = False, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__()
|
||||
self._red = red
|
||||
self._icon_offset = icon_offset
|
||||
|
||||
# State
|
||||
self.set_rect(rl.Rectangle(0, 0, 180, 180))
|
||||
self._press_state_enabled = True
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
# Icons
|
||||
self._txt_icon = gui_app.texture(icon, *icon_size)
|
||||
self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
|
||||
|
||||
self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
|
||||
self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180)
|
||||
|
||||
self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
|
||||
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180)
|
||||
|
||||
def set_enable_pressed_state(self, pressed: bool):
|
||||
self._press_state_enabled = pressed
|
||||
|
||||
def _render(self, _):
|
||||
# draw background
|
||||
txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_btn_disabled_bg
|
||||
elif self.is_pressed and self._press_state_enabled:
|
||||
txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
|
||||
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
|
||||
# draw icon
|
||||
icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0]),
|
||||
int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), icon_color)
|
||||
|
||||
|
||||
class BigCircleToggle(BigCircleButton):
|
||||
def __init__(self, icon: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, False, icon_size=icon_size, icon_offset=icon_offset)
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
# State
|
||||
self._checked = False
|
||||
|
||||
# Icons
|
||||
self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
|
||||
self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
|
||||
# draw status icon
|
||||
rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
|
||||
int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2),
|
||||
int(self._rect.y + 5), rl.WHITE)
|
||||
|
||||
|
||||
class BigButton(Widget):
|
||||
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
|
||||
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", icon_size: tuple[int, int] = (64, 64)):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 402, 180))
|
||||
self.text = text
|
||||
self.value = value
|
||||
self._icon_size = icon_size
|
||||
self.set_icon(icon)
|
||||
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._rotate_icon_t: float | None = None
|
||||
|
||||
self._label_font = gui_app.font(FontWeight.DISPLAY)
|
||||
self._value_font = gui_app.font(FontWeight.ROMAN)
|
||||
|
||||
self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
|
||||
font_weight=FontWeight.DISPLAY, color=LABEL_COLOR,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
|
||||
self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
|
||||
font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
|
||||
|
||||
self._load_images()
|
||||
|
||||
# internal state
|
||||
self._scroll_offset = 0 # in pixels
|
||||
self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width
|
||||
self._scroll_timer = 0
|
||||
self._scroll_state = ScrollState.PRE_SCROLL
|
||||
|
||||
def set_icon(self, icon: Union[str, rl.Texture]):
|
||||
self._txt_icon = gui_app.texture(icon, *self._icon_size) if isinstance(icon, str) and len(icon) else icon
|
||||
|
||||
def set_rotate_icon(self, rotate: bool):
|
||||
if rotate and self._rotate_icon_t is not None:
|
||||
return
|
||||
self._rotate_icon_t = rl.get_time() if rotate else None
|
||||
|
||||
def _load_images(self):
|
||||
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
|
||||
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
|
||||
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
|
||||
self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
if len(self.text) < 12:
|
||||
font_size = 64
|
||||
elif len(self.text) < 17:
|
||||
font_size = 48
|
||||
elif len(self.text) < 20:
|
||||
font_size = 42
|
||||
else:
|
||||
font_size = 36
|
||||
|
||||
if self.value:
|
||||
font_size -= 20
|
||||
|
||||
return font_size
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.text = text
|
||||
self._label.set_text(text)
|
||||
|
||||
def set_value(self, value: str):
|
||||
self.value = value
|
||||
self._sub_label.set_text(value)
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
|
||||
def get_text(self):
|
||||
return self.text
|
||||
|
||||
def _update_state(self):
|
||||
# hold on text for a bit, scroll, hold again, reset
|
||||
if self._needs_scroll:
|
||||
"""`dt` should be seconds since last frame (rl.get_frame_time())."""
|
||||
# TODO: this comment is generated by GPT, prob wrong and misused
|
||||
dt = rl.get_frame_time()
|
||||
|
||||
self._scroll_timer += dt
|
||||
if self._scroll_state == ScrollState.PRE_SCROLL:
|
||||
if self._scroll_timer < 0.5:
|
||||
return
|
||||
self._scroll_state = ScrollState.SCROLLING
|
||||
self._scroll_timer = 0
|
||||
|
||||
elif self._scroll_state == ScrollState.SCROLLING:
|
||||
self._scroll_offset -= SCROLLING_SPEED_PX_S * dt
|
||||
# reset when text has completely left the button + 50 px gap
|
||||
# TODO: use global constant for 30+30 px gap
|
||||
# TODO: add std Widget padding option integrated into the self._rect
|
||||
full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30
|
||||
if self._scroll_offset < (self._rect.width - full_len):
|
||||
self._scroll_state = ScrollState.POST_SCROLL
|
||||
self._scroll_timer = 0
|
||||
|
||||
elif self._scroll_state == ScrollState.POST_SCROLL:
|
||||
# wait for a bit before starting to scroll again
|
||||
if self._scroll_timer < 0.75:
|
||||
return
|
||||
self._scroll_state = ScrollState.PRE_SCROLL
|
||||
self._scroll_timer = 0
|
||||
self._scroll_offset = 0
|
||||
|
||||
def _render(self, _):
|
||||
# draw _txt_default_bg
|
||||
txt_bg = self._txt_default_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_disabled_bg
|
||||
elif self.is_pressed:
|
||||
txt_bg = self._txt_hover_bg
|
||||
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
|
||||
# LABEL ------------------------------------------------------------------
|
||||
lx = self._rect.x + LABEL_HORIZONTAL_PADDING
|
||||
ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2
|
||||
|
||||
if self.value:
|
||||
self._sub_label.set_position(lx, ly)
|
||||
ly -= self._sub_label.font_size + 9
|
||||
self._sub_label.render()
|
||||
|
||||
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
self._label.set_color(label_color)
|
||||
self._label.set_position(lx, ly)
|
||||
self._label.render()
|
||||
|
||||
# ICON -------------------------------------------------------------------
|
||||
if self._txt_icon:
|
||||
rotation = 0
|
||||
if self._rotate_icon_t is not None:
|
||||
rotation = (rl.get_time() - self._rotate_icon_t) * 180
|
||||
|
||||
# drop top right with 30px padding
|
||||
x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
|
||||
y = self._rect.y + 30 + self._txt_icon.height / 2
|
||||
source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
|
||||
dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height)
|
||||
origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
|
||||
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE)
|
||||
|
||||
|
||||
class BigToggle(BigButton):
|
||||
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, value, "")
|
||||
self._checked = initial_state
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
self._label.set_font_size(48)
|
||||
|
||||
def _load_images(self):
|
||||
super()._load_images()
|
||||
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
|
||||
self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_pill(self, x: float, y: float, checked: bool):
|
||||
# draw toggle icon top right
|
||||
if checked:
|
||||
rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE)
|
||||
else:
|
||||
rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
|
||||
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
|
||||
y = self._rect.y
|
||||
self._draw_pill(x, y, self._checked)
|
||||
|
||||
|
||||
class BigMultiToggle(BigToggle):
|
||||
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
assert len(options) > 0
|
||||
self._options = options
|
||||
self._select_callback = select_callback
|
||||
|
||||
self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width))
|
||||
# TODO: why isn't this automatic?
|
||||
self._label.set_font_size(self._get_label_font_size())
|
||||
|
||||
self.set_value(self._options[0])
|
||||
|
||||
def _get_label_font_size(self):
|
||||
font_size = super()._get_label_font_size()
|
||||
return font_size - 6
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
cur_idx = self._options.index(self.value)
|
||||
new_idx = (cur_idx + 1) % len(self._options)
|
||||
self.set_value(self._options[new_idx])
|
||||
if self._select_callback:
|
||||
self._select_callback(self.value)
|
||||
|
||||
def _render(self, _):
|
||||
BigButton._render(self, _)
|
||||
|
||||
checked_idx = self._options.index(self.value)
|
||||
|
||||
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
|
||||
y = self._rect.y
|
||||
|
||||
for i in range(len(self._options)):
|
||||
self._draw_pill(x, y, checked_idx == i)
|
||||
y += 35
|
||||
|
||||
|
||||
class BigMultiParamToggle(BigMultiToggle):
|
||||
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, options, toggle_callback, select_callback)
|
||||
self._param = param
|
||||
|
||||
self._params = Params()
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
self.set_value(self._options[self._params.get(self._param) or 0])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
new_idx = self._options.index(self.value)
|
||||
self._params.put_nonblocking(self._param, new_idx)
|
||||
|
||||
|
||||
class BigParamControl(BigToggle):
|
||||
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
self.param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self.params.get_bool(self.param, False))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self.params.put_bool(self.param, self._checked)
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self.params.get_bool(self.param, False))
|
||||
|
||||
|
||||
# TODO: param control base class
|
||||
class BigCircleParamControl(BigCircleToggle):
|
||||
def __init__(self, icon: str, param: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53),
|
||||
icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, toggle_callback, icon_size=icon_size, icon_offset=icon_offset)
|
||||
self._param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self.params.get_bool(self._param, False))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self.params.put_bool(self._param, self._checked)
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self.params.get_bool(self._param, False))
|
||||
|
||||
|
||||
_CHIP_BG = rl.Color(0x30, 0x30, 0x30, 230)
|
||||
_CHIP_TEXT_COLOR = rl.Color(0xDD, 0xDD, 0xDD, 255)
|
||||
_CHIP_H = 28
|
||||
_CHIP_FONT_SIZE = 18
|
||||
_CHIP_H_PAD = 10
|
||||
_CHIP_V_PAD = 5
|
||||
_CHIP_RADIUS = 0.5
|
||||
_CHIP_SPACING = 8
|
||||
_NEON_CORNER_ROUND = 0.28
|
||||
_NEON_CARD_PAD = 18
|
||||
|
||||
|
||||
def _draw_neon_glow_halo(rect: rl.Rectangle, intensity: float = 1.0):
|
||||
segs = 8
|
||||
roundness = _NEON_CORNER_ROUND
|
||||
for expand, alpha in [
|
||||
(14, int(18 * intensity)),
|
||||
(12, int(28 * intensity)),
|
||||
(10, int(40 * intensity)),
|
||||
(8, int(55 * intensity)),
|
||||
(6, int(72 * intensity)),
|
||||
(4, int(95 * intensity)),
|
||||
(2, int(120 * intensity)),
|
||||
]:
|
||||
ex = rl.Rectangle(
|
||||
rect.x - expand, rect.y - expand,
|
||||
rect.width + expand * 2, rect.height + expand * 2,
|
||||
)
|
||||
rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha))
|
||||
|
||||
|
||||
def _draw_neon_glow_border(rect: rl.Rectangle, intensity: float = 1.0):
|
||||
segs = 8
|
||||
roundness = _NEON_CORNER_ROUND
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5,
|
||||
NeonTheme.glow(int(255 * intensity)))
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0,
|
||||
rl.Color(255, 255, 255, int(180 * intensity)))
|
||||
|
||||
|
||||
def _neon_title_font_size(text: str, has_chips: bool) -> int:
|
||||
if len(text) < 10:
|
||||
size = 52
|
||||
elif len(text) < 14:
|
||||
size = 42
|
||||
elif len(text) < 18:
|
||||
size = 36
|
||||
else:
|
||||
size = 30
|
||||
if has_chips:
|
||||
size = min(size, 36)
|
||||
return size
|
||||
|
||||
|
||||
class NeonBigButton(Widget):
|
||||
|
||||
CARD_W = 310
|
||||
CARD_H = 160
|
||||
|
||||
def __init__(self, title: str, chips: list[str] | None = None,
|
||||
click_callback: Callable | None = None):
|
||||
super().__init__()
|
||||
self._title_text = title
|
||||
self._chips: list[str] = chips or []
|
||||
self._click_callback = click_callback
|
||||
self._born: float = time.monotonic()
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, self.CARD_W, self.CARD_H))
|
||||
|
||||
self._label = MiciLabel(
|
||||
title,
|
||||
font_size=_neon_title_font_size(title, bool(chips)),
|
||||
width=self.CARD_W - _NEON_CARD_PAD * 2,
|
||||
font_weight=FontWeight.DISPLAY,
|
||||
color=LABEL_COLOR,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
wrap_text=True,
|
||||
elide_right=False,
|
||||
)
|
||||
self._chip_font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
def set_chips(self, chips: list[str]):
|
||||
self._chips = chips
|
||||
# Re-size title now that we know if chips are present
|
||||
self._label.set_font_size(_neon_title_font_size(self._title_text, bool(chips)))
|
||||
|
||||
def set_click_callback(self, cb: Callable | None):
|
||||
self._click_callback = cb
|
||||
|
||||
def _glow_intensity(self) -> float:
|
||||
t = time.monotonic() - self._born
|
||||
return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0))
|
||||
|
||||
def _render_chips(self, start_x: float, start_y: float):
|
||||
x = start_x
|
||||
for chip in self._chips:
|
||||
text_w = int(measure_text_cached(self._chip_font, chip, _CHIP_FONT_SIZE).x)
|
||||
w = text_w + _CHIP_H_PAD * 2
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(x, start_y, w, _CHIP_H),
|
||||
_CHIP_RADIUS, 4, _CHIP_BG)
|
||||
text_y = start_y + (_CHIP_H - _CHIP_FONT_SIZE) // 2
|
||||
rl.draw_text_ex(
|
||||
self._chip_font,
|
||||
chip,
|
||||
rl.Vector2(x + _CHIP_H_PAD, text_y),
|
||||
_CHIP_FONT_SIZE,
|
||||
0,
|
||||
_CHIP_TEXT_COLOR,
|
||||
)
|
||||
x += w + _CHIP_SPACING
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
intensity = self._glow_intensity()
|
||||
_draw_neon_glow_halo(rect, intensity)
|
||||
bg = NeonTheme.bg_pressed() if self.is_pressed else NeonTheme.bg()
|
||||
rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, bg)
|
||||
_draw_neon_glow_border(rect, intensity)
|
||||
chips_h = (_CHIP_H + 6) if self._chips else 0
|
||||
title_rect = rl.Rectangle(
|
||||
rect.x + _NEON_CARD_PAD,
|
||||
rect.y + _NEON_CARD_PAD,
|
||||
rect.width - _NEON_CARD_PAD * 2,
|
||||
rect.height - _NEON_CARD_PAD - chips_h,
|
||||
)
|
||||
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
self._label.set_color(label_color)
|
||||
self._label.render(title_rect)
|
||||
if self._chips:
|
||||
chip_y = rect.y + rect.height - _CHIP_H - _NEON_CARD_PAD // 2
|
||||
self._render_chips(rect.x + _NEON_CARD_PAD, chip_y)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
if self._click_callback:
|
||||
self._click_callback()
|
||||
|
||||
|
||||
class NeonBigParamToggle(NeonBigButton):
|
||||
def __init__(self, title: str, param: str,
|
||||
sub_chips: list[str] | None = None,
|
||||
toggle_callback: Callable | None = None):
|
||||
super().__init__(title, chips=[tr("disabled")]) # pre-set so layout reserves chip space
|
||||
self._param = param
|
||||
self._params = Params() if Params else None
|
||||
self._sub_chips: list[str] = sub_chips or []
|
||||
self._toggle_callback = toggle_callback
|
||||
self._checked: bool = False
|
||||
|
||||
self._load_value()
|
||||
self._rebuild_chips()
|
||||
|
||||
def set_sub_chips(self, sub_chips: list[str]):
|
||||
self._sub_chips = sub_chips
|
||||
self._rebuild_chips()
|
||||
|
||||
def _load_value(self):
|
||||
if self._params:
|
||||
self._checked = self._params.get_bool(self._param, False)
|
||||
|
||||
def _rebuild_chips(self):
|
||||
state = "enabled" if self._checked else "disabled"
|
||||
self.set_chips([state] + self._sub_chips)
|
||||
|
||||
def _glow_intensity(self) -> float:
|
||||
return super()._glow_intensity() if self._checked else 0.25
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
super()._render(rect)
|
||||
if not self._checked:
|
||||
rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, rl.Color(0, 0, 0, 120))
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
self._checked = not self._checked
|
||||
if self._params:
|
||||
self._params.put_bool(self._param, self._checked)
|
||||
self._rebuild_chips()
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def refresh(self):
|
||||
self._load_value()
|
||||
self._rebuild_chips()
|
||||
|
||||
|
||||
class NeonBigCircleParamControl(BigCircleParamControl):
|
||||
def __init__(self, icon: str, param: str,
|
||||
toggle_callback: Callable | None = None,
|
||||
icon_size: tuple[int, int] = (64, 53),
|
||||
icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, param, toggle_callback=toggle_callback,
|
||||
icon_size=icon_size, icon_offset=icon_offset)
|
||||
self._born = time.monotonic()
|
||||
|
||||
def _glow_intensity(self) -> float:
|
||||
if not self._checked:
|
||||
return 0.25
|
||||
t = time.monotonic() - self._born
|
||||
return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0))
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
intensity = self._glow_intensity()
|
||||
roundness = 1.0
|
||||
segs = 12
|
||||
|
||||
for expand, alpha in [
|
||||
(12, int(14 * intensity)),
|
||||
(10, int(22 * intensity)),
|
||||
(8, int(35 * intensity)),
|
||||
(6, int(50 * intensity)),
|
||||
(4, int(70 * intensity)),
|
||||
(2, int(95 * intensity)),
|
||||
]:
|
||||
ex = rl.Rectangle(rect.x - expand, rect.y - expand,
|
||||
rect.width + expand * 2, rect.height + expand * 2)
|
||||
rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha))
|
||||
super()._render(rect)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5,
|
||||
NeonTheme.glow(int(255 * intensity)))
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0,
|
||||
rl.Color(255, 255, 255, int(160 * intensity)))
|
||||
|
||||
class NeonBigMultiToggle(NeonBigButton):
|
||||
def __init__(self, title: str, options: list[str],
|
||||
select_callback: Callable | None = None):
|
||||
assert len(options) > 0
|
||||
super().__init__(title, chips=[options[0]])
|
||||
self._options = options
|
||||
self._value = options[0]
|
||||
self._select_callback = select_callback
|
||||
|
||||
def set_value(self, value: str):
|
||||
if value in self._options:
|
||||
self._value = value
|
||||
else:
|
||||
self._value = self._options[0]
|
||||
self.set_chips([self._value])
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self._value
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
idx = self._options.index(self._value)
|
||||
self._value = self._options[(idx + 1) % len(self._options)]
|
||||
self.set_chips([self._value])
|
||||
if self._select_callback:
|
||||
self._select_callback(self._value)
|
||||
|
||||
|
||||
class NeonBigMultiParamToggle(NeonBigMultiToggle):
|
||||
|
||||
def __init__(self, title: str, param: str, options: list[str],
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(title, options, select_callback)
|
||||
self._param = param
|
||||
self._params = Params() if Params else None
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
if self._params:
|
||||
try:
|
||||
idx = int(self._params.get(self._param) or 0)
|
||||
idx = max(0, min(idx, len(self._options) - 1))
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
self.set_value(self._options[idx])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
idx = self._options.index(self._value)
|
||||
if self._params:
|
||||
self._params.put_nonblocking(self._param, idx)
|
||||
|
||||
def refresh(self):
|
||||
self._load_value()
|
||||
|
||||
|
||||
class NeonMappedParamToggle(NeonBigMultiToggle):
|
||||
def __init__(self, title: str, param: str, options: list[str], values: list[int],
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(title, options, select_callback)
|
||||
assert len(options) == len(values)
|
||||
self._param = param
|
||||
self._values = values
|
||||
self._params = Params() if Params else None
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
if self._params:
|
||||
try:
|
||||
current = int(self._params.get(self._param, return_default=True) or 0)
|
||||
idx = self._values.index(current)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
self.set_value(self._options[idx])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
idx = self._options.index(self._value)
|
||||
if self._params:
|
||||
self._params.put_nonblocking(self._param, self._values[idx])
|
||||
|
||||
def refresh(self):
|
||||
self._load_value()
|
||||
|
||||
|
||||
class NeonFloatMappedParamToggle(NeonBigMultiToggle):
|
||||
|
||||
def __init__(self, title: str, param: str, options: list[str], values: list[float],
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(title, options, select_callback)
|
||||
assert len(options) == len(values)
|
||||
self._param = param
|
||||
self._values = values
|
||||
self._params = Params() if Params else None
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
if self._params:
|
||||
try:
|
||||
current_val = float(self._params.get(self._param, return_default=True) or 0)
|
||||
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - current_val))
|
||||
except (TypeError, ValueError):
|
||||
idx = 1 if len(self._values) > 1 else 0
|
||||
self.set_value(self._options[idx])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
idx = self._options.index(self._value)
|
||||
if self._params:
|
||||
self._params.put_nonblocking(self._param, str(self._values[idx]))
|
||||
|
||||
def refresh(self):
|
||||
self._load_value()
|
||||
|
||||
|
||||
|
||||
class DrumPickerButton(NeonBigButton):
|
||||
def __init__(self, title: str, options: list[str]):
|
||||
super().__init__(title, chips=[""])
|
||||
self._options = options
|
||||
|
||||
def _read_current(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def _write_value(self, value: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self):
|
||||
self.set_chips([self._read_current()])
|
||||
|
||||
def _on_picked(self, value: str):
|
||||
self._write_value(value)
|
||||
self.set_chips([value])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
# Bypass NeonBigButton cycle — open drum picker instead
|
||||
Widget._handle_mouse_release(self, mouse_pos)
|
||||
if getattr(self, '_swiping_away', False):
|
||||
return
|
||||
from iqpilot.selfdrive.ui.mici.widgets.drum_picker import DrumPickerDialog
|
||||
from iqpilot.system.ui.lib.application import gui_app as _app
|
||||
dlg = DrumPickerDialog(
|
||||
title=self._title_text.lower(),
|
||||
options=self._options,
|
||||
current=self._read_current(),
|
||||
confirm_callback=self._on_picked,
|
||||
)
|
||||
_app.set_modal_overlay(dlg)
|
||||
|
||||
|
||||
class DrumParamButton(DrumPickerButton):
|
||||
def __init__(self, title: str, param: str, options: list[str]):
|
||||
self._param = param
|
||||
self._params = Params() if Params else None
|
||||
super().__init__(title, options)
|
||||
self.refresh()
|
||||
|
||||
def _read_current(self) -> str:
|
||||
try:
|
||||
idx = int(self._params.get(self._param, return_default=True) or 0)
|
||||
idx = max(0, min(idx, len(self._options) - 1))
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
return self._options[idx]
|
||||
|
||||
def _write_value(self, value: str):
|
||||
if value in self._options and self._params:
|
||||
self._params.put_nonblocking(self._param, self._options.index(value))
|
||||
|
||||
|
||||
class DrumMappedParamButton(DrumPickerButton):
|
||||
def __init__(self, title: str, param: str, options: list[str], values: list[int]):
|
||||
assert len(options) == len(values)
|
||||
self._param = param
|
||||
self._values = values
|
||||
self._params = Params() if Params else None
|
||||
super().__init__(title, options)
|
||||
self.refresh()
|
||||
|
||||
def _read_current(self) -> str:
|
||||
try:
|
||||
raw = int(self._params.get(self._param, return_default=True) or 0)
|
||||
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw))
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
return self._options[idx]
|
||||
|
||||
def _write_value(self, value: str):
|
||||
if value in self._options and self._params:
|
||||
idx = self._options.index(value)
|
||||
self._params.put_nonblocking(self._param, int(self._values[idx]))
|
||||
|
||||
|
||||
class DrumFloatMappedParamButton(DrumPickerButton):
|
||||
def __init__(self, title: str, param: str, options: list[str], values: list[float]):
|
||||
assert len(options) == len(values)
|
||||
self._param = param
|
||||
self._values = values
|
||||
self._params = Params() if Params else None
|
||||
super().__init__(title, options)
|
||||
self.refresh()
|
||||
|
||||
def _read_current(self) -> str:
|
||||
try:
|
||||
raw = float(self._params.get(self._param, return_default=True) or 0)
|
||||
idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw))
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
return self._options[idx]
|
||||
|
||||
def _write_value(self, value: str):
|
||||
if value in self._options and self._params:
|
||||
idx = self._options.index(value)
|
||||
self._params.put_nonblocking(self._param, float(self._values[idx]))
|
||||
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal file
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import abc
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
from iqpilot.system.ui.widgets import Widget, NavWidget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel, gui_label
|
||||
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller
|
||||
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.side_button import SideButton
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PADDING = 20
|
||||
|
||||
|
||||
class BigDialogBase(NavWidget, abc.ABC):
|
||||
def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
self.set_back_callback(gui_app.pop_widget)
|
||||
|
||||
self._right_btn = None
|
||||
if right_btn:
|
||||
def right_btn_callback_wrapper():
|
||||
gui_app.pop_widget()
|
||||
if right_btn_callback:
|
||||
right_btn_callback()
|
||||
|
||||
self._right_btn = SideButton(right_btn)
|
||||
self._right_btn.set_click_callback(right_btn_callback_wrapper)
|
||||
# move to right side
|
||||
self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
|
||||
|
||||
def _layout(self) -> None:
|
||||
rl.draw_rectangle_rec(rl.Rectangle(0, 0, gui_app.width, gui_app.height), rl.Color(8, 9, 10, 255))
|
||||
|
||||
def _render(self, _):
|
||||
if self._right_btn:
|
||||
self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
|
||||
self._right_btn.render()
|
||||
|
||||
|
||||
class BigDialog(BigDialogBase):
|
||||
def __init__(self,
|
||||
title: str,
|
||||
description: str,
|
||||
right_btn: str | None = None,
|
||||
right_btn_callback: Callable | None = None):
|
||||
super().__init__(right_btn, right_btn_callback)
|
||||
self._title = title
|
||||
self._description = description
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
|
||||
# draw title
|
||||
# TODO: we desperately need layouts
|
||||
# TODO: coming up with these numbers manually is a pain and not scalable
|
||||
# TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
|
||||
max_width = self._rect.width - PADDING * 2
|
||||
if self._right_btn:
|
||||
max_width -= self._right_btn._rect.width
|
||||
|
||||
title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width)))
|
||||
title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50)
|
||||
text_x_offset = 0
|
||||
title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
|
||||
int(self._rect.y + PADDING),
|
||||
int(max_width),
|
||||
int(title_size.y))
|
||||
gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
# draw description
|
||||
desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width)))
|
||||
desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30)
|
||||
desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
|
||||
int(self._rect.y + self._rect.height / 3),
|
||||
int(max_width),
|
||||
int(desc_size.y))
|
||||
# TODO: text align doesn't seem to work properly with newlines
|
||||
gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
class BigConfirmationDialogV2(BigDialogBase):
|
||||
def __init__(self, title: str, icon: str, red: bool = False,
|
||||
exit_on_confirm: bool = True,
|
||||
confirm_callback: Callable | None = None):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._exit_on_confirm = exit_on_confirm
|
||||
|
||||
icon_txt = gui_app.texture(icon, 64, 53)
|
||||
self._slider: BigSlider | RedBigSlider
|
||||
if red:
|
||||
self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
|
||||
else:
|
||||
self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
|
||||
self._slider.set_enabled(lambda: self.enabled and not self._swiping_away) # self.enabled for nav stack
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._exit_on_confirm:
|
||||
gui_app.pop_widget()
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self._swiping_away and not self._slider.confirmed:
|
||||
self._slider.reset()
|
||||
|
||||
def _render(self, _):
|
||||
self._slider.render(self._rect)
|
||||
|
||||
|
||||
class BigInputDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.2
|
||||
BACKSPACE_RATE = 25 # hz
|
||||
TEXT_INPUT_SIZE = 35
|
||||
|
||||
def __init__(self,
|
||||
hint: str,
|
||||
default_text: str = "",
|
||||
minimum_length: int = 1,
|
||||
confirm_callback: Callable[[str], None] | None = None):
|
||||
super().__init__(None, None)
|
||||
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
|
||||
font_weight=FontWeight.MEDIUM)
|
||||
self._keyboard = MiciKeyboard()
|
||||
self._keyboard.set_text(default_text)
|
||||
self._keyboard.set_enabled(lambda: self.enabled) # for nav stack
|
||||
self._minimum_length = minimum_length
|
||||
|
||||
self._backspace_held_time: float | None = None
|
||||
|
||||
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
|
||||
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 42, 36)
|
||||
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
# rects for top buttons
|
||||
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def confirm_callback_wrapper():
|
||||
text = self._keyboard.text()
|
||||
gui_app.pop_widget()
|
||||
if confirm_callback:
|
||||
confirm_callback(text)
|
||||
self._confirm_callback = confirm_callback_wrapper
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
|
||||
if self._backspace_held_time is None:
|
||||
self._backspace_held_time = rl.get_time()
|
||||
|
||||
if rl.get_time() - self._backspace_held_time > 0.5:
|
||||
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
|
||||
self._keyboard.backspace()
|
||||
|
||||
else:
|
||||
self._backspace_held_time = None
|
||||
|
||||
def _render(self, _):
|
||||
# draw current text so far below everything. text floats left but always stays in view
|
||||
text = self._keyboard.text()
|
||||
candidate_char = self._keyboard.get_candidate_character()
|
||||
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
|
||||
|
||||
bg_block_margin = 5
|
||||
text_x = PADDING * 2 + self._enter_img.width + bg_block_margin
|
||||
text_field_rect = rl.Rectangle(text_x, int(self._rect.y + PADDING) - bg_block_margin,
|
||||
int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width) - bg_block_margin * 2,
|
||||
int(text_size.y))
|
||||
|
||||
# draw text input
|
||||
# push text left with a gradient on left side if too long
|
||||
if text_size.x > text_field_rect.width:
|
||||
text_x -= text_size.x - text_field_rect.width
|
||||
|
||||
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE)
|
||||
|
||||
# draw grayed out character user is hovering over
|
||||
if candidate_char:
|
||||
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
|
||||
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
|
||||
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128))
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# draw gradient on left side to indicate more text
|
||||
if text_size.x > text_field_rect.width:
|
||||
rl.draw_rectangle_gradient_h(int(text_field_rect.x), int(text_field_rect.y), 80, int(text_field_rect.height),
|
||||
rl.BLACK, rl.BLANK)
|
||||
|
||||
# draw cursor
|
||||
if text:
|
||||
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
|
||||
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_field_rect.y), 4, int(text_size.y)),
|
||||
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
|
||||
|
||||
# draw backspace icon with nice fade
|
||||
self._backspace_img_alpha.update(255 * bool(text))
|
||||
if self._backspace_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
|
||||
rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
|
||||
|
||||
if not text and self._hint_label.text and not candidate_char:
|
||||
# draw description if no text entered yet and not drawing candidate char
|
||||
self._hint_label.render(text_field_rect)
|
||||
|
||||
# TODO: move to update state
|
||||
# make rect take up entire area so it's easier to click
|
||||
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
|
||||
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
|
||||
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
|
||||
|
||||
self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
|
||||
if self._enter_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
|
||||
rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
|
||||
|
||||
# keyboard goes over everything
|
||||
self._keyboard.render(self._rect)
|
||||
|
||||
# draw debugging rect bounds
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# TODO: need to track where press was so enter and back can activate on release rather than press
|
||||
# or turn into icon widgets :eyes_open:
|
||||
# handle backspace icon click
|
||||
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
|
||||
self._keyboard.backspace()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
|
||||
# handle enter icon click
|
||||
self._confirm_callback()
|
||||
|
||||
|
||||
class BigDialogOptionButton(Widget):
|
||||
HEIGHT = 64
|
||||
SELECTED_HEIGHT = 74
|
||||
|
||||
def __init__(self, option: str):
|
||||
super().__init__()
|
||||
self.option = option
|
||||
self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), self.HEIGHT))
|
||||
|
||||
self._selected = False
|
||||
|
||||
self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
|
||||
font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
scroll=True)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._label.reset_scroll()
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self._selected = selected
|
||||
self._rect.height = self.SELECTED_HEIGHT if selected else self.HEIGHT
|
||||
|
||||
def _render(self, _):
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
|
||||
# FIXME: offset x by -45 because scroller centers horizontally
|
||||
if self._selected:
|
||||
self._label.set_font_size(self.SELECTED_HEIGHT)
|
||||
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
self._label.set_font_weight(FontWeight.DISPLAY)
|
||||
else:
|
||||
self._label.set_font_size(self.HEIGHT)
|
||||
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
|
||||
self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
|
||||
|
||||
self._label.render(self._rect)
|
||||
|
||||
|
||||
class BigMultiOptionDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.1
|
||||
|
||||
def __init__(self, options: list[str], default: str | None,
|
||||
right_btn: str | None = 'check', right_btn_callback: Callable[[], None] | None = None):
|
||||
super().__init__(right_btn, right_btn_callback=right_btn_callback)
|
||||
self._options = options
|
||||
if default is not None:
|
||||
assert default in options
|
||||
|
||||
self._default_option: str | None = default
|
||||
self._selected_option: str = self._default_option or (options[0] if len(options) > 0 else "")
|
||||
self._last_selected_option: str = self._selected_option
|
||||
|
||||
# Widget doesn't differentiate between click and drag
|
||||
self._can_click = True
|
||||
|
||||
self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0, snap_items=True)
|
||||
if self._right_btn is not None:
|
||||
self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
|
||||
|
||||
for option in options:
|
||||
self._scroller.add_widget(BigDialogOptionButton(option))
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
if self._default_option is not None:
|
||||
self._on_option_selected(self._default_option)
|
||||
|
||||
def get_selected_option(self) -> str:
|
||||
return self._selected_option
|
||||
|
||||
def _on_option_selected(self, option: str):
|
||||
y_pos = 0.0
|
||||
for btn in self._scroller._items:
|
||||
btn = cast(BigDialogOptionButton, btn)
|
||||
if btn.option == option:
|
||||
rect_center_y = self._rect.y + self._rect.height / 2
|
||||
if btn._selected:
|
||||
height = btn.rect.height
|
||||
else:
|
||||
# when selecting an option under current, account for changing heights
|
||||
btn_center_y = btn.rect.y + btn.rect.height / 2 # not accurate, just to determine direction
|
||||
height_offset = BigDialogOptionButton.SELECTED_HEIGHT - BigDialogOptionButton.HEIGHT
|
||||
height = (BigDialogOptionButton.HEIGHT - height_offset) if rect_center_y < btn_center_y else BigDialogOptionButton.SELECTED_HEIGHT
|
||||
y_pos = rect_center_y - (btn.rect.y + height / 2)
|
||||
break
|
||||
|
||||
self._scroller.scroll_to(-y_pos)
|
||||
|
||||
def _selected_option_changed(self):
|
||||
pass
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
self._can_click = True
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
# # TODO: add generic _handle_mouse_click handler to Widget
|
||||
if not self._scroller.scroll_panel.is_touch_valid():
|
||||
self._can_click = False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
if not self._can_click:
|
||||
return
|
||||
|
||||
# select current option
|
||||
for btn in self._scroller._items:
|
||||
btn = cast(BigDialogOptionButton, btn)
|
||||
if btn.option == self._selected_option:
|
||||
self._on_option_selected(btn.option)
|
||||
break
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# get selection by whichever button is closest to center
|
||||
center_y = self._rect.y + self._rect.height / 2
|
||||
closest_btn = (None, float('inf'))
|
||||
for btn in self._scroller._items:
|
||||
dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
|
||||
if dist_y < closest_btn[1]:
|
||||
closest_btn = (btn, dist_y)
|
||||
|
||||
if closest_btn[0]:
|
||||
for btn in self._scroller._items:
|
||||
btn.set_selected(btn.option == closest_btn[0].option)
|
||||
self._selected_option = closest_btn[0].option
|
||||
|
||||
# Signal to subclasses if selection changed
|
||||
if self._selected_option != self._last_selected_option:
|
||||
self._selected_option_changed()
|
||||
self._last_selected_option = self._selected_option
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
|
||||
class BigDialogButton(BigButton):
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
|
||||
super().__init__(text, value, icon)
|
||||
self._description = description
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
dlg = BigDialog(self.text, self._description)
|
||||
gui_app.push_widget(dlg)
|
||||
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal file
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
DrumPickerDialog — iOS-style drum-roll value selector.
|
||||
|
||||
Layout (matches concept image):
|
||||
- Title label centred at top with a white underline
|
||||
- 5 visible values: [n-2] [n-1] [ N ] [n+1] [n+2]
|
||||
- Centre value: large bold white, flanked by two thin vertical bars
|
||||
- Outer values fade with distance (alpha 0.35 → 0.22 → 0.10)
|
||||
- Optional unit label below centre
|
||||
- Drag left/right or tap arrows to change value; confirm on release / tap elsewhere
|
||||
"""
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import DialogResult
|
||||
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
|
||||
|
||||
try:
|
||||
from iqpilot.ui.theme import NeonTheme
|
||||
except ImportError:
|
||||
class _FT:
|
||||
def glow(self, a=255): return rl.Color(0, 255, 245, a)
|
||||
def glow_outer(self, a=45): return rl.Color(0, 255, 245, a)
|
||||
NeonTheme = _FT()
|
||||
|
||||
|
||||
# ── Visual constants ───────────────────────────────────────────────────────────
|
||||
_CENTRE_FONT_SIZE = 36 # fits comfortably inside the slot walls
|
||||
_SIDE1_FONT_SIZE = 30 # one step away
|
||||
_SIDE2_FONT_SIZE = 20 # two steps away
|
||||
_UNIT_FONT_SIZE = 18
|
||||
_TITLE_FONT_SIZE = 24
|
||||
|
||||
_CENTRE_ALPHA = 255
|
||||
_SIDE1_ALPHA = int(255 * 0.45)
|
||||
_SIDE2_ALPHA = int(255 * 0.18)
|
||||
|
||||
_BAR_W = 2 # vertical separator bar width
|
||||
_BAR_H_FRAC = 0.55 # bar height as fraction of dialog height
|
||||
_SLOT_W = 90 # width of the centre slot (determines bar positions)
|
||||
_SLOT_SPACING = 115 # horizontal distance between adjacent value centres
|
||||
# 1 index = 1 slot spacing in pixels — drag exactly one slot width to move one step
|
||||
_DRAG_SCALE = 1.0 / _SLOT_SPACING
|
||||
|
||||
|
||||
class DrumPickerDialog(BigDialogBase):
|
||||
"""
|
||||
Full-screen drum-roll value picker.
|
||||
Drag left/right to scroll values; releasing snaps and calls confirm_callback
|
||||
immediately (live preview) but keeps the dialog open.
|
||||
Swipe down (back gesture) to dismiss.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
title: str,
|
||||
options: list[str],
|
||||
current: str,
|
||||
unit: str = "",
|
||||
confirm_callback: Callable[[str], None] | None = None):
|
||||
super().__init__()
|
||||
assert len(options) > 0
|
||||
self._title = title
|
||||
self._options = options
|
||||
self._unit = unit
|
||||
self._confirm_callback = confirm_callback
|
||||
|
||||
# Current index with a smooth bounce filter for animation
|
||||
try:
|
||||
idx = options.index(current)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
dt = 1 / gui_app.target_fps
|
||||
self._idx: float = float(idx)
|
||||
self._idx_filter = BounceFilter(float(idx), 0.06, dt, bounce=3)
|
||||
self._idx_filter.x = float(idx)
|
||||
|
||||
# Press/release zoom scale — smoothly goes 1.0 → 1.12 on touch-down,
|
||||
# back to 1.0 on release, giving a tactile "grab" feel.
|
||||
self._scale_target: float = 1.0
|
||||
self._scale_filter = FirstOrderFilter(1.0, 0.04, dt)
|
||||
|
||||
# Drag state
|
||||
self._drag_start_x: float | None = None
|
||||
self._drag_start_idx: float = float(idx)
|
||||
self._dragging: bool = False
|
||||
|
||||
# Pre-load fonts
|
||||
self._font_display = gui_app.font(FontWeight.DISPLAY)
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
self._font_roman = gui_app.font(FontWeight.ROMAN)
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _current_idx(self) -> int:
|
||||
return max(0, min(round(self._idx_filter.x), len(self._options) - 1))
|
||||
|
||||
def selected_value(self) -> str:
|
||||
return self._options[self._current_idx()]
|
||||
|
||||
def _clamp_idx(self, v: float) -> float:
|
||||
return max(0.0, min(v, float(len(self._options) - 1)))
|
||||
|
||||
# ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# Kill any in-progress bounce so the grab starts from exactly
|
||||
# where the value visually is right now — no jump on first drag.
|
||||
snapped = float(round(self._idx_filter.x))
|
||||
self._idx = snapped
|
||||
self._idx_filter.x = snapped
|
||||
self._idx_filter.velocity.x = 0.0 # kill bounce velocity
|
||||
self._drag_start_x = mouse_pos.x
|
||||
self._drag_start_idx = snapped
|
||||
self._dragging = False
|
||||
self._scale_target = 1.12 # zoom in on touch-down
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
if self._drag_start_x is None:
|
||||
return
|
||||
delta = self._drag_start_x - mouse_event.pos.x # drag left = higher idx
|
||||
if abs(delta) > 8:
|
||||
self._dragging = True
|
||||
if self._dragging:
|
||||
self._idx = self._clamp_idx(self._drag_start_idx + delta * _DRAG_SCALE)
|
||||
self._idx_filter.x = self._idx # snap immediately while dragging
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
# Swipe-down = dismiss, calling callback with final value first
|
||||
if self._swiping_away:
|
||||
self._idx = float(round(self._idx_filter.x))
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback(self.selected_value())
|
||||
self._drag_start_x = None
|
||||
self._dragging = False
|
||||
self._ret = DialogResult.CONFIRM
|
||||
return
|
||||
# Normal release: set the snap target and let BounceFilter glide there.
|
||||
# Do NOT hard-set _idx_filter.x — that would teleport instead of animate.
|
||||
self._idx = float(round(self._idx_filter.x))
|
||||
self._drag_start_x = None
|
||||
self._dragging = False
|
||||
self._scale_target = 1.0 # zoom back out on release
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback(self.selected_value())
|
||||
|
||||
# ── Update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
# While dragging, filter is slaved directly to _idx (instant follow).
|
||||
# On release, _dragging=False so filter animates toward _idx with bounce.
|
||||
if self._dragging:
|
||||
self._idx_filter.x = self._idx
|
||||
self._idx_filter.velocity.x = 0.0
|
||||
else:
|
||||
self._idx_filter.update(self._idx)
|
||||
self._scale_filter.update(self._scale_target)
|
||||
|
||||
# ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _render(self, _) -> DialogResult:
|
||||
rect = self._rect
|
||||
cx = rect.x + rect.width / 2
|
||||
cy = rect.y + rect.height / 2
|
||||
|
||||
# ── Dark background ───────────────────────────────────────────────────────
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
|
||||
rl.Color(0, 0, 0, 230))
|
||||
|
||||
# ── Vertical separator bars (drawn first so title renders above) ──────────
|
||||
bar_h = int(rect.height * _BAR_H_FRAC)
|
||||
bar_y = int(cy - bar_h / 2)
|
||||
bar_col = NeonTheme.glow(60)
|
||||
rl.draw_rectangle(int(cx - _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
|
||||
rl.draw_rectangle(int(cx + _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
|
||||
|
||||
# ── Title — centred above the bars, no underline ──────────────────────────
|
||||
title_w = int(measure_text_cached(self._font_medium, self._title, _TITLE_FONT_SIZE).x)
|
||||
tx = int(cx - title_w / 2)
|
||||
ty = int(rect.y + (bar_y - rect.y) / 2 - _TITLE_FONT_SIZE / 2)
|
||||
rl.draw_text_ex(self._font_medium, self._title,
|
||||
rl.Vector2(tx, ty), _TITLE_FONT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, 220))
|
||||
|
||||
# ── Value row ─────────────────────────────────────────────────────────────
|
||||
animated_idx = self._idx_filter.x
|
||||
n = len(self._options)
|
||||
|
||||
# centre_int is always the settled target — never flips mid-animation.
|
||||
# frac drives pixel offset only (how far filter still needs to travel).
|
||||
centre_int = int(self._idx)
|
||||
frac = animated_idx - centre_int
|
||||
|
||||
for offset in [-2, -1, 0, 1, 2]:
|
||||
# Which option index lives in this visual slot?
|
||||
opt_idx = centre_int + offset
|
||||
if opt_idx < 0 or opt_idx >= n:
|
||||
continue
|
||||
|
||||
# Pixel offset from screen centre: slot position minus fractional drift
|
||||
draw_x_off = (offset - frac) * _SLOT_SPACING
|
||||
|
||||
label = self._options[opt_idx]
|
||||
abs_offset = abs(offset - frac) # fractional distance from visual centre
|
||||
|
||||
# Font size and alpha interpolated by distance
|
||||
if abs_offset < 0.5:
|
||||
font_sz = int(_SIDE1_FONT_SIZE + (_CENTRE_FONT_SIZE - _SIDE1_FONT_SIZE) * (1 - abs_offset * 2))
|
||||
alpha = int(_SIDE1_ALPHA + (_CENTRE_ALPHA - _SIDE1_ALPHA) * (1 - abs_offset * 2))
|
||||
weight = FontWeight.DISPLAY
|
||||
elif abs_offset < 1.5:
|
||||
t = abs_offset - 0.5
|
||||
font_sz = int(_SIDE2_FONT_SIZE + (_SIDE1_FONT_SIZE - _SIDE2_FONT_SIZE) * (1 - t))
|
||||
alpha = int(_SIDE2_ALPHA + (_SIDE1_ALPHA - _SIDE2_ALPHA) * (1 - t))
|
||||
weight = FontWeight.DISPLAY
|
||||
else:
|
||||
font_sz = _SIDE2_FONT_SIZE
|
||||
alpha = _SIDE2_ALPHA
|
||||
weight = FontWeight.DISPLAY
|
||||
|
||||
# Apply press-zoom scale to the centre value only
|
||||
scale = 1.0 + (self._scale_filter.x - 1.0) * max(0.0, 1.0 - abs_offset * 2)
|
||||
font_sz = max(8, int(font_sz * scale))
|
||||
|
||||
font = gui_app.font(weight)
|
||||
tw = int(measure_text_cached(font, label, font_sz).x)
|
||||
th = font_sz
|
||||
draw_x = int(cx + draw_x_off - tw / 2)
|
||||
draw_y = int(cy - th / 2)
|
||||
|
||||
rl.draw_text_ex(font, label,
|
||||
rl.Vector2(draw_x, draw_y),
|
||||
font_sz, 0,
|
||||
rl.Color(255, 255, 255, alpha))
|
||||
|
||||
# ── Unit label below centre ───────────────────────────────────────────────
|
||||
if self._unit:
|
||||
uw = int(measure_text_cached(self._font_roman, self._unit, _UNIT_FONT_SIZE).x)
|
||||
rl.draw_text_ex(self._font_roman, self._unit,
|
||||
rl.Vector2(int(cx - uw / 2), int(cy + _CENTRE_FONT_SIZE / 2 + 8)),
|
||||
_UNIT_FONT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, 140))
|
||||
|
||||
# ── Subtle neon glow on centre slot ───────────────────────────────────────
|
||||
slot_rect = rl.Rectangle(cx - _SLOT_W / 2 - 1, bar_y, _SLOT_W + 2, bar_h)
|
||||
rl.draw_rectangle_gradient_h(
|
||||
int(slot_rect.x), int(slot_rect.y),
|
||||
int(slot_rect.width // 2), int(slot_rect.height),
|
||||
rl.BLANK, NeonTheme.glow_outer(40),
|
||||
)
|
||||
rl.draw_rectangle_gradient_h(
|
||||
int(slot_rect.x + slot_rect.width // 2), int(slot_rect.y),
|
||||
int(slot_rect.width // 2), int(slot_rect.height),
|
||||
NeonTheme.glow_outer(40), rl.BLANK,
|
||||
)
|
||||
|
||||
return self._ret
|
||||
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal file
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import pyray as rl
|
||||
import qrcode
|
||||
import numpy as np
|
||||
import time
|
||||
import jwt
|
||||
import os
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from iqpilot.common.api.base import BaseApi
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.widgets import NavWidget
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.widgets.label import MiciLabel
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class PairingDialog(NavWidget):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
|
||||
self._params = Params()
|
||||
self._qr_texture: rl.Texture | None = None
|
||||
self._last_qr_generation = float("-inf")
|
||||
|
||||
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
|
||||
self._pair_label = MiciLabel(tr("pair with Konn3kt"), 48, font_weight=FontWeight.BOLD,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
dev_pairing = PC and os.getenv("KONN3KT_DEV_PAIRING") == "1"
|
||||
if dev_pairing:
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
except Exception:
|
||||
return "error://dev_identity_setup_failed"
|
||||
|
||||
try:
|
||||
imei1 = HARDWARE.get_imei(0) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei1: {e}")
|
||||
imei1 = ""
|
||||
|
||||
try:
|
||||
imei2 = HARDWARE.get_imei(1) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei2: {e}")
|
||||
imei2 = ""
|
||||
|
||||
try:
|
||||
algorithm, private_key, public_key = BaseApi.get_key_pair()
|
||||
if not private_key or not algorithm:
|
||||
cloudlog.error("No device keys found")
|
||||
return "error://keys_not_found"
|
||||
|
||||
dongle_id = get_or_create_dongle_id(self._params, prefer_readonly=True)
|
||||
|
||||
try:
|
||||
serial = HARDWARE.get_serial() or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get serial: {e}")
|
||||
serial = ""
|
||||
if not serial:
|
||||
serial = (self._params.get("HardwareSerial") or "") if dev_pairing else ""
|
||||
if not serial:
|
||||
cloudlog.error("No hardware serial found, cannot generate pairing token")
|
||||
return "error://serial_not_found"
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
payload = {
|
||||
'identity': dongle_id,
|
||||
'nbf': now,
|
||||
'iat': now,
|
||||
'imei': imei1,
|
||||
'imei2': imei2,
|
||||
'serial': serial,
|
||||
'public_key': public_key,
|
||||
'register': True,
|
||||
'exp': now + timedelta(hours=1),
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(payload, private_key, algorithm=algorithm)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"jwt.encode failed ({e}), retrying with normalized key")
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key_bytes = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
|
||||
try:
|
||||
key_obj = serialization.load_pem_private_key(key_bytes, password=None)
|
||||
except Exception:
|
||||
key_obj = serialization.load_ssh_private_key(key_bytes, password=None)
|
||||
token = jwt.encode(payload, key_obj, algorithm=algorithm)
|
||||
except Exception as e2:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e2}")
|
||||
return "error://token_generation_failed"
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode('utf8')
|
||||
return f"https://konn3kt.com/?pair={token}"
|
||||
except FileNotFoundError as e:
|
||||
cloudlog.error(f"Key files not found: {e}")
|
||||
return "error://keys_not_found"
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e}")
|
||||
return "error://token_generation_failed"
|
||||
|
||||
def _generate_qr_code(self) -> None:
|
||||
try:
|
||||
url = self._get_pairing_url()
|
||||
if url.startswith("error://"):
|
||||
cloudlog.warning(f"Cannot generate QR code: {url}")
|
||||
self._qr_texture = None
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
self._qr_texture = None
|
||||
|
||||
def _check_qr_refresh(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._generate_qr_code()
|
||||
self._last_qr_generation = current_time
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired():
|
||||
self._playing_dismiss_animation = True
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> int:
|
||||
self._check_qr_refresh()
|
||||
|
||||
self._render_qr_code()
|
||||
|
||||
label_x = self._rect.x + 8 + self._rect.height + 24
|
||||
self._pair_label.set_width(int(self._rect.width - label_x))
|
||||
self._pair_label.set_position(label_x, self._rect.y + 16)
|
||||
self._pair_label.render()
|
||||
|
||||
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
|
||||
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
|
||||
|
||||
return -1
|
||||
|
||||
def _render_qr_code(self) -> None:
|
||||
if not self._qr_texture:
|
||||
error_font = gui_app.font(FontWeight.BOLD)
|
||||
rl.draw_text_ex(
|
||||
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
|
||||
)
|
||||
return
|
||||
|
||||
scale = self._rect.height / self._qr_texture.height
|
||||
pos = rl.Vector2(self._rect.x + 8, self._rect.y)
|
||||
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PairingDialog()
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
if result != -1:
|
||||
break
|
||||
finally:
|
||||
del pairing
|
||||
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal file
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import pyray as rl
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants extracted from the original Qt style
|
||||
# ---------------------------------------------------------------------------
|
||||
# TODO: this should be corrected, but Scroller relies on this being incorrect :/
|
||||
WIDTH, HEIGHT = 112, 240
|
||||
|
||||
|
||||
class SideButton(Widget):
|
||||
def __init__(self, btn_type: str):
|
||||
super().__init__()
|
||||
self.type = btn_type
|
||||
self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
|
||||
|
||||
# load pre-rendered button images
|
||||
if btn_type not in ("check", "back"):
|
||||
btn_type = "back"
|
||||
btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
|
||||
btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
|
||||
self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
|
||||
|
||||
def _render(self, _) -> bool:
|
||||
x = int(self._rect.x + 12)
|
||||
y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
|
||||
rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
|
||||
x, y, rl.WHITE)
|
||||
|
||||
return False
|
||||
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal file
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from enum import Enum
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.common.filter_simple import BounceFilter
|
||||
from iqpilot.ui.theme import NeonTheme
|
||||
|
||||
try:
|
||||
from iqpilot.common.params import Params, UnknownKeyName
|
||||
except ImportError:
|
||||
Params = None
|
||||
class UnknownKeyName(Exception):
|
||||
pass
|
||||
|
||||
SCROLLING_SPEED_PX_S = 50
|
||||
COMPLICATION_SIZE = 36
|
||||
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
|
||||
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
|
||||
|
||||
_FORCE_ACCENT_RGB = None
|
||||
|
||||
_ACCENT_ROUND = 0.34
|
||||
_ACCENT_INSET = 6
|
||||
_GLOW_OUT = 3
|
||||
_GLOW_IN = 12
|
||||
_GLOW_IN_IDLE = 4
|
||||
_GLOW_OUT_ALPHA = 32
|
||||
_GLOW_IN_ALPHA = 80
|
||||
_RIM_ALPHA = 200
|
||||
_GLOW_SEGS = 16
|
||||
_GLOW_CORNER_SEGS = 16
|
||||
|
||||
|
||||
def _accent_rgb() -> tuple[int, int, int]:
|
||||
if _FORCE_ACCENT_RGB is not None:
|
||||
return _FORCE_ACCENT_RGB
|
||||
c = NeonTheme.glow(255)
|
||||
return (c.r, c.g, c.b)
|
||||
|
||||
|
||||
def _inset(rect: rl.Rectangle, px: float) -> rl.Rectangle:
|
||||
return rl.Rectangle(rect.x + px, rect.y + px, rect.width - 2 * px, rect.height - 2 * px)
|
||||
|
||||
|
||||
_BOX_BG = rl.Color(0x08, 0x09, 0x0A, 255)
|
||||
_BOX_BG_PRESSED = rl.Color(0x16, 0x18, 0x1A, 255)
|
||||
_BOX_BG_DISABLED = rl.Color(0x05, 0x06, 0x07, 255)
|
||||
|
||||
|
||||
def _draw_accent_box(rect: rl.Rectangle, enabled: bool, pressed: bool):
|
||||
"""Clean dark rounded box + smooth teal glow (matches the concept). Same roundness
|
||||
for box and glow → no seam. `rect` is the final box rect."""
|
||||
base = 1.0 if enabled else 0.4
|
||||
r, g, b = _accent_rgb()
|
||||
|
||||
# keep every concentric ring's corner radius offset by exactly its inset, so the
|
||||
# rings stay parallel at the corners too (a fixed roundness fraction would shrink
|
||||
# the corner radius unevenly and leave a dark seam in each corner)
|
||||
shorter = min(rect.width, rect.height)
|
||||
base_radius = _ACCENT_ROUND * shorter / 2.0
|
||||
|
||||
def _round_for(short_side: float, radius: float) -> float:
|
||||
return max(0.0, min(1.0, 2.0 * radius / short_side)) if short_side > 0 else 0.0
|
||||
|
||||
for px in range(_GLOW_OUT, 0, -1):
|
||||
f = px / _GLOW_OUT
|
||||
a = int(_GLOW_OUT_ALPHA * base * (1.0 - f) ** 2.0)
|
||||
if a <= 0:
|
||||
continue
|
||||
ex = rl.Rectangle(rect.x - px, rect.y - px, rect.width + 2 * px, rect.height + 2 * px)
|
||||
rl.draw_rectangle_rounded(ex, _round_for(shorter + 2 * px, base_radius + px), _GLOW_SEGS, rl.Color(r, g, b, a))
|
||||
|
||||
bg = _BOX_BG_PRESSED if pressed else (_BOX_BG if enabled else _BOX_BG_DISABLED)
|
||||
rl.draw_rectangle_rounded(rect, _round_for(shorter, base_radius), _GLOW_SEGS, bg)
|
||||
|
||||
for px in range(_GLOW_IN, 0, -1):
|
||||
f = px / _GLOW_IN
|
||||
a = int(_GLOW_IN_ALPHA * base * (1.0 - f) ** 1.8)
|
||||
if a <= 0:
|
||||
continue
|
||||
rl.draw_rectangle_rounded_lines_ex(_inset(rect, px), _round_for(shorter - 2 * px, base_radius - px),
|
||||
_GLOW_CORNER_SEGS, 3, rl.Color(r, g, b, a))
|
||||
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, _round_for(shorter, base_radius), _GLOW_CORNER_SEGS, 2,
|
||||
rl.Color(r, g, b, int(_RIM_ALPHA * base)))
|
||||
|
||||
|
||||
_CIRCLE_RED_RGB = (0xE0, 0x3A, 0x3A)
|
||||
|
||||
|
||||
def _draw_accent_circle(cx: float, cy: float, radius: float, enabled: bool, red: bool = False, pressed: bool = False):
|
||||
"""Circular version of the box accent: teal (or red) rim + inward glow, matching the boxes."""
|
||||
base = 1.0 if enabled else 0.4
|
||||
r, g, b = _CIRCLE_RED_RGB if red else _accent_rgb()
|
||||
c = rl.Vector2(cx, cy)
|
||||
segs = _GLOW_CORNER_SEGS * 2
|
||||
for px in range(_GLOW_OUT, 0, -1):
|
||||
a = int(_GLOW_OUT_ALPHA * base * (1.0 - px / _GLOW_OUT) ** 2.0)
|
||||
if a > 0:
|
||||
rl.draw_ring(c, radius + px - 1.0, radius + px + 1.0, 0, 360, segs, rl.Color(r, g, b, a))
|
||||
glow_in = _GLOW_IN if pressed else _GLOW_IN_IDLE
|
||||
for px in range(glow_in, 0, -1):
|
||||
a = int(_GLOW_IN_ALPHA * base * (1.0 - px / glow_in) ** 1.8)
|
||||
if a > 0:
|
||||
rl.draw_ring(c, radius - px - 1.5, radius - px + 1.5, 0, 360, segs, rl.Color(r, g, b, a))
|
||||
rl.draw_ring(c, radius - 1.5, radius + 1.5, 0, 360, segs, rl.Color(r, g, b, int(_RIM_ALPHA * base)))
|
||||
|
||||
|
||||
class ScrollState(Enum):
|
||||
PRE_SCROLL = 0
|
||||
SCROLLING = 1
|
||||
POST_SCROLL = 2
|
||||
|
||||
|
||||
class BigCircleButton(Widget):
|
||||
def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__()
|
||||
self._red = red
|
||||
self._icon_offset = icon_offset
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 180, 180))
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
|
||||
self._txt_icon = icon
|
||||
self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
|
||||
|
||||
self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
|
||||
self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180)
|
||||
|
||||
self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
|
||||
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
icon_color = rl.Color(255, 255, 255, int(255 * 0.9)) if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
rl.draw_texture_ex(self._txt_icon, (btn_x + (btn_width - self._txt_icon.width) / 2 + self._icon_offset[0],
|
||||
btn_y + (btn_height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color)
|
||||
|
||||
def _render(self, _):
|
||||
txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_btn_disabled_bg
|
||||
elif self.is_pressed:
|
||||
txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
|
||||
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
|
||||
|
||||
cx = btn_x + self._rect.width * scale / 2.0
|
||||
cy = btn_y + self._rect.height * scale / 2.0
|
||||
_draw_accent_circle(cx, cy, self._rect.width * scale / 2.0 - _ACCENT_INSET, self.enabled, self._red, self.is_pressed)
|
||||
|
||||
self._draw_content(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
|
||||
|
||||
|
||||
class BigCircleToggle(BigCircleButton):
|
||||
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, False, icon_offset=icon_offset)
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
self._checked = False
|
||||
|
||||
self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
|
||||
self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
|
||||
(btn_x + (btn_width - self._txt_toggle_enabled.width) / 2, btn_y + 5),
|
||||
0, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
class BigButton(Widget):
|
||||
LABEL_HORIZONTAL_PADDING = 40
|
||||
LABEL_VERTICAL_PADDING = 23
|
||||
|
||||
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
|
||||
|
||||
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 402, 180))
|
||||
self.text = text
|
||||
self.value = value
|
||||
self._txt_icon = icon
|
||||
self._scroll = scroll
|
||||
self._press_effect_enabled = True
|
||||
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
self._shake_start: float | None = None
|
||||
self._grow_animation_until: float | None = None
|
||||
|
||||
self._rotate_icon_t: float | None = None
|
||||
|
||||
self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
|
||||
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
|
||||
line_height=0.9)
|
||||
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
|
||||
text_color=COMPLICATION_GREY,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
|
||||
wrap_text=False, scroll=True)
|
||||
self._update_label_layout()
|
||||
|
||||
self._load_images()
|
||||
|
||||
def set_icon(self, icon: Union[rl.Texture, None]):
|
||||
self._txt_icon = icon
|
||||
|
||||
def set_rotate_icon(self, rotate: bool):
|
||||
if rotate and self._rotate_icon_t is not None:
|
||||
return
|
||||
self._rotate_icon_t = rl.get_time() if rotate else None
|
||||
|
||||
def set_press_effect_enabled(self, enabled: bool) -> None:
|
||||
self._press_effect_enabled = enabled
|
||||
|
||||
def set_scroll_active(self, active: bool) -> None:
|
||||
self._label.set_scroll_active(active)
|
||||
|
||||
def _load_images(self):
|
||||
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
|
||||
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
|
||||
self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
|
||||
|
||||
def _get_label_font_size(self):
|
||||
if len(self.text) <= 18:
|
||||
return 48
|
||||
else:
|
||||
return 42
|
||||
|
||||
def _update_label_layout(self):
|
||||
self._label.set_font_size(self._get_label_font_size())
|
||||
if self.value:
|
||||
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
|
||||
else:
|
||||
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.text = text
|
||||
self._label.set_text(text)
|
||||
self._update_label_layout()
|
||||
|
||||
def set_value(self, value: str):
|
||||
self.value = value
|
||||
self._sub_label.set_text(value)
|
||||
self._update_label_layout()
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
|
||||
def get_text(self):
|
||||
return self.text
|
||||
|
||||
def trigger_shake(self):
|
||||
self._shake_start = rl.get_time()
|
||||
|
||||
def trigger_grow_animation(self, duration: float = 0.65):
|
||||
self._grow_animation_until = rl.get_time() + duration
|
||||
|
||||
@property
|
||||
def _shake_offset(self) -> float:
|
||||
SHAKE_DURATION = 0.5
|
||||
SHAKE_AMPLITUDE = 24.0
|
||||
SHAKE_FREQUENCY = 32.0
|
||||
if self._shake_start is None:
|
||||
return 0.0
|
||||
t = rl.get_time() - self._shake_start
|
||||
if t > SHAKE_DURATION:
|
||||
return 0.0
|
||||
decay = 1.0 - t / SHAKE_DURATION
|
||||
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
|
||||
|
||||
def set_position(self, x: float, y: float) -> None:
|
||||
super().set_position(x + self._shake_offset, y)
|
||||
|
||||
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
|
||||
if self._grow_animation_until is not None:
|
||||
if rl.get_time() >= self._grow_animation_until:
|
||||
self._grow_animation_until = None
|
||||
|
||||
txt_bg = self._txt_default_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_disabled_bg
|
||||
elif self.is_pressed and self._press_effect_enabled:
|
||||
txt_bg = self._txt_pressed_bg
|
||||
|
||||
pressed_scale = self.is_pressed and self._press_effect_enabled
|
||||
animate_scale = pressed_scale or self._grow_animation_until is not None
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if animate_scale else 1.0)
|
||||
btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
|
||||
btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
|
||||
return txt_bg, btn_x, btn_y, scale
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
label_x = btn_x + self.LABEL_HORIZONTAL_PADDING
|
||||
|
||||
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
|
||||
self._label.set_color(label_color)
|
||||
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
|
||||
btn_height - self.LABEL_VERTICAL_PADDING * 2)
|
||||
self._label.render(label_rect)
|
||||
|
||||
if self.value:
|
||||
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
|
||||
sub_label_height = btn_y + btn_height - self.LABEL_VERTICAL_PADDING - label_y
|
||||
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
|
||||
self._sub_label.render(sub_label_rect)
|
||||
|
||||
if self._txt_icon:
|
||||
rotation = 0
|
||||
if self._rotate_icon_t is not None:
|
||||
rotation = (rl.get_time() - self._rotate_icon_t) * 180
|
||||
|
||||
x = btn_x + btn_width - 30 - self._txt_icon.width / 2
|
||||
y = btn_y + 30 + self._txt_icon.height / 2
|
||||
source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
|
||||
dest_rec = rl.Rectangle(x, y, self._txt_icon.width, self._txt_icon.height)
|
||||
origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
|
||||
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
def _render(self, _):
|
||||
txt_bg, btn_x, btn_y, scale = self._handle_background()
|
||||
|
||||
cell = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
|
||||
box_rect = _inset(cell, _ACCENT_INSET)
|
||||
_draw_accent_box(box_rect, self.enabled, self.is_pressed)
|
||||
|
||||
# Clip each card's content to its own bounds so long/scrolling labels from one
|
||||
# tile cannot bleed into neighboring tiles in the horizontal scroller.
|
||||
content_rect = _inset(box_rect, 2)
|
||||
rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height))
|
||||
self._draw_content(btn_x, btn_y, cell.width, cell.height)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
|
||||
class BigToggle(BigButton):
|
||||
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, value, "")
|
||||
self._checked = initial_state
|
||||
self._toggle_callback = toggle_callback
|
||||
|
||||
def _load_images(self):
|
||||
super()._load_images()
|
||||
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
|
||||
self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
self._checked = checked
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_pill(self, x: float, y: float, checked: bool):
|
||||
if checked:
|
||||
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
else:
|
||||
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
x = btn_x + btn_width - self._txt_enabled_toggle.width
|
||||
y = btn_y
|
||||
self._draw_pill(x, y, self._checked)
|
||||
|
||||
|
||||
class BigMultiToggle(BigToggle):
|
||||
def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
assert len(options) > 0
|
||||
self._options = options
|
||||
self._select_callback = select_callback
|
||||
|
||||
self.set_value(self._options[0])
|
||||
|
||||
def _width_hint(self) -> int:
|
||||
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
cur_idx = self._options.index(self.value)
|
||||
new_idx = (cur_idx + 1) % len(self._options)
|
||||
self.set_value(self._options[new_idx])
|
||||
if self._select_callback:
|
||||
self._select_callback(self.value)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
BigButton._draw_content(self, btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
checked_idx = self._options.index(self.value)
|
||||
|
||||
x = btn_x + btn_width - self._txt_enabled_toggle.width
|
||||
y = btn_y
|
||||
|
||||
for i in range(len(self._options)):
|
||||
self._draw_pill(x, y, checked_idx == i)
|
||||
y += 35
|
||||
|
||||
|
||||
class GreyBigButton(BigButton):
|
||||
"""Users should manage newlines with this class themselves"""
|
||||
|
||||
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 _get_label_font_size(self):
|
||||
return 36
|
||||
|
||||
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.x, self._rect.y, self._rect.width, self._rect.height)
|
||||
|
||||
|
||||
class BigMultiParamToggle(BigMultiToggle):
|
||||
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None,
|
||||
select_callback: Callable | None = None):
|
||||
super().__init__(text, options, toggle_callback, select_callback)
|
||||
self._param = param
|
||||
|
||||
self._params = Params()
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
self.set_value(self._options[self._params.get(self._param) or 0])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
new_idx = self._options.index(self.value)
|
||||
self._params.put(self._param, new_idx)
|
||||
|
||||
|
||||
class BigParamControl(BigToggle):
|
||||
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
|
||||
super().__init__(text, "", toggle_callback=toggle_callback)
|
||||
self.param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self._read_bool())
|
||||
|
||||
def _read_bool(self) -> bool:
|
||||
try:
|
||||
return self.params.get_bool(self.param)
|
||||
except UnknownKeyName:
|
||||
return False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
try:
|
||||
self.params.put_bool(self.param, self._checked)
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self._read_bool())
|
||||
|
||||
|
||||
class BigCircleParamControl(BigCircleToggle):
|
||||
def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None,
|
||||
icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, toggle_callback, icon_offset=icon_offset)
|
||||
self._param = param
|
||||
self.params = Params()
|
||||
self.set_checked(self._read_bool())
|
||||
|
||||
def _read_bool(self) -> bool:
|
||||
try:
|
||||
return self.params.get_bool(self._param)
|
||||
except UnknownKeyName:
|
||||
return False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
try:
|
||||
self.params.put_bool(self._param, self._checked)
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self._read_bool())
|
||||
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal file
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import abc
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.lib.raylib_compat import draw_rectangle_gradient_ex
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigCircleButton, BigButton, GreyBigButton
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PADDING = 20
|
||||
|
||||
|
||||
class BigDialogBase(NavWidget, abc.ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
|
||||
class BigDialog(BigDialogBase):
|
||||
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
|
||||
super().__init__()
|
||||
self._card = GreyBigButton(title, description, icon)
|
||||
|
||||
def _render(self, _):
|
||||
self._card.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width / 2 - self._card.rect.width / 2,
|
||||
self._rect.y + self._rect.height / 2 - self._card.rect.height / 2,
|
||||
self._card.rect.width,
|
||||
self._card.rect.height,
|
||||
))
|
||||
|
||||
|
||||
class BigConfirmationDialog(BigDialogBase):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
|
||||
exit_on_confirm: bool = True, red: bool = False):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._exit_on_confirm = exit_on_confirm
|
||||
|
||||
self._slider: BigSlider | RedBigSlider
|
||||
if red:
|
||||
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
else:
|
||||
self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._exit_on_confirm:
|
||||
self.dismiss(self._confirm_callback)
|
||||
elif self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.is_dismissing and not self._slider.confirmed:
|
||||
self._slider.reset()
|
||||
|
||||
def _render(self, _):
|
||||
self._slider.render(self._rect)
|
||||
|
||||
|
||||
class BigInputDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 1.0
|
||||
BACKSPACE_RATE = 25 # hz
|
||||
TEXT_INPUT_SIZE = 35
|
||||
INTRO_DURATION_S = 0.14
|
||||
INTRO_OFFSET_Y = 20
|
||||
|
||||
def __init__(self,
|
||||
hint: str,
|
||||
default_text: str = "",
|
||||
minimum_length: int = 1,
|
||||
confirm_callback: Callable[[str], None] | None = None,
|
||||
auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
|
||||
font_weight=FontWeight.MEDIUM)
|
||||
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
|
||||
self._keyboard.set_text(default_text)
|
||||
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
self._minimum_length = minimum_length
|
||||
|
||||
self._backspace_held_time: float | None = None
|
||||
|
||||
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
|
||||
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62)
|
||||
self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62)
|
||||
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
# rects for top buttons
|
||||
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._intro_started_at = 0.0
|
||||
|
||||
def confirm_callback_wrapper():
|
||||
text = self._keyboard.text()
|
||||
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
|
||||
self._confirm_callback = confirm_callback_wrapper
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self.settle_to_top()
|
||||
self._intro_started_at = rl.get_time()
|
||||
|
||||
def _intro_progress(self) -> float:
|
||||
if self._intro_started_at <= 0.0:
|
||||
return 1.0
|
||||
return min(max((rl.get_time() - self._intro_started_at) / self.INTRO_DURATION_S, 0.0), 1.0)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if self.is_dismissing:
|
||||
self._backspace_held_time = None
|
||||
return
|
||||
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
|
||||
if self._backspace_held_time is None:
|
||||
self._backspace_held_time = rl.get_time()
|
||||
|
||||
if rl.get_time() - self._backspace_held_time > 0.5:
|
||||
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
|
||||
self._keyboard.backspace()
|
||||
|
||||
else:
|
||||
self._backspace_held_time = None
|
||||
|
||||
def _render(self, _):
|
||||
intro_progress = self._intro_progress()
|
||||
intro_offset_y = (1.0 - intro_progress) * self.INTRO_OFFSET_Y
|
||||
intro_alpha = max(int(255 * intro_progress), 1)
|
||||
|
||||
# draw current text so far below everything. text floats left but always stays in view
|
||||
text = self._keyboard.text()
|
||||
candidate_char = self._keyboard.get_candidate_character()
|
||||
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
|
||||
|
||||
bg_block_margin = 5
|
||||
text_x = PADDING / 2 + self._enter_img.width + PADDING
|
||||
text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin + intro_offset_y,
|
||||
self._rect.width - text_x * 2,
|
||||
text_size.y)
|
||||
|
||||
# draw text input
|
||||
# push text left with a gradient on left side if too long
|
||||
if text_size.x > text_field_rect.width:
|
||||
text_x -= text_size.x - text_field_rect.width
|
||||
|
||||
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, intro_alpha))
|
||||
|
||||
# draw grayed out character user is hovering over
|
||||
if candidate_char:
|
||||
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
|
||||
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
|
||||
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, int(128 * intro_progress)))
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# draw gradient on left side to indicate more text
|
||||
if text_size.x > text_field_rect.width:
|
||||
draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height),
|
||||
rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK)
|
||||
|
||||
# draw cursor
|
||||
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
|
||||
if text:
|
||||
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
|
||||
else:
|
||||
cursor_x = text_field_rect.x - 6
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y),
|
||||
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha * intro_progress)))
|
||||
|
||||
# draw backspace icon with nice fade
|
||||
self._backspace_img_alpha.update(255 * bool(text))
|
||||
if self._backspace_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x * intro_progress))
|
||||
rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14 + intro_offset_y), 0.0, 1.0, color)
|
||||
|
||||
if not text and self._hint_label.text and not candidate_char:
|
||||
# draw description if no text entered yet and not drawing candidate char
|
||||
hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y,
|
||||
self._rect.width - text_field_rect.x - PADDING,
|
||||
text_field_rect.height)
|
||||
self._hint_label.set_color(rl.Color(255, 255, 255, int(255 * 0.35 * intro_progress)))
|
||||
self._hint_label.render(hint_rect)
|
||||
|
||||
# TODO: move to update state
|
||||
# make rect take up entire area so it's easier to click
|
||||
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
|
||||
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
|
||||
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
|
||||
|
||||
# draw enter button
|
||||
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
|
||||
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x * intro_progress))
|
||||
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
|
||||
color = rl.Color(255, 255, 255, int((255 - self._enter_img_alpha.x) * intro_progress))
|
||||
rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
|
||||
|
||||
# keyboard goes over everything
|
||||
self._keyboard.render(rl.Rectangle(self._rect.x, self._rect.y + intro_offset_y, self._rect.width, self._rect.height))
|
||||
|
||||
# draw debugging rect bounds
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# TODO: need to track where press was so enter and back can activate on release rather than press
|
||||
# or turn into icon widgets :eyes_open:
|
||||
|
||||
if self.is_dismissing:
|
||||
return
|
||||
|
||||
# handle backspace icon click
|
||||
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
|
||||
self._keyboard.backspace()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
|
||||
# handle enter icon click
|
||||
self._confirm_callback()
|
||||
|
||||
|
||||
class BigDialogButton(BigButton):
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
|
||||
super().__init__(text, value, icon)
|
||||
self._description = description
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
dlg = BigDialog(self.text, self._description)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class BigConfirmationCircleButton(BigCircleButton):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True,
|
||||
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, red, icon_offset)
|
||||
|
||||
def show_confirm_dialog():
|
||||
gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback,
|
||||
exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
self.set_click_callback(show_confirm_dialog)
|
||||
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal file
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import os
|
||||
import pyray as rl
|
||||
import qrcode
|
||||
import numpy as np
|
||||
import time
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from iqpilot.common.api.base import BaseApi
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class PairingDialog(NavWidget):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._qr_texture: rl.Texture | None = None
|
||||
self._last_qr_generation = float("-inf")
|
||||
|
||||
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60)
|
||||
self._pair_label = UnifiedLabel(tr("pair with Konn3kt"), font_size=48, font_weight=FontWeight.BOLD, line_height=0.8)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
dev_pairing = PC and os.getenv("KONN3KT_DEV_PAIRING") == "1"
|
||||
if dev_pairing:
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
except Exception:
|
||||
return "error://dev_identity_setup_failed"
|
||||
|
||||
try:
|
||||
imei1 = HARDWARE.get_imei(0) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei1: {e}")
|
||||
imei1 = ""
|
||||
|
||||
try:
|
||||
imei2 = HARDWARE.get_imei(1) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei2: {e}")
|
||||
imei2 = ""
|
||||
|
||||
try:
|
||||
algorithm, private_key, public_key = BaseApi.get_key_pair()
|
||||
if not private_key or not algorithm:
|
||||
cloudlog.error("No device keys found")
|
||||
return "error://keys_not_found"
|
||||
|
||||
dongle_id = get_or_create_dongle_id(self._params, prefer_readonly=True)
|
||||
|
||||
try:
|
||||
serial = HARDWARE.get_serial() or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get serial: {e}")
|
||||
serial = ""
|
||||
if not serial:
|
||||
serial = (self._params.get("HardwareSerial") or "") if dev_pairing else ""
|
||||
if not serial:
|
||||
cloudlog.error("No hardware serial found, cannot generate pairing token")
|
||||
return "error://serial_not_found"
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
payload = {
|
||||
'identity': dongle_id,
|
||||
'nbf': now,
|
||||
'iat': now,
|
||||
'imei': imei1,
|
||||
'imei2': imei2,
|
||||
'serial': serial,
|
||||
'public_key': public_key,
|
||||
'register': True,
|
||||
'exp': now + timedelta(hours=1),
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(payload, private_key, algorithm=algorithm)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"jwt.encode failed ({e}), retrying with normalized key")
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key_bytes = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
|
||||
try:
|
||||
key_obj = serialization.load_pem_private_key(key_bytes, password=None)
|
||||
except Exception:
|
||||
key_obj = serialization.load_ssh_private_key(key_bytes, password=None)
|
||||
token = jwt.encode(payload, key_obj, algorithm=algorithm)
|
||||
except Exception as e2:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e2}")
|
||||
return "error://token_generation_failed"
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode('utf8')
|
||||
return f"https://konn3kt.com/?pair={token}"
|
||||
except FileNotFoundError as e:
|
||||
cloudlog.error(f"Key files not found: {e}")
|
||||
return "error://keys_not_found"
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e}")
|
||||
return "error://token_generation_failed"
|
||||
|
||||
def _generate_qr_code(self) -> None:
|
||||
try:
|
||||
url = self._get_pairing_url()
|
||||
if url.startswith("error://"):
|
||||
cloudlog.warning(f"Cannot generate QR code: {url}")
|
||||
self._qr_texture = None
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
self._qr_texture = None
|
||||
|
||||
def _check_qr_refresh(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._generate_qr_code()
|
||||
self._last_qr_generation = current_time
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired() and not self.is_dismissing:
|
||||
self.dismiss()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._check_qr_refresh()
|
||||
|
||||
self._render_qr_code()
|
||||
|
||||
label_x = self._rect.x + 8 + self._rect.height + 24
|
||||
self._pair_label.set_max_width(int(self._rect.width - label_x))
|
||||
self._pair_label.set_position(label_x, self._rect.y + 16)
|
||||
self._pair_label.render()
|
||||
|
||||
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
|
||||
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
|
||||
|
||||
def _render_qr_code(self) -> None:
|
||||
if not self._qr_texture:
|
||||
error_font = gui_app.font(FontWeight.BOLD)
|
||||
rl.draw_text_ex(
|
||||
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
|
||||
)
|
||||
return
|
||||
|
||||
scale = self._rect.height / self._qr_texture.height
|
||||
pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y))
|
||||
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PairingDialog()
|
||||
gui_app.push_widget(pairing)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
finally:
|
||||
del pairing
|
||||
0
iqpilot/selfdrive/ui/onroad/__init__.py
Normal file
0
iqpilot/selfdrive/ui/onroad/__init__.py
Normal file
186
iqpilot/selfdrive/ui/onroad/alert_renderer.py
Normal file
186
iqpilot/selfdrive/ui/onroad/alert_renderer.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import time
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from iqpilot.cereal import messaging, log
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.hardware import TICI
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import Label
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
AlertStatus = log.SelfdriveState.AlertStatus
|
||||
|
||||
ALERT_MARGIN = 40
|
||||
ALERT_PADDING = 60
|
||||
ALERT_LINE_SPACING = 45
|
||||
ALERT_BORDER_RADIUS = 30
|
||||
|
||||
ALERT_FONT_SMALL = 66
|
||||
ALERT_FONT_MEDIUM = 74
|
||||
ALERT_FONT_BIG = 88
|
||||
|
||||
ALERT_HEIGHTS = {
|
||||
AlertSize.small: 271,
|
||||
AlertSize.mid: 420,
|
||||
}
|
||||
|
||||
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
|
||||
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
|
||||
|
||||
# Constants
|
||||
ALERT_COLORS = {
|
||||
AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1
|
||||
AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1
|
||||
AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Alert:
|
||||
text1: str = ""
|
||||
text2: str = ""
|
||||
size: int = 0
|
||||
status: int = 0
|
||||
|
||||
|
||||
# Pre-defined alert instances
|
||||
ALERT_STARTUP_PENDING = Alert(
|
||||
text1=tr("IQ.Pilot Unavailable"),
|
||||
text2=tr("Waiting to start"),
|
||||
size=AlertSize.mid,
|
||||
status=AlertStatus.normal,
|
||||
)
|
||||
|
||||
ALERT_CRITICAL_TIMEOUT = Alert(
|
||||
text1=tr("TAKE CONTROL IMMEDIATELY"),
|
||||
text2=tr("System Unresponsive"),
|
||||
size=AlertSize.full,
|
||||
status=AlertStatus.critical,
|
||||
)
|
||||
|
||||
ALERT_CRITICAL_REBOOT = Alert(
|
||||
text1=tr("System Unresponsive"),
|
||||
text2=tr("Reboot Device"),
|
||||
size=AlertSize.mid,
|
||||
status=AlertStatus.normal,
|
||||
)
|
||||
|
||||
|
||||
class AlertRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL)
|
||||
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
|
||||
|
||||
# font size is set dynamically
|
||||
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
|
||||
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
|
||||
|
||||
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
|
||||
"""Generate the current alert based on selfdrive state."""
|
||||
ss = sm['selfdriveState']
|
||||
|
||||
# Check if selfdriveState messages have stopped arriving
|
||||
recv_frame = sm.recv_frame['selfdriveState']
|
||||
if not sm.updated['selfdriveState']:
|
||||
time_since_onroad = time.monotonic() - ui_state.started_time
|
||||
|
||||
# 1. Never received selfdriveState since going onroad
|
||||
waiting_for_startup = recv_frame < ui_state.started_frame
|
||||
if waiting_for_startup and time_since_onroad > 5:
|
||||
return ALERT_STARTUP_PENDING
|
||||
|
||||
# 2. Lost communication with selfdriveState after receiving it
|
||||
if TICI and not waiting_for_startup:
|
||||
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
|
||||
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
|
||||
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
|
||||
return ALERT_CRITICAL_TIMEOUT
|
||||
return ALERT_CRITICAL_REBOOT
|
||||
|
||||
# No alert if size is none
|
||||
if ss.alertSize == 0:
|
||||
return None
|
||||
|
||||
# Don't get old alert
|
||||
if recv_frame < ui_state.started_frame:
|
||||
return None
|
||||
|
||||
event_name = ss.alertType.split('/')[0] if ss.alertType else ''
|
||||
if event_name in {'selfdrivedLagging', 'commIssue', 'commIssueAvgFreq'}:
|
||||
return None
|
||||
|
||||
# Return current alert
|
||||
return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
alert = self.get_alert(ui_state.sm)
|
||||
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.onroad_brightness_handle_alerts(ui_state.started, alert)
|
||||
|
||||
if not alert:
|
||||
return
|
||||
|
||||
alert_rect = self._get_alert_rect(rect, alert.size)
|
||||
self._draw_background(alert_rect, alert)
|
||||
|
||||
text_rect = rl.Rectangle(
|
||||
alert_rect.x + ALERT_PADDING,
|
||||
alert_rect.y + ALERT_PADDING,
|
||||
alert_rect.width - 2 * ALERT_PADDING,
|
||||
alert_rect.height - 2 * ALERT_PADDING
|
||||
)
|
||||
self._draw_text(text_rect, alert)
|
||||
|
||||
def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle:
|
||||
if size == AlertSize.full:
|
||||
return rect
|
||||
|
||||
h = ALERT_HEIGHTS.get(size, rect.height)
|
||||
return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN,
|
||||
rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
|
||||
|
||||
def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None:
|
||||
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
|
||||
|
||||
if alert.size != AlertSize.full:
|
||||
roundness = ALERT_BORDER_RADIUS / (min(rect.width, rect.height) / 2)
|
||||
rl.draw_rectangle_rounded(rect, roundness, 10, color)
|
||||
else:
|
||||
rl.draw_rectangle_rec(rect, color)
|
||||
|
||||
def _draw_text(self, rect: rl.Rectangle, alert: Alert) -> None:
|
||||
if alert.size == AlertSize.small:
|
||||
self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_MEDIUM)
|
||||
|
||||
elif alert.size == AlertSize.mid:
|
||||
self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_BIG, center_y=False)
|
||||
rect.y += ALERT_FONT_BIG + ALERT_LINE_SPACING
|
||||
self._draw_centered(alert.text2, rect, self.font_regular, ALERT_FONT_SMALL, center_y=False)
|
||||
|
||||
else:
|
||||
is_long = len(alert.text1) > 15
|
||||
font_size1 = 132 if is_long else 177
|
||||
|
||||
top_offset = 200 if is_long or '\n' in alert.text1 else 270
|
||||
title_rect = rl.Rectangle(rect.x, rect.y + top_offset, rect.width, 600)
|
||||
self._full_text1_label.set_font_size(font_size1)
|
||||
self._full_text1_label.set_text(alert.text1)
|
||||
self._full_text1_label.render(title_rect)
|
||||
|
||||
bottom_offset = 361 if is_long else 420
|
||||
subtitle_rect = rl.Rectangle(rect.x, rect.y + rect.height - bottom_offset, rect.width, 300)
|
||||
self._full_text2_label.set_text(alert.text2)
|
||||
self._full_text2_label.render(subtitle_rect)
|
||||
|
||||
def _draw_centered(self, text, rect, font, font_size, center_y=True, color=rl.WHITE) -> None:
|
||||
text_size = measure_text_cached(font, text, font_size)
|
||||
x = rect.x + (rect.width - text_size.x) / 2
|
||||
y = rect.y + ((rect.height - text_size.y) / 2 if center_y else 0)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(x, y), font_size, 0, color)
|
||||
312
iqpilot/selfdrive/ui/onroad/augmented_road_view.py
Normal file
312
iqpilot/selfdrive/ui/onroad/augmented_road_view.py
Normal file
@@ -0,0 +1,312 @@
|
||||
import time
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import log, messaging
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from iqpilot.selfdrive.ui import UI_BORDER_SIZE
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer
|
||||
from iqpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer as BaseDriverStateRenderer, BTN_SIZE
|
||||
from iqpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer as BaseHudRenderer
|
||||
from iqpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer
|
||||
from iqpilot.selfdrive.ui.onroad.environment_renderer import EnvironmentRenderer
|
||||
from iqpilot.selfdrive.ui.onroad.cameraview import CameraView
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.common.issue_debug import log_issue_limited
|
||||
from iqpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
|
||||
from iqpilot.common.transformations.orientation import rot_from_euler
|
||||
from iqpilot.selfdrive.locationd.calibration_helpers import get_calibrated_rpy
|
||||
|
||||
from iqpilot.ui.onroad.augmented_road_view import BORDER_COLORS_IQ, AugmentedRoadViewIQ
|
||||
from iqpilot.ui.onroad.driver_state import DriverStateRendererIQ
|
||||
from iqpilot.ui.onroad.hud_renderer import IQHudRenderer
|
||||
from iqpilot.selfdrive.ui.ui_state import OnroadTimerStatus
|
||||
|
||||
OpState = log.SelfdriveState.OpenpilotState
|
||||
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
|
||||
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
|
||||
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
|
||||
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
|
||||
|
||||
BORDER_COLORS = {
|
||||
UIStatus.DISENGAGED: rl.Color(0x12, 0x28, 0x39, 0xFF), # Blue for disengaged state
|
||||
UIStatus.OVERRIDE: rl.Color(0x89, 0x92, 0x8D, 0xFF), # Gray for override state
|
||||
UIStatus.ENGAGED: rl.Color(0x0C, 0x94, 0x96, 0xFF),
|
||||
**BORDER_COLORS_IQ,
|
||||
}
|
||||
|
||||
WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph)
|
||||
ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph)
|
||||
INF_POINT = np.array([1000.0, 0.0, 0.0])
|
||||
|
||||
|
||||
class AugmentedRoadView(CameraView, AugmentedRoadViewIQ):
|
||||
def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
|
||||
CameraView.__init__(self, "camerad", stream_type)
|
||||
AugmentedRoadViewIQ.__init__(self)
|
||||
self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED])
|
||||
|
||||
self.device_camera: DeviceCameraConfig | None = None
|
||||
self.view_from_calib = view_frame_from_device_frame.copy()
|
||||
self.view_from_wide_calib = view_frame_from_device_frame.copy()
|
||||
|
||||
self._matrix_cache_key = (0, 0.0, 0.0, stream_type)
|
||||
self._cached_matrix: np.ndarray | None = None
|
||||
self._content_rect = rl.Rectangle()
|
||||
self._split_nav_available = False
|
||||
|
||||
self.model_renderer = ModelRenderer()
|
||||
self.environment_renderer = EnvironmentRenderer()
|
||||
self.alert_renderer = AlertRenderer()
|
||||
self._hud_renderer = IQHudRenderer()
|
||||
self.driver_state_renderer = DriverStateRendererIQ()
|
||||
self._split_nav_available = hasattr(self._hud_renderer, "render_split_nav")
|
||||
|
||||
# debug
|
||||
self._pm = messaging.PubMaster(['uiDebug'])
|
||||
|
||||
def _render(self, rect):
|
||||
# Only render when system is started to avoid invalid data access
|
||||
start_draw = time.monotonic()
|
||||
if not ui_state.started:
|
||||
return
|
||||
|
||||
self._switch_stream_if_needed(ui_state.sm)
|
||||
|
||||
# Update calibration before rendering
|
||||
self._update_calibration()
|
||||
|
||||
# Create inner content area with border padding
|
||||
full_content_rect = rl.Rectangle(
|
||||
rect.x + UI_BORDER_SIZE,
|
||||
rect.y + UI_BORDER_SIZE,
|
||||
rect.width - 2 * UI_BORDER_SIZE,
|
||||
rect.height - 2 * UI_BORDER_SIZE,
|
||||
)
|
||||
split_nav_enabled = bool(getattr(self._hud_renderer, "split_nav_enabled", lambda: False)())
|
||||
if split_nav_enabled:
|
||||
split_width = full_content_rect.width * 0.5
|
||||
camera_rect = rl.Rectangle(full_content_rect.x, full_content_rect.y, split_width, full_content_rect.height)
|
||||
map_rect = rl.Rectangle(full_content_rect.x + split_width, full_content_rect.y, full_content_rect.width - split_width, full_content_rect.height)
|
||||
else:
|
||||
camera_rect = full_content_rect
|
||||
map_rect = None
|
||||
self._content_rect = camera_rect
|
||||
|
||||
if map_rect is not None:
|
||||
self._hud_renderer.render_split_nav(map_rect)
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(map_rect.x, map_rect.y + 20),
|
||||
rl.Vector2(map_rect.x, map_rect.y + map_rect.height - 20),
|
||||
2.0,
|
||||
rl.Color(255, 255, 255, 20),
|
||||
)
|
||||
|
||||
# Enable scissor mode to clip all rendering within content rectangle boundaries
|
||||
# This creates a rendering viewport that prevents graphics from drawing outside the border
|
||||
rl.begin_scissor_mode(
|
||||
int(camera_rect.x),
|
||||
int(camera_rect.y),
|
||||
int(camera_rect.width),
|
||||
int(camera_rect.height)
|
||||
)
|
||||
|
||||
# Render the base camera view
|
||||
super()._render(camera_rect)
|
||||
|
||||
# Draw all UI overlays
|
||||
self.model_renderer.render(camera_rect)
|
||||
self.environment_renderer.render(camera_rect)
|
||||
AugmentedRoadViewIQ.update_fade_out_bottom_overlay(self, camera_rect)
|
||||
self._hud_renderer.render(camera_rect)
|
||||
|
||||
# Custom UI extension point - add custom overlays here
|
||||
# Use self._content_rect for positioning within camera bounds
|
||||
|
||||
# End clipping region
|
||||
rl.end_scissor_mode()
|
||||
|
||||
if hasattr(self._hud_renderer, "render_full_width_overlays"):
|
||||
self._hud_renderer.render_full_width_overlays(full_content_rect)
|
||||
|
||||
self.alert_renderer.render(full_content_rect)
|
||||
self.driver_state_renderer.render(full_content_rect)
|
||||
|
||||
# Draw colored border based on driving state
|
||||
self._draw_border(rect)
|
||||
|
||||
# publish uiDebug
|
||||
draw_time_ms = (time.monotonic() - start_draw) * 1000
|
||||
if draw_time_ms > 40.0:
|
||||
log_issue_limited(
|
||||
"ui_draw_slow",
|
||||
"ui",
|
||||
f"onroad draw slow drawTimeMillis={draw_time_ms:.2f} navActive={getattr(ui_state.sm['iqNavState'], 'active', False)}",
|
||||
interval_sec=1.0,
|
||||
)
|
||||
msg = messaging.new_message('uiDebug')
|
||||
msg.uiDebug.drawTimeMillis = draw_time_ms
|
||||
self._pm.send('uiDebug', msg)
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos):
|
||||
dm = self.driver_state_renderer
|
||||
if ui_state.has_longitudinal_control and dm.is_visible:
|
||||
dx = mouse_pos.x - dm.position_x
|
||||
dy = mouse_pos.y - dm.position_y
|
||||
if dx * dx + dy * dy <= (BTN_SIZE / 2) ** 2:
|
||||
dm.cycle_personality()
|
||||
return
|
||||
|
||||
if not self._hud_renderer.user_interacting() and self._click_callback is not None:
|
||||
self._click_callback()
|
||||
|
||||
def _handle_mouse_release(self, _):
|
||||
# We only call click callback on press if not interacting with HUD
|
||||
pass
|
||||
|
||||
def _draw_border(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK)
|
||||
border_roundness = 0.12
|
||||
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
|
||||
border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE,
|
||||
rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE)
|
||||
aol = ui_state.sm["iqState"].aol
|
||||
if aol.active and not ui_state.sm["selfdriveState"].enabled:
|
||||
bottom_only_height = max(int(UI_BORDER_SIZE * 4), 60)
|
||||
clip_y = int(rect.y + rect.height - bottom_only_height)
|
||||
rl.begin_scissor_mode(int(rect.x), clip_y, int(rect.width), bottom_only_height)
|
||||
rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
|
||||
rl.end_scissor_mode()
|
||||
else:
|
||||
rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
|
||||
|
||||
def _switch_stream_if_needed(self, sm):
|
||||
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
|
||||
v_ego = sm['carState'].vEgo
|
||||
if v_ego < WIDE_CAM_MAX_SPEED:
|
||||
target = WIDE_CAM
|
||||
elif v_ego > ROAD_CAM_MIN_SPEED:
|
||||
target = ROAD_CAM
|
||||
else:
|
||||
# Hysteresis zone - keep current stream
|
||||
target = self.stream_type
|
||||
else:
|
||||
target = ROAD_CAM
|
||||
|
||||
if self.stream_type != target:
|
||||
self.switch_stream(target)
|
||||
|
||||
def _update_calibration(self):
|
||||
# Update device camera if not already set
|
||||
sm = ui_state.sm
|
||||
if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
||||
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
|
||||
if not sm.seen["extrinsicsCalibration"]:
|
||||
return
|
||||
|
||||
calib = sm['extrinsicsCalibration']
|
||||
calib_rpy = get_calibrated_rpy(calib)
|
||||
if calib_rpy is None:
|
||||
return
|
||||
|
||||
# Update view_from_calib matrix
|
||||
prev_view_from_calib = self.view_from_calib.copy()
|
||||
prev_view_from_wide_calib = self.view_from_wide_calib.copy()
|
||||
device_from_calib = rot_from_euler(calib_rpy)
|
||||
self.view_from_calib = view_frame_from_device_frame @ device_from_calib
|
||||
|
||||
# Update wide calibration if available
|
||||
if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
|
||||
wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
|
||||
self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
|
||||
|
||||
if (not np.allclose(self.view_from_calib, prev_view_from_calib) or
|
||||
not np.allclose(self.view_from_wide_calib, prev_view_from_wide_calib)):
|
||||
self._matrix_cache_key = (0, 0.0, 0.0, self.stream_type)
|
||||
self._cached_matrix = None
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
# Check if we can use cached matrix
|
||||
cache_key = (
|
||||
ui_state.sm.recv_frame['extrinsicsCalibration'],
|
||||
self._content_rect.width,
|
||||
self._content_rect.height,
|
||||
self.stream_type
|
||||
)
|
||||
if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
|
||||
return self._cached_matrix
|
||||
|
||||
# Get camera configuration
|
||||
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
|
||||
is_wide_camera = self.stream_type == WIDE_CAM
|
||||
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
|
||||
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
|
||||
zoom = 2.0 if is_wide_camera else 1.1
|
||||
|
||||
# Calculate transforms for vanishing point
|
||||
calib_transform = intrinsic @ calibration
|
||||
kep = calib_transform @ INF_POINT
|
||||
|
||||
# Calculate center points and dimensions
|
||||
x, y = self._content_rect.x, self._content_rect.y
|
||||
w, h = self._content_rect.width, self._content_rect.height
|
||||
cx, cy = intrinsic[0, 2], intrinsic[1, 2]
|
||||
|
||||
# Calculate max allowed offsets with margins
|
||||
margin = 5
|
||||
max_x_offset = cx * zoom - w / 2 - margin
|
||||
max_y_offset = cy * zoom - h / 2 - margin
|
||||
|
||||
# Calculate and clamp offsets to prevent out-of-bounds issues
|
||||
try:
|
||||
if abs(kep[2]) > 1e-6:
|
||||
x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
|
||||
y_offset = np.clip((kep[1] / kep[2] - cy) * zoom, -max_y_offset, max_y_offset)
|
||||
else:
|
||||
x_offset, y_offset = 0, 0
|
||||
except (ZeroDivisionError, OverflowError):
|
||||
x_offset, y_offset = 0, 0
|
||||
|
||||
# Cache the computed transformation matrix to avoid recalculations
|
||||
self._matrix_cache_key = cache_key
|
||||
self._cached_matrix = np.array([
|
||||
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
|
||||
[0, zoom * 2 * cy / h, -y_offset / h * 2],
|
||||
[0, 0, 1.0]
|
||||
])
|
||||
|
||||
video_transform = np.array([
|
||||
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
|
||||
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
self.model_renderer.set_transform(video_transform @ calib_transform)
|
||||
self.model_renderer.set_frame_transform(video_transform, is_wide_camera)
|
||||
self.environment_renderer.set_transform(video_transform @ calib_transform)
|
||||
|
||||
return self._cached_matrix
|
||||
|
||||
def show_event(self):
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
|
||||
|
||||
def hide_event(self):
|
||||
if gui_app.iqpilot_ui():
|
||||
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("OnRoad Camera View")
|
||||
road_camera_view = AugmentedRoadView(ROAD_CAM)
|
||||
print("***press space to switch camera view***")
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
|
||||
if WIDE_CAM in road_camera_view.available_streams:
|
||||
stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
|
||||
road_camera_view.switch_stream(stream)
|
||||
road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
finally:
|
||||
road_camera_view.close()
|
||||
367
iqpilot/selfdrive/ui/onroad/cameraview.py
Normal file
367
iqpilot/selfdrive/ui/onroad/cameraview.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import platform
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from msgq.visionipc import VisionIpcClient, VisionBuf
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.system.hardware import EGL_DMA_BUF_SUPPORTED
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
|
||||
|
||||
VERSION = """
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
"""
|
||||
if platform.system() == "Darwin":
|
||||
VERSION = """
|
||||
#version 330 core
|
||||
"""
|
||||
|
||||
|
||||
VERTEX_SHADER = VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
in vec3 vertexNormal;
|
||||
in vec4 vertexColor;
|
||||
uniform mat4 mvp;
|
||||
out vec2 fragTexCoord;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
fragColor = vertexColor;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
# Choose fragment shader based on platform capabilities
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
FRAME_FRAGMENT_SHADER = """
|
||||
#version 300 es
|
||||
#extension GL_OES_EGL_image_external_essl3 : enable
|
||||
precision mediump float;
|
||||
in vec2 fragTexCoord;
|
||||
uniform samplerExternalOES texture0;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
vec4 color = texture(texture0, fragTexCoord);
|
||||
fragColor = vec4(pow(color.rgb, vec3(1.0/1.28)), color.a);
|
||||
}
|
||||
"""
|
||||
else:
|
||||
FRAME_FRAGMENT_SHADER = VERSION + """
|
||||
in vec2 fragTexCoord;
|
||||
uniform sampler2D texture0;
|
||||
uniform sampler2D texture1;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
float y = texture(texture0, fragTexCoord).r;
|
||||
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
|
||||
fragColor = vec4(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CameraView(Widget):
|
||||
def __init__(self, name: str, stream_type: VisionStreamType):
|
||||
super().__init__()
|
||||
self._name = name
|
||||
# Primary stream
|
||||
self.client = VisionIpcClient(name, stream_type, conflate=True)
|
||||
self._stream_type = stream_type
|
||||
self.available_streams: list[VisionStreamType] = []
|
||||
|
||||
# Target stream for switching
|
||||
self._target_client: VisionIpcClient | None = None
|
||||
self._target_stream_type: VisionStreamType | None = None
|
||||
self._switching: bool = False
|
||||
|
||||
self._texture_needs_update = True
|
||||
self.last_connection_attempt: float = 0.0
|
||||
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
|
||||
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1
|
||||
|
||||
self.frame: VisionBuf | None = None
|
||||
self.texture_y: rl.Texture | None = None
|
||||
self.texture_uv: rl.Texture | None = None
|
||||
|
||||
# EGL resources
|
||||
self.egl_images: dict[int, EGLImage] = {}
|
||||
self.egl_texture: rl.Texture | None = None
|
||||
|
||||
self._placeholder_color: rl.Color | None = None
|
||||
|
||||
# Initialize EGL for zero-copy rendering on comma 3/3X.
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
if not init_egl():
|
||||
raise RuntimeError("Failed to initialize EGL")
|
||||
|
||||
# Create a 1x1 pixel placeholder texture for EGL image binding
|
||||
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
|
||||
self.egl_texture = rl.load_texture_from_image(temp_image)
|
||||
rl.unload_image(temp_image)
|
||||
|
||||
ui_state.add_offroad_transition_callback(self._offroad_transition)
|
||||
|
||||
def _offroad_transition(self):
|
||||
# Reconnect if not first time going onroad
|
||||
if ui_state.is_onroad() and self.frame is not None:
|
||||
# Prevent old frames from showing when going onroad. Qt has a separate thread
|
||||
# which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough
|
||||
# and only clears internal buffers, not the message queue.
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
if self.client:
|
||||
del self.client
|
||||
self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
|
||||
|
||||
def _set_placeholder_color(self, color: rl.Color):
|
||||
"""Set a placeholder color to be drawn when no frame is available."""
|
||||
self._placeholder_color = color
|
||||
|
||||
def switch_stream(self, stream_type: VisionStreamType) -> None:
|
||||
if self._stream_type == stream_type:
|
||||
return
|
||||
|
||||
if self._switching and self._target_stream_type == stream_type:
|
||||
return
|
||||
|
||||
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
|
||||
|
||||
if self._target_client:
|
||||
del self._target_client
|
||||
|
||||
self._target_stream_type = stream_type
|
||||
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
|
||||
self._switching = True
|
||||
|
||||
@property
|
||||
def stream_type(self) -> VisionStreamType:
|
||||
return self._stream_type
|
||||
|
||||
def close(self) -> None:
|
||||
self._clear_textures()
|
||||
|
||||
# Clean up EGL texture
|
||||
if EGL_DMA_BUF_SUPPORTED and self.egl_texture:
|
||||
rl.unload_texture(self.egl_texture)
|
||||
self.egl_texture = None
|
||||
|
||||
# Clean up shader
|
||||
if self.shader and self.shader.id:
|
||||
rl.unload_shader(self.shader)
|
||||
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
self.client = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
if not self.frame:
|
||||
return np.eye(3)
|
||||
|
||||
# Calculate aspect ratios
|
||||
widget_aspect_ratio = rect.width / rect.height
|
||||
frame_aspect_ratio = self.frame.width / self.frame.height
|
||||
|
||||
# Calculate scaling factors to maintain aspect ratio
|
||||
zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
|
||||
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
|
||||
|
||||
return np.array([
|
||||
[zx, 0.0, 0.0],
|
||||
[0.0, zy, 0.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._switching:
|
||||
self._handle_switch()
|
||||
|
||||
if not self._ensure_connection():
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
# Try to get a new buffer without blocking
|
||||
buffer = self.client.recv(timeout_ms=0)
|
||||
if buffer:
|
||||
self._texture_needs_update = True
|
||||
self.frame = buffer
|
||||
elif not self.client.is_connected():
|
||||
# ensure we clear the displayed frame when the connection is lost
|
||||
self.frame = None
|
||||
|
||||
if not self.frame:
|
||||
self._draw_placeholder(rect)
|
||||
return
|
||||
|
||||
transform = self._calc_frame_matrix(rect)
|
||||
src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
|
||||
# Flip driver camera horizontally
|
||||
if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
|
||||
src_rect.width = -src_rect.width
|
||||
|
||||
# Calculate scale
|
||||
scale_x = rect.width * transform[0, 0] # zx
|
||||
scale_y = rect.height * transform[1, 1] # zy
|
||||
|
||||
# Calculate base position (centered)
|
||||
x_offset = rect.x + (rect.width - scale_x) / 2
|
||||
y_offset = rect.y + (rect.height - scale_y) / 2
|
||||
|
||||
x_offset += transform[0, 2] * rect.width / 2
|
||||
y_offset += transform[1, 2] * rect.height / 2
|
||||
|
||||
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
|
||||
|
||||
# Render with appropriate method
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
self._render_egl(src_rect, dst_rect)
|
||||
else:
|
||||
self._render_textures(src_rect, dst_rect)
|
||||
|
||||
def _draw_placeholder(self, rect: rl.Rectangle):
|
||||
if self._placeholder_color:
|
||||
rl.draw_rectangle_rec(rect, self._placeholder_color)
|
||||
|
||||
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using EGL for direct buffer access"""
|
||||
if self.frame is None or self.egl_texture is None:
|
||||
return
|
||||
|
||||
idx = self.frame.idx
|
||||
egl_image = self.egl_images.get(idx)
|
||||
|
||||
# Create EGL image if needed
|
||||
if egl_image is None:
|
||||
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
|
||||
if egl_image:
|
||||
self.egl_images[idx] = egl_image
|
||||
else:
|
||||
return
|
||||
|
||||
# Update texture dimensions to match current frame
|
||||
self.egl_texture.width = self.frame.width
|
||||
self.egl_texture.height = self.frame.height
|
||||
|
||||
# Bind the EGL image to our texture
|
||||
bind_egl_image_to_texture(self.egl_texture.id, egl_image)
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
|
||||
"""Render using texture copies"""
|
||||
if not self.texture_y or not self.texture_uv or self.frame is None:
|
||||
return
|
||||
|
||||
# Update textures with new frame data
|
||||
if self._texture_needs_update:
|
||||
y_data = self.frame.data[: self.frame.uv_offset]
|
||||
uv_data = self.frame.data[self.frame.uv_offset:]
|
||||
|
||||
rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
|
||||
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
|
||||
self._texture_needs_update = False
|
||||
|
||||
# Render with shader
|
||||
rl.begin_shader_mode(self.shader)
|
||||
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
|
||||
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
|
||||
def _ensure_connection(self) -> bool:
|
||||
if not self.client.is_connected():
|
||||
self.frame = None
|
||||
self.available_streams.clear()
|
||||
|
||||
# Throttle connection attempts
|
||||
current_time = rl.get_time()
|
||||
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
|
||||
return False
|
||||
self.last_connection_attempt = current_time
|
||||
|
||||
if not self.client.connect(False) or not self.client.num_buffers:
|
||||
return False
|
||||
|
||||
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
|
||||
self._initialize_textures()
|
||||
self.available_streams = self.client.available_streams(self._name, block=False)
|
||||
|
||||
return True
|
||||
|
||||
def _handle_switch(self) -> None:
|
||||
"""Check if target stream is ready and switch immediately."""
|
||||
if not self._target_client or not self._switching:
|
||||
return
|
||||
|
||||
# Try to connect target if needed
|
||||
if not self._target_client.is_connected():
|
||||
if not self._target_client.connect(False) or not self._target_client.num_buffers:
|
||||
return
|
||||
|
||||
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
|
||||
|
||||
# Check if target has frames ready
|
||||
target_frame = self._target_client.recv(timeout_ms=0)
|
||||
if target_frame:
|
||||
self.frame = target_frame # Update current frame to target frame
|
||||
self._complete_switch()
|
||||
|
||||
def _complete_switch(self) -> None:
|
||||
"""Instantly switch to target stream."""
|
||||
cloudlog.debug(f"Switching to {self._target_stream_type}")
|
||||
# Clean up current resources
|
||||
if self.client:
|
||||
del self.client
|
||||
|
||||
# Switch to target
|
||||
self.client = self._target_client
|
||||
self._stream_type = self._target_stream_type
|
||||
self._texture_needs_update = True
|
||||
|
||||
# Reset state
|
||||
self._target_client = None
|
||||
self._target_stream_type = None
|
||||
self._switching = False
|
||||
|
||||
# Initialize textures for new stream
|
||||
self._initialize_textures()
|
||||
|
||||
def _initialize_textures(self):
|
||||
self._clear_textures()
|
||||
if not EGL_DMA_BUF_SUPPORTED:
|
||||
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
|
||||
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
|
||||
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
|
||||
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
|
||||
|
||||
def _clear_textures(self):
|
||||
if self.texture_y and self.texture_y.id:
|
||||
rl.unload_texture(self.texture_y)
|
||||
self.texture_y = None
|
||||
|
||||
if self.texture_uv and self.texture_uv.id:
|
||||
rl.unload_texture(self.texture_uv)
|
||||
self.texture_uv = None
|
||||
|
||||
# Clean up EGL resources
|
||||
if EGL_DMA_BUF_SUPPORTED:
|
||||
for data in self.egl_images.values():
|
||||
destroy_egl_image(data)
|
||||
self.egl_images = {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("camera view")
|
||||
road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
|
||||
for _ in gui_app.render():
|
||||
road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
111
iqpilot/selfdrive/ui/onroad/driver_camera_dialog.py
Normal file
111
iqpilot/selfdrive/ui/onroad/driver_camera_dialog.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal.visionipc import VisionStreamType
|
||||
from iqpilot.selfdrive.ui.onroad.cameraview import CameraView
|
||||
from iqpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.widgets.label import gui_label
|
||||
|
||||
|
||||
class DriverCameraDialog(CameraView):
|
||||
def __init__(self):
|
||||
super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER)
|
||||
self.driver_state_renderer = DriverStateRenderer()
|
||||
# TODO: this can grow unbounded, should be given some thought
|
||||
device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None))
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", True)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
ui_state.params.put_bool("IsDriverViewEnabled", False)
|
||||
self.close()
|
||||
|
||||
def _handle_mouse_release(self, _):
|
||||
super()._handle_mouse_release(_)
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def _render(self, rect):
|
||||
super()._render(rect)
|
||||
|
||||
if not self.frame:
|
||||
gui_label(
|
||||
rect,
|
||||
tr("camera starting"),
|
||||
font_size=100,
|
||||
font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
)
|
||||
return -1
|
||||
|
||||
self._draw_face_detection(rect)
|
||||
self.driver_state_renderer.render(rect)
|
||||
|
||||
return -1
|
||||
|
||||
def _draw_face_detection(self, rect: rl.Rectangle) -> None:
|
||||
driver_state = ui_state.sm["driverStateV2"]
|
||||
is_rhd = driver_state.wheelOnRightProb > 0.5
|
||||
driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData
|
||||
face_detect = driver_data.faceProb > 0.7
|
||||
if not face_detect:
|
||||
return
|
||||
|
||||
# Get face position and orientation
|
||||
face_x, face_y = driver_data.facePosition
|
||||
face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
|
||||
alpha = 0.7
|
||||
if face_std > 0.15:
|
||||
alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
|
||||
|
||||
# use approx instead of distort_points
|
||||
# TODO: replace with distort_points
|
||||
fbox_x = int(1080.0 - 1714.0 * face_x)
|
||||
fbox_y = int(-135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y)
|
||||
box_size = 220
|
||||
|
||||
line_color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
rl.draw_rectangle_rounded_lines_ex(
|
||||
rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
|
||||
35.0 / box_size / 2,
|
||||
10,
|
||||
10,
|
||||
line_color,
|
||||
)
|
||||
|
||||
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
||||
driver_view_ratio = 2.0
|
||||
|
||||
# Get stream dimensions
|
||||
if self.frame:
|
||||
stream_width = self.frame.width
|
||||
stream_height = self.frame.height
|
||||
else:
|
||||
# Default values if frame not available
|
||||
stream_width = 1928
|
||||
stream_height = 1208
|
||||
|
||||
yscale = stream_height * driver_view_ratio / stream_width
|
||||
xscale = yscale * rect.height / rect.width * stream_width / stream_height
|
||||
|
||||
return np.array([
|
||||
[xscale, 0.0, 0.0],
|
||||
[0.0, yscale, 0.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Driver Camera View")
|
||||
|
||||
driver_camera_view = DriverCameraDialog()
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
ui_state.update()
|
||||
driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
finally:
|
||||
driver_camera_view.close()
|
||||
231
iqpilot/selfdrive/ui/onroad/driver_state.py
Normal file
231
iqpilot/selfdrive/ui/onroad/driver_state.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import log
|
||||
from dataclasses import dataclass
|
||||
from iqpilot.selfdrive.ui import UI_BORDER_SIZE
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
|
||||
# Default 3D coordinates for face keypoints as a NumPy array
|
||||
DEFAULT_FACE_KPTS_3D = np.array([
|
||||
[-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00],
|
||||
[-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00],
|
||||
[-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00],
|
||||
[-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00],
|
||||
[4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00],
|
||||
[24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00],
|
||||
[36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00],
|
||||
[30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00],
|
||||
[-5.98, -51.20, 8.00],
|
||||
], dtype=np.float32)
|
||||
|
||||
# UI constants
|
||||
BTN_SIZE = 192
|
||||
IMG_SIZE = 144
|
||||
ARC_LENGTH = 133
|
||||
ARC_THICKNESS_DEFAULT = 6.7
|
||||
ARC_THICKNESS_EXTEND = 12.0
|
||||
|
||||
SCALES_POS = np.array([0.9, 0.4, 0.4], dtype=np.float32)
|
||||
SCALES_NEG = np.array([0.7, 0.4, 0.4], dtype=np.float32)
|
||||
|
||||
ARC_POINT_COUNT = 37 # Number of points in the arc
|
||||
ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArcData:
|
||||
"""Data structure for arc rendering parameters."""
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
thickness: float
|
||||
|
||||
|
||||
class DriverStateRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Initial state with NumPy arrays
|
||||
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D.copy()
|
||||
self.is_active = False
|
||||
self.is_rhd = False
|
||||
self.dm_fade_state = 0.0
|
||||
self.driver_pose_vals = np.zeros(3, dtype=np.float32)
|
||||
self.driver_pose_diff = np.zeros(3, dtype=np.float32)
|
||||
self.driver_pose_sins = np.zeros(3, dtype=np.float32)
|
||||
self.driver_pose_coss = np.zeros(3, dtype=np.float32)
|
||||
self.face_keypoints_transformed = np.zeros((DEFAULT_FACE_KPTS_3D.shape[0], 2), dtype=np.float32)
|
||||
self.position_x: float = 0.0
|
||||
self.position_y: float = 0.0
|
||||
self.h_arc_data = None
|
||||
self.v_arc_data = None
|
||||
|
||||
# Pre-allocate drawing arrays
|
||||
self.face_lines = [rl.Vector2(0, 0) for _ in range(len(DEFAULT_FACE_KPTS_3D))]
|
||||
self.h_arc_lines = [rl.Vector2(0, 0) for _ in range(ARC_POINT_COUNT)]
|
||||
self.v_arc_lines = [rl.Vector2(0, 0) for _ in range(ARC_POINT_COUNT)]
|
||||
|
||||
# Load the driver face icon
|
||||
self.dm_img = gui_app.texture("icons/driver_face.png", IMG_SIZE, IMG_SIZE)
|
||||
|
||||
# Colors
|
||||
self.white_color = rl.Color(255, 255, 255, 255)
|
||||
self.arc_color = rl.Color(26, 242, 66, 255)
|
||||
self.engaged_color = rl.Color(0x0C, 0x94, 0x96, 0xFF)
|
||||
self.disengaged_color = rl.Color(139, 139, 139, 255)
|
||||
|
||||
self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
|
||||
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame))
|
||||
|
||||
def _render(self, rect):
|
||||
# Set opacity based on active state
|
||||
opacity = 0.65 if self.is_active else 0.2
|
||||
|
||||
# Draw background circle
|
||||
rl.draw_circle(int(self.position_x), int(self.position_y), BTN_SIZE // 2, rl.Color(0, 0, 0, 70))
|
||||
|
||||
# Draw face icon
|
||||
icon_pos = rl.Vector2(self.position_x - self.dm_img.width // 2, self.position_y - self.dm_img.height // 2)
|
||||
rl.draw_texture_v(self.dm_img, icon_pos, rl.Color(255, 255, 255, int(255 * opacity)))
|
||||
|
||||
# Draw face outline
|
||||
self.white_color.a = int(255 * opacity)
|
||||
rl.draw_spline_linear(self.face_lines, len(self.face_lines), 5.2, self.white_color)
|
||||
|
||||
# Set arc color based on engaged state
|
||||
self.arc_color = self.engaged_color if ui_state.engaged else self.disengaged_color
|
||||
self.arc_color.a = int(0.4 * 255 * (1.0 - self.dm_fade_state)) # Fade out when inactive
|
||||
|
||||
# Draw arcs
|
||||
if self.h_arc_data:
|
||||
rl.draw_spline_linear(self.h_arc_lines, len(self.h_arc_lines), self.h_arc_data.thickness, self.arc_color)
|
||||
if self.v_arc_data:
|
||||
rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color)
|
||||
|
||||
def _update_state(self):
|
||||
"""Update the driver monitoring state based on model data"""
|
||||
sm = ui_state.sm
|
||||
if not self.is_visible:
|
||||
return
|
||||
|
||||
# Get monitoring state
|
||||
dm_state = sm["driverMonitoringState"]
|
||||
self.is_active = dm_state.isActiveMode
|
||||
self.is_rhd = dm_state.isRHD
|
||||
|
||||
# Update fade state (smoother transition between active/inactive)
|
||||
fade_target = 0.0 if self.is_active else 0.5
|
||||
self.dm_fade_state = np.clip(self.dm_fade_state + 0.2 * (fade_target - self.dm_fade_state), 0.0, 1.0)
|
||||
|
||||
# Get driver orientation data from appropriate camera
|
||||
driverstate = sm["driverStateV2"]
|
||||
driver_data = driverstate.rightDriverData if self.is_rhd else driverstate.leftDriverData
|
||||
driver_orient = driver_data.faceOrientation
|
||||
|
||||
# Update pose values with scaling and smoothing
|
||||
driver_orient = np.array(driver_orient)
|
||||
scales = np.where(driver_orient < 0, SCALES_NEG, SCALES_POS)
|
||||
v_this = driver_orient * scales
|
||||
self.driver_pose_diff = np.abs(self.driver_pose_vals - v_this)
|
||||
self.driver_pose_vals = 0.8 * v_this + 0.2 * self.driver_pose_vals # Smooth changes
|
||||
|
||||
# Apply fade to rotation and compute sin/cos
|
||||
rotation_amount = self.driver_pose_vals * (1.0 - self.dm_fade_state)
|
||||
self.driver_pose_sins = np.sin(rotation_amount)
|
||||
self.driver_pose_coss = np.cos(rotation_amount)
|
||||
|
||||
# Create rotation matrix for 3D face model
|
||||
sin_y, sin_x, sin_z = self.driver_pose_sins
|
||||
cos_y, cos_x, cos_z = self.driver_pose_coss
|
||||
r_xyz = np.array(
|
||||
[
|
||||
[cos_x * cos_z, cos_x * sin_z, -sin_x],
|
||||
[-sin_y * sin_x * cos_z - cos_y * sin_z, -sin_y * sin_x * sin_z + cos_y * cos_z, -sin_y * cos_x],
|
||||
[cos_y * sin_x * cos_z - sin_y * sin_z, cos_y * sin_x * sin_z + sin_y * cos_z, cos_y * cos_x],
|
||||
]
|
||||
)
|
||||
|
||||
# Transform face keypoints using vectorized matrix multiplication
|
||||
self.face_kpts_draw = DEFAULT_FACE_KPTS_3D @ r_xyz.T
|
||||
self.face_kpts_draw[:, 2] = self.face_kpts_draw[:, 2] * (1.0 - self.dm_fade_state) + 8 * self.dm_fade_state
|
||||
|
||||
# Pre-calculate the transformed keypoints
|
||||
kp_depth = (self.face_kpts_draw[:, 2] - 8) / 120.0 + 1.0
|
||||
self.face_keypoints_transformed = self.face_kpts_draw[:, :2] * kp_depth[:, None]
|
||||
|
||||
# Pre-calculate all drawing elements
|
||||
self._pre_calculate_drawing_elements()
|
||||
|
||||
def _pre_calculate_drawing_elements(self):
|
||||
"""Pre-calculate all drawing elements based on the current rectangle"""
|
||||
# Calculate icon position (bottom-left or bottom-right)
|
||||
width, height = self._rect.width, self._rect.height
|
||||
offset = UI_BORDER_SIZE + BTN_SIZE // 2
|
||||
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
|
||||
self.position_y = self._rect.y + height - offset
|
||||
|
||||
# Pre-calculate the face lines positions
|
||||
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
|
||||
for i in range(len(positioned_keypoints)):
|
||||
self.face_lines[i].x = positioned_keypoints[i][0]
|
||||
self.face_lines[i].y = positioned_keypoints[i][1]
|
||||
|
||||
# Calculate arc dimensions based on head rotation
|
||||
delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0 # Horizontal movement
|
||||
delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0 # Vertical movement
|
||||
|
||||
# Horizontal arc
|
||||
h_width = abs(delta_x)
|
||||
self.h_arc_data = self._calculate_arc_data(
|
||||
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
|
||||
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
|
||||
)
|
||||
|
||||
# Vertical arc
|
||||
v_height = abs(delta_y)
|
||||
self.v_arc_data = self._calculate_arc_data(
|
||||
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
|
||||
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
|
||||
)
|
||||
|
||||
def _calculate_arc_data(
|
||||
self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool
|
||||
):
|
||||
"""Calculate arc data and pre-compute arc points."""
|
||||
if size <= 0:
|
||||
return None
|
||||
|
||||
thickness = ARC_THICKNESS_DEFAULT + ARC_THICKNESS_EXTEND * min(1.0, diff_val * 5.0)
|
||||
start_angle = (90 if sin_val > 0 else -90) if is_horizontal else (0 if sin_val > 0 else 180)
|
||||
x = min(x + delta, x) if is_horizontal else x
|
||||
y = y if is_horizontal else min(y + delta, y)
|
||||
|
||||
arc_data = ArcData(
|
||||
x=x,
|
||||
y=y,
|
||||
width=size if is_horizontal else ARC_LENGTH,
|
||||
height=ARC_LENGTH if is_horizontal else size,
|
||||
thickness=thickness,
|
||||
)
|
||||
|
||||
# Pre-calculate arc points
|
||||
angles = ARC_ANGLES + np.deg2rad(start_angle)
|
||||
|
||||
center_x = x + arc_data.width / 2
|
||||
center_y = y + arc_data.height / 2
|
||||
radius_x = arc_data.width / 2
|
||||
radius_y = arc_data.height / 2
|
||||
|
||||
x_coords = center_x + np.cos(angles) * radius_x
|
||||
y_coords = center_y - np.sin(angles) * radius_y
|
||||
|
||||
arc_lines = self.h_arc_lines if is_horizontal else self.v_arc_lines
|
||||
for i, (x_coord, y_coord) in enumerate(zip(x_coords, y_coords, strict=True)):
|
||||
arc_lines[i].x = x_coord
|
||||
arc_lines[i].y = y_coord
|
||||
|
||||
return arc_data
|
||||
141
iqpilot/selfdrive/ui/onroad/environment_renderer.py
Normal file
141
iqpilot/selfdrive/ui/onroad/environment_renderer.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import custom
|
||||
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
MODE_OFF = 0
|
||||
MODE_OVERLAY = 1
|
||||
MODE_REPLACE = 2
|
||||
|
||||
_ENV_LABEL = custom.IQEnvironment.Object.Label
|
||||
_OBJECT_COLORS = {
|
||||
_ENV_LABEL.car: (40, 210, 200),
|
||||
_ENV_LABEL.truck: (40, 210, 200),
|
||||
_ENV_LABEL.bus: (40, 210, 200),
|
||||
_ENV_LABEL.motorcycle: (90, 220, 255),
|
||||
_ENV_LABEL.bicycle: (90, 220, 255),
|
||||
_ENV_LABEL.person: (255, 210, 90),
|
||||
_ENV_LABEL.stopSign: (255, 60, 45),
|
||||
_ENV_LABEL.trafficLight: (255, 190, 0),
|
||||
}
|
||||
|
||||
_BOX_EDGES = (
|
||||
(0, 1), (1, 3), (3, 2), (2, 0),
|
||||
(4, 5), (5, 7), (7, 6), (6, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
)
|
||||
|
||||
GRID_HALF_WIDTH = 12.0
|
||||
GRID_MAX_DISTANCE = 90.0
|
||||
GRID_STEP = 6.0
|
||||
|
||||
|
||||
class EnvironmentRenderer(Widget):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
|
||||
self._mode = MODE_OFF
|
||||
self._counter = 0
|
||||
|
||||
def set_transform(self, transform: np.ndarray):
|
||||
self._car_space_transform = transform.astype(np.float32)
|
||||
|
||||
def _project(self, pt: np.ndarray):
|
||||
p = self._car_space_transform @ pt
|
||||
if abs(p[2]) < 1e-6:
|
||||
return None
|
||||
return p[0] / p[2], p[1] / p[2]
|
||||
|
||||
def _in_rect(self, x: float, y: float) -> bool:
|
||||
r = self._rect
|
||||
return r.x - 400 <= x <= r.x + r.width + 400 and r.y - 400 <= y <= r.y + r.height + 400
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
if self._counter % 30 == 0:
|
||||
self._mode = int(ui_state.params.get("EnvironmentView", return_default=True) or 0) if ui_state.active_bundle else 0
|
||||
self._counter += 1
|
||||
|
||||
if self._mode == MODE_OFF:
|
||||
return
|
||||
if sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame:
|
||||
return
|
||||
|
||||
if self._mode == MODE_REPLACE:
|
||||
self._draw_backdrop(rect)
|
||||
self._draw_ground_grid()
|
||||
if sm.valid["modelV2"]:
|
||||
self._draw_model_scene(sm["modelV2"])
|
||||
|
||||
if sm.alive["iqEnvironment"] and sm.valid["iqEnvironment"]:
|
||||
self._draw_objects(sm["iqEnvironment"])
|
||||
|
||||
def _draw_backdrop(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
|
||||
rl.Color(14, 17, 22, 255), rl.Color(6, 8, 11, 255))
|
||||
|
||||
def _draw_ground_grid(self):
|
||||
col = rl.Color(60, 70, 82, 90)
|
||||
dist = GRID_STEP
|
||||
while dist <= GRID_MAX_DISTANCE:
|
||||
a = self._project(np.array([dist, -GRID_HALF_WIDTH, 0.0]))
|
||||
b = self._project(np.array([dist, GRID_HALF_WIDTH, 0.0]))
|
||||
if a and b and self._in_rect(*a) and self._in_rect(*b):
|
||||
rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col)
|
||||
dist += GRID_STEP
|
||||
for off in np.arange(-GRID_HALF_WIDTH, GRID_HALF_WIDTH + 0.1, 3.0):
|
||||
a = self._project(np.array([GRID_STEP, float(off), 0.0]))
|
||||
b = self._project(np.array([GRID_MAX_DISTANCE, float(off), 0.0]))
|
||||
if a and b and self._in_rect(*a) and self._in_rect(*b):
|
||||
rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col)
|
||||
|
||||
def _draw_polyline(self, xs, ys, zs, color, thick):
|
||||
pts = []
|
||||
for x, y, z in zip(xs, ys, zs, strict=False):
|
||||
if x < 0:
|
||||
continue
|
||||
s = self._project(np.array([x, y, z], dtype=np.float32))
|
||||
if s and self._in_rect(*s):
|
||||
pts.append(rl.Vector2(*s))
|
||||
for i in range(len(pts) - 1):
|
||||
rl.draw_line_ex(pts[i], pts[i + 1], thick, color)
|
||||
|
||||
def _draw_model_scene(self, model):
|
||||
for i, lane in enumerate(model.laneLines):
|
||||
a = int(np.clip(model.laneLineProbs[i], 0.0, 0.9) * 255)
|
||||
self._draw_polyline(lane.x, lane.y, lane.z, rl.Color(235, 235, 235, a), 3.0)
|
||||
for edge in model.roadEdges:
|
||||
self._draw_polyline(edge.x, edge.y, edge.z, rl.Color(230, 70, 70, 180), 3.0)
|
||||
pos = model.position
|
||||
self._draw_polyline(pos.x, pos.y, pos.z, rl.Color(40, 210, 200, 220), 6.0)
|
||||
|
||||
def _draw_objects(self, env):
|
||||
for obj in env.objects:
|
||||
self._draw_box(obj)
|
||||
|
||||
def _draw_box(self, obj):
|
||||
hx, hy = obj.length / 2.0, obj.width / 2.0
|
||||
base = np.array([
|
||||
[obj.x - hx, obj.y - hy, obj.z], [obj.x - hx, obj.y + hy, obj.z],
|
||||
[obj.x + hx, obj.y - hy, obj.z], [obj.x + hx, obj.y + hy, obj.z],
|
||||
[obj.x - hx, obj.y - hy, obj.z + obj.height], [obj.x - hx, obj.y + hy, obj.z + obj.height],
|
||||
[obj.x + hx, obj.y - hy, obj.z + obj.height], [obj.x + hx, obj.y + hy, obj.z + obj.height],
|
||||
], dtype=np.float32)
|
||||
|
||||
screen = []
|
||||
for corner in base:
|
||||
s = self._project(corner)
|
||||
if s is None or not self._in_rect(*s):
|
||||
return
|
||||
screen.append(s)
|
||||
|
||||
r, g, b = _OBJECT_COLORS.get(obj.label, (40, 210, 200))
|
||||
a = int(np.clip(obj.prob, 0.3, 1.0) * 210)
|
||||
floor = [rl.Vector2(*screen[i]) for i in (0, 1, 3, 2)]
|
||||
rl.draw_triangle(floor[0], floor[1], floor[2], rl.Color(r, g, b, a // 5))
|
||||
rl.draw_triangle(floor[0], floor[2], floor[3], rl.Color(r, g, b, a // 5))
|
||||
for i, j in _BOX_EDGES:
|
||||
rl.draw_line_ex(rl.Vector2(*screen[i]), rl.Vector2(*screen[j]), 2.0, rl.Color(r, g, b, a))
|
||||
87
iqpilot/selfdrive/ui/onroad/exp_button.py
Normal file
87
iqpilot/selfdrive/ui/onroad/exp_button.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import time
|
||||
import pyray as rl
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class ExpButton(Widget):
|
||||
def __init__(self, button_size: int, icon_size: int):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._experimental_mode: bool = False
|
||||
self._iq_dynamic_mode: bool = False
|
||||
self._engageable: bool = False
|
||||
|
||||
# State hold mechanism
|
||||
self._hold_duration = 2.0 # seconds
|
||||
self._held_mode: tuple | None = None # (experimental, iq_dynamic) or None
|
||||
self._hold_end_time: float | None = None
|
||||
|
||||
self._white_color: rl.Color = rl.Color(255, 255, 255, 255)
|
||||
self._black_bg: rl.Color = rl.Color(0, 0, 0, 166)
|
||||
self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size)
|
||||
self._txt_standard: rl.Texture = gui_app.texture('icons_mici/iqstandard_mode_tizi.png', icon_size, icon_size)
|
||||
self._txt_pilot: rl.Texture = gui_app.texture('icons_mici/experimental_mode_tizi.png', icon_size, icon_size)
|
||||
self._txt_dyn: rl.Texture = gui_app.texture('icons_mici/iqdynamic_mode_tizi.png', icon_size, icon_size)
|
||||
self._rect = rl.Rectangle(0, 0, button_size, button_size)
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle) -> None:
|
||||
self._rect.x, self._rect.y = rect.x, rect.y
|
||||
|
||||
def _update_state(self) -> None:
|
||||
selfdrive_state = ui_state.sm["selfdriveState"]
|
||||
self._experimental_mode = selfdrive_state.experimentalMode
|
||||
self._iq_dynamic_mode = self._params.get_bool("IQDynamicMode")
|
||||
self._engageable = selfdrive_state.engageable or selfdrive_state.enabled
|
||||
|
||||
def _handle_mouse_release(self, _):
|
||||
super()._handle_mouse_release(_)
|
||||
if not self._is_toggle_allowed():
|
||||
return
|
||||
|
||||
exp, dyn = self._current_mode()
|
||||
# Cycle: IQ.Chill → IQ.Dynamic → IQ.Pilot → IQ.Chill
|
||||
if not exp:
|
||||
new_exp, new_dyn = True, True
|
||||
elif dyn:
|
||||
new_exp, new_dyn = True, False
|
||||
else:
|
||||
new_exp, new_dyn = False, False
|
||||
|
||||
self._params.put_bool("ExperimentalMode", new_exp)
|
||||
self._params.put_bool("IQDynamicMode", new_dyn)
|
||||
self._held_mode = (new_exp, new_dyn)
|
||||
self._hold_end_time = time.monotonic() + self._hold_duration
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
center_x = int(self._rect.x + self._rect.width // 2)
|
||||
center_y = int(self._rect.y + self._rect.height // 2)
|
||||
|
||||
self._white_color.a = 180 if self.is_pressed or not self._engageable else 255
|
||||
|
||||
exp, dyn = self._current_mode()
|
||||
if not ui_state.has_longitudinal_control:
|
||||
texture = self._txt_wheel
|
||||
elif exp and dyn:
|
||||
texture = self._txt_dyn
|
||||
elif exp:
|
||||
texture = self._txt_pilot
|
||||
else:
|
||||
texture = self._txt_standard
|
||||
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
|
||||
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
|
||||
|
||||
def _current_mode(self) -> tuple:
|
||||
now = time.monotonic()
|
||||
if self._hold_end_time and now < self._hold_end_time:
|
||||
return self._held_mode
|
||||
if self._hold_end_time and now >= self._hold_end_time:
|
||||
self._hold_end_time = self._held_mode = None
|
||||
return (self._experimental_mode, self._iq_dynamic_mode)
|
||||
|
||||
def _is_toggle_allowed(self):
|
||||
if not self._params.get_bool("ExperimentalModeConfirmed"):
|
||||
return False
|
||||
return ui_state.has_longitudinal_control
|
||||
251
iqpilot/selfdrive/ui/onroad/hud_renderer.py
Normal file
251
iqpilot/selfdrive/ui/onroad/hud_renderer.py
Normal file
@@ -0,0 +1,251 @@
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from iqpilot.common.constants import CV
|
||||
from iqpilot.selfdrive.ui.onroad.exp_button import ExpButton
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
# Constants
|
||||
SET_SPEED_NA = 255
|
||||
KM_TO_MILE = 0.621371
|
||||
CRUISE_DISABLED_CHAR = '–'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UIConfig:
|
||||
header_height: int = 300
|
||||
border_size: int = 30
|
||||
button_size: int = 192
|
||||
set_speed_width_metric: int = 186
|
||||
set_speed_width_imperial: int = 174
|
||||
set_speed_height: int = 228
|
||||
wheel_icon_size: int = 144
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontSizes:
|
||||
current_speed: int = 176
|
||||
speed_unit: int = 66
|
||||
max_speed: int = 28
|
||||
set_speed: int = 74
|
||||
limit_speed: int = 64
|
||||
limit_offset: int = 30
|
||||
limit_unit: int = 22
|
||||
limit_label: int = 24
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Colors:
|
||||
WHITE = rl.WHITE
|
||||
DISENGAGED = rl.Color(145, 155, 149, 255)
|
||||
OVERRIDE = rl.Color(145, 155, 149, 255) # Added
|
||||
ENGAGED = rl.Color(0x0C, 0x94, 0x96, 0xFF)
|
||||
LIMIT_ENGAGED = rl.Color(0x27, 0xF5, 0xD3, 0xFF)
|
||||
DISENGAGED_BG = rl.Color(0, 0, 0, 153)
|
||||
OVERRIDE_BG = rl.Color(145, 155, 149, 204)
|
||||
ENGAGED_BG = rl.Color(128, 216, 166, 204)
|
||||
GREY = rl.Color(166, 166, 166, 255)
|
||||
DARK_GREY = rl.Color(114, 114, 114, 255)
|
||||
BLACK_TRANSLUCENT = rl.Color(0, 0, 0, 166)
|
||||
WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200)
|
||||
BORDER_TRANSLUCENT = rl.Color(255, 255, 255, 75)
|
||||
HEADER_GRADIENT_START = rl.Color(0, 0, 0, 114)
|
||||
HEADER_GRADIENT_END = rl.BLANK
|
||||
|
||||
|
||||
UI_CONFIG = UIConfig()
|
||||
FONT_SIZES = FontSizes()
|
||||
COLORS = Colors()
|
||||
|
||||
|
||||
class HudRenderer(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
"""Initialize the HUD renderer."""
|
||||
self.is_cruise_set: bool = False
|
||||
self.is_cruise_available: bool = True
|
||||
self.set_speed: float = SET_SPEED_NA
|
||||
self.speed: float = 0.0
|
||||
self.v_ego_cluster_seen: bool = False
|
||||
self.limit_speed_text: str = "---"
|
||||
self.limit_offset_text: str = ""
|
||||
self.limit_available: bool = False
|
||||
|
||||
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
|
||||
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
"""Update HUD state based on car state and controls state."""
|
||||
sm = ui_state.sm
|
||||
if sm.recv_frame["carState"] < ui_state.started_frame:
|
||||
self.is_cruise_set = False
|
||||
self.set_speed = SET_SPEED_NA
|
||||
self.speed = 0.0
|
||||
return
|
||||
|
||||
controls_state = sm['controlsState']
|
||||
car_state = sm['carState']
|
||||
|
||||
v_cruise_cluster = car_state.vCruiseCluster
|
||||
self.set_speed = (
|
||||
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
|
||||
)
|
||||
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
|
||||
self.is_cruise_available = self.set_speed != -1
|
||||
|
||||
if self.is_cruise_set and not ui_state.is_metric:
|
||||
self.set_speed *= KM_TO_MILE
|
||||
|
||||
v_ego_cluster = car_state.vEgoCluster
|
||||
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
|
||||
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
|
||||
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
self.speed = max(0.0, v_ego * speed_conversion)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
"""Render HUD elements to the screen."""
|
||||
# Draw the header background
|
||||
rl.draw_rectangle_gradient_v(
|
||||
int(rect.x),
|
||||
int(rect.y),
|
||||
int(rect.width),
|
||||
UI_CONFIG.header_height,
|
||||
COLORS.HEADER_GRADIENT_START,
|
||||
COLORS.HEADER_GRADIENT_END,
|
||||
)
|
||||
|
||||
if self.is_cruise_available:
|
||||
self._draw_set_speed(rect)
|
||||
|
||||
self._draw_current_speed(rect)
|
||||
|
||||
button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
|
||||
button_y = rect.y + UI_CONFIG.border_size
|
||||
self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size))
|
||||
|
||||
def user_interacting(self) -> bool:
|
||||
return self._exp_button.is_pressed
|
||||
|
||||
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
|
||||
"""Draw the compact stacked LIMIT / MAX speed indicator box."""
|
||||
set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
|
||||
x = rect.x + 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2
|
||||
y = rect.y + 45
|
||||
|
||||
set_speed_rect = rl.Rectangle(x, y, set_speed_width, UI_CONFIG.set_speed_height)
|
||||
rl.draw_rectangle_rounded(set_speed_rect, 0.35, 10, COLORS.BLACK_TRANSLUCENT)
|
||||
rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.35, 10, 6, COLORS.BORDER_TRANSLUCENT)
|
||||
split_y = y + 112
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(x + 18, split_y),
|
||||
rl.Vector2(x + set_speed_width - 18, split_y),
|
||||
3,
|
||||
COLORS.BORDER_TRANSLUCENT,
|
||||
)
|
||||
|
||||
max_color = COLORS.DARK_GREY
|
||||
set_speed_color = COLORS.WHITE
|
||||
limit_value_color = COLORS.WHITE if self.limit_available else COLORS.DARK_GREY
|
||||
if self.is_cruise_set:
|
||||
if ui_state.status == UIStatus.ENGAGED:
|
||||
max_color = COLORS.ENGAGED
|
||||
if self.limit_available:
|
||||
limit_value_color = COLORS.LIMIT_ENGAGED
|
||||
elif ui_state.status == UIStatus.DISENGAGED:
|
||||
max_color = COLORS.DISENGAGED
|
||||
elif ui_state.status == UIStatus.OVERRIDE:
|
||||
max_color = COLORS.OVERRIDE
|
||||
|
||||
limit_value_text = self.limit_speed_text
|
||||
if len(limit_value_text) <= 2:
|
||||
limit_value_size = FONT_SIZES.limit_speed
|
||||
elif len(limit_value_text) == 3:
|
||||
limit_value_size = 56
|
||||
else:
|
||||
limit_value_size = 48
|
||||
limit_value_width = measure_text_cached(self._font_bold, limit_value_text, limit_value_size).x
|
||||
limit_offset_text = self.limit_offset_text if self.limit_available else ""
|
||||
limit_offset_width = 0.0
|
||||
if limit_offset_text:
|
||||
limit_offset_width = measure_text_cached(self._font_semi_bold, limit_offset_text, FONT_SIZES.limit_offset).x + 8
|
||||
limit_value_x = x + (set_speed_width - limit_value_width - limit_offset_width) / 2
|
||||
rl.draw_text_ex(
|
||||
self._font_bold,
|
||||
limit_value_text,
|
||||
rl.Vector2(limit_value_x, y + 14),
|
||||
limit_value_size,
|
||||
0,
|
||||
limit_value_color,
|
||||
)
|
||||
if limit_offset_text:
|
||||
rl.draw_text_ex(
|
||||
self._font_semi_bold,
|
||||
limit_offset_text,
|
||||
rl.Vector2(limit_value_x + limit_value_width + 8, y + 22),
|
||||
FONT_SIZES.limit_offset,
|
||||
0,
|
||||
COLORS.WHITE_TRANSLUCENT,
|
||||
)
|
||||
|
||||
if self.limit_available:
|
||||
limit_unit_text = tr("LIMIT")
|
||||
limit_unit_width = measure_text_cached(self._font_medium, limit_unit_text, FONT_SIZES.limit_unit).x
|
||||
rl.draw_text_ex(
|
||||
self._font_medium,
|
||||
limit_unit_text,
|
||||
rl.Vector2(x + (set_speed_width - limit_unit_width) / 2, y + 78),
|
||||
FONT_SIZES.limit_unit,
|
||||
0,
|
||||
COLORS.WHITE_TRANSLUCENT,
|
||||
)
|
||||
else:
|
||||
limit_label_text = tr("LIMIT")
|
||||
limit_label_width = measure_text_cached(self._font_semi_bold, limit_label_text, FONT_SIZES.limit_label).x
|
||||
rl.draw_text_ex(
|
||||
self._font_semi_bold,
|
||||
limit_label_text,
|
||||
rl.Vector2(x + (set_speed_width - limit_label_width) / 2, y + 74),
|
||||
FONT_SIZES.limit_label,
|
||||
0,
|
||||
COLORS.GREY,
|
||||
)
|
||||
|
||||
max_text = tr("MAX")
|
||||
max_text_width = measure_text_cached(self._font_semi_bold, max_text, FONT_SIZES.max_speed).x
|
||||
rl.draw_text_ex(
|
||||
self._font_semi_bold,
|
||||
max_text,
|
||||
rl.Vector2(x + (set_speed_width - max_text_width) / 2, y + 118),
|
||||
FONT_SIZES.max_speed,
|
||||
0,
|
||||
max_color,
|
||||
)
|
||||
|
||||
set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(self.set_speed))
|
||||
speed_text_width = measure_text_cached(self._font_bold, set_speed_text, FONT_SIZES.set_speed).x
|
||||
rl.draw_text_ex(
|
||||
self._font_bold,
|
||||
set_speed_text,
|
||||
rl.Vector2(x + (set_speed_width - speed_text_width) / 2, y + 136),
|
||||
FONT_SIZES.set_speed,
|
||||
0,
|
||||
set_speed_color,
|
||||
)
|
||||
|
||||
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
|
||||
"""Draw the current vehicle speed and unit."""
|
||||
speed_text = str(round(self.speed))
|
||||
speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
|
||||
speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
|
||||
rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.WHITE)
|
||||
|
||||
unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
|
||||
unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
|
||||
unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
|
||||
rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.WHITE_TRANSLUCENT)
|
||||
612
iqpilot/selfdrive/ui/onroad/model_renderer.py
Normal file
612
iqpilot/selfdrive/ui/onroad/model_renderer.py
Normal file
@@ -0,0 +1,612 @@
|
||||
import colorsys
|
||||
import numpy as np
|
||||
import pyray as rl
|
||||
from iqpilot.cereal import car, custom
|
||||
from dataclasses import dataclass, field
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
|
||||
from iqpilot.selfdrive.locationd.calibration_helpers import get_render_path_height
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, log_param_from_bytes
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
from iqpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
|
||||
from iqpilot.ui.onroad.hud_overlays import ChevronMetrics
|
||||
from iqpilot.ui.onroad.lead_confidence import driving_confidence
|
||||
|
||||
CLIP_MARGIN = 500
|
||||
MIN_DRAW_DISTANCE = 10.0
|
||||
MAX_DRAW_DISTANCE = 100.0
|
||||
|
||||
_VT_LABEL = custom.IQVehicleTracks.Track.Label
|
||||
VEHICLE_TRACK_LABELS = (_VT_LABEL.car, _VT_LABEL.motorcycle, _VT_LABEL.bus, _VT_LABEL.truck)
|
||||
SIGN_TRACK_COLORS = {
|
||||
_VT_LABEL.stopSign: rl.Color(255, 60, 45, 200),
|
||||
_VT_LABEL.trafficLight: rl.Color(255, 190, 0, 200),
|
||||
}
|
||||
|
||||
THROTTLE_COLORS = [
|
||||
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
|
||||
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
|
||||
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
|
||||
]
|
||||
|
||||
NO_THROTTLE_COLORS = [
|
||||
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
|
||||
rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
|
||||
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class ModelPoints:
|
||||
raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
|
||||
projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadVehicle:
|
||||
center: tuple[float, float] | None = None
|
||||
radius: float = 0.0
|
||||
sz: float = 0.0
|
||||
fill_alpha: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisionDot:
|
||||
x: float
|
||||
y: float
|
||||
tx: float
|
||||
ty: float
|
||||
radius: float
|
||||
tradius: float
|
||||
alpha: float = 0.0
|
||||
talpha: float = 1.0
|
||||
rgb: tuple[int, int, int] | None = None
|
||||
|
||||
|
||||
_VD_EASE = 0.4
|
||||
|
||||
|
||||
class ModelRenderer(Widget):
|
||||
def __init__(self):
|
||||
Widget.__init__(self)
|
||||
self.chevron_metrics = ChevronMetrics()
|
||||
self._lead_orb = gui_app.texture("icons/lead_orb.png", 256, 256)
|
||||
self._longitudinal_control = False
|
||||
self._experimental_mode = False
|
||||
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
|
||||
self._prev_allow_throttle = True
|
||||
self._lane_line_probs = np.zeros(4, dtype=np.float32)
|
||||
self._road_edge_stds = np.zeros(2, dtype=np.float32)
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
self._track_dots: list[LeadVehicle] = []
|
||||
self._vision_dots: list[VisionDot] = []
|
||||
self._vt_frame = -1
|
||||
self._frame_transform: np.ndarray | None = None
|
||||
self._frame_transform_wide = False
|
||||
self._path_offset_z = HEIGHT_INIT[0]
|
||||
self._counter = -1
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
self._ambient_dots = bool(ui_state.params.get("AmbientTrackDots", return_default=True))
|
||||
# Initialize ModelPoints objects
|
||||
self._path = ModelPoints()
|
||||
self._lane_lines = [ModelPoints() for _ in range(4)]
|
||||
self._road_edges = [ModelPoints() for _ in range(2)]
|
||||
self._acceleration_x = np.empty((0,), dtype=np.float32)
|
||||
|
||||
# Transform matrix (3x3 for car space to screen space)
|
||||
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
|
||||
self._transform_dirty = True
|
||||
self._clip_region = None
|
||||
|
||||
self._exp_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=[],
|
||||
stops=[],
|
||||
)
|
||||
|
||||
# Get longitudinal control setting from car parameters
|
||||
if (cp := log_param_from_bytes(Params(), "CarParams", car.CarParams)) is not None:
|
||||
self._longitudinal_control = cp.openpilotLongitudinalControl
|
||||
|
||||
def set_transform(self, transform: np.ndarray):
|
||||
self._car_space_transform = transform.astype(np.float32)
|
||||
self._transform_dirty = True
|
||||
|
||||
def set_frame_transform(self, transform: np.ndarray, is_wide: bool):
|
||||
self._frame_transform = transform.astype(np.float32)
|
||||
self._frame_transform_wide = is_wide
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
driving_confidence.update()
|
||||
|
||||
# Check if data is up-to-date
|
||||
if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or
|
||||
sm.recv_frame["modelV2"] < ui_state.started_frame):
|
||||
return
|
||||
|
||||
# Set up clipping region
|
||||
self._clip_region = rl.Rectangle(
|
||||
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
|
||||
)
|
||||
|
||||
# Update state
|
||||
self._experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
|
||||
live_calib = sm['extrinsicsCalibration']
|
||||
self._path_offset_z = get_render_path_height(live_calib)
|
||||
|
||||
if self._counter % 60 == 0:
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
self._ambient_dots = bool(ui_state.params.get("AmbientTrackDots", return_default=True))
|
||||
self._counter += 1
|
||||
|
||||
if sm.updated['carParams']:
|
||||
self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
|
||||
|
||||
model = sm['modelV2']
|
||||
radar_state = sm['radarState'] if sm.valid['radarState'] else None
|
||||
lead_one = radar_state.leadOne if radar_state else None
|
||||
render_lead_indicator = self._longitudinal_control and radar_state is not None
|
||||
|
||||
# Update model data when needed
|
||||
model_updated = sm.updated['modelV2']
|
||||
if model_updated or sm.updated['radarState'] or self._transform_dirty:
|
||||
if model_updated:
|
||||
self._update_raw_points(model)
|
||||
|
||||
path_x_array = self._path.raw_points[:, 0]
|
||||
if path_x_array.size == 0:
|
||||
return
|
||||
|
||||
self._update_model(lead_one, path_x_array)
|
||||
if render_lead_indicator:
|
||||
self._update_leads(radar_state, path_x_array)
|
||||
if self._ambient_dots:
|
||||
self._update_track_dots(sm, radar_state, path_x_array)
|
||||
else:
|
||||
self._track_dots = []
|
||||
self._transform_dirty = False
|
||||
|
||||
# Draw elements
|
||||
self._draw_lane_lines()
|
||||
self._draw_path(sm)
|
||||
|
||||
if self._ambient_dots:
|
||||
self._update_vision_dots(sm)
|
||||
self._draw_vision_dots()
|
||||
|
||||
if render_lead_indicator and radar_state:
|
||||
if self._ambient_dots:
|
||||
self._draw_track_dots()
|
||||
self._draw_lead_indicator()
|
||||
self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles)
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
|
||||
|
||||
for i, lane_line in enumerate(model.laneLines):
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
|
||||
|
||||
for i, road_edge in enumerate(model.roadEdges):
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
|
||||
|
||||
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
|
||||
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
|
||||
self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
|
||||
|
||||
def _update_leads(self, radar_state, path_x_array):
|
||||
"""Update positions of lead vehicles"""
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
leads = [radar_state.leadOne, radar_state.leadTwo]
|
||||
|
||||
for i, lead_data in enumerate(leads):
|
||||
if lead_data and lead_data.status:
|
||||
d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
|
||||
idx = self._get_path_length_idx(path_x_array, d_rel)
|
||||
|
||||
# Get z-coordinate from path at the lead vehicle position
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
|
||||
if point:
|
||||
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
|
||||
|
||||
def _update_track_dots(self, sm, radar_state, path_x_array):
|
||||
self._track_dots = []
|
||||
if not sm.valid['radarTracks']:
|
||||
return
|
||||
|
||||
lead_track_ids = {lead.radarTrackId for lead in (radar_state.leadOne, radar_state.leadTwo)
|
||||
if lead.status and lead.radarTrackId >= 0}
|
||||
|
||||
for pt in sm['radarTracks'].points:
|
||||
if pt.trackId in lead_track_ids or pt.dRel < 1.0 or abs(pt.yRel) > 10.0:
|
||||
continue
|
||||
|
||||
idx = self._get_path_length_idx(path_x_array, pt.dRel)
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(pt.dRel, -pt.yRel + self._camera_offset, z + self._path_offset_z)
|
||||
if point is None:
|
||||
continue
|
||||
|
||||
sz = np.clip((25 * 30) / (pt.dRel / 3 + 30), 15.0, 30.0) * 1.1
|
||||
radius = sz * 1.1
|
||||
x, y = point
|
||||
if not (self._rect.x <= x <= self._rect.x + self._rect.width and
|
||||
self._rect.y <= y <= self._rect.y + self._rect.height):
|
||||
continue
|
||||
|
||||
self._track_dots.append(LeadVehicle(center=(float(x), float(y)), radius=float(radius), sz=float(sz)))
|
||||
|
||||
def _update_vision_dots(self, sm):
|
||||
# iqVehicleTracks arrives at a few Hz; the dots are eased toward the latest
|
||||
# detection every render frame so they glide instead of teleporting.
|
||||
hidden = (self._frame_transform is None or
|
||||
not sm.alive['iqVehicleTracks'] or not sm.valid['iqVehicleTracks'])
|
||||
if not hidden:
|
||||
vt = sm['iqVehicleTracks']
|
||||
hidden = bool(vt.wide) != self._frame_transform_wide or vt.frameWidth == 0 or vt.frameHeight == 0
|
||||
|
||||
if hidden:
|
||||
for d in self._vision_dots:
|
||||
d.talpha = 0.0
|
||||
elif vt.frameId != self._vt_frame:
|
||||
self._vt_frame = vt.frameId
|
||||
self._retarget_vision_dots(vt)
|
||||
|
||||
for d in self._vision_dots:
|
||||
d.x += (d.tx - d.x) * _VD_EASE
|
||||
d.y += (d.ty - d.y) * _VD_EASE
|
||||
d.radius += (d.tradius - d.radius) * _VD_EASE
|
||||
d.alpha += (d.talpha - d.alpha) * _VD_EASE
|
||||
self._vision_dots = [d for d in self._vision_dots if d.alpha > 0.02 or d.talpha > 0.0]
|
||||
|
||||
def _retarget_vision_dots(self, vt):
|
||||
fw, fh = vt.frameWidth, vt.frameHeight
|
||||
m = self._frame_transform
|
||||
occupied = [d.center for d in self._lead_vehicles + self._track_dots if d.center is not None]
|
||||
|
||||
targets = []
|
||||
for t in vt.tracks:
|
||||
is_vehicle = t.label in VEHICLE_TRACK_LABELS
|
||||
sign_color = SIGN_TRACK_COLORS.get(t.label)
|
||||
if not is_vehicle and sign_color is None:
|
||||
continue
|
||||
cx_f = (t.x1 + t.x2) / 2.0 * fw
|
||||
cy_f = (t.y1 + t.y2) / 2.0 * fh
|
||||
x = m[0, 0] * cx_f + m[0, 2]
|
||||
y = m[1, 1] * cy_f + m[1, 2]
|
||||
if not (self._rect.x <= x <= self._rect.x + self._rect.width and
|
||||
self._rect.y <= y <= self._rect.y + self._rect.height):
|
||||
continue
|
||||
box_h = (t.y2 - t.y1) * fh * m[1, 1]
|
||||
radius = float(np.clip(box_h * 0.35, 14.0, 40.0))
|
||||
if sign_color is not None:
|
||||
targets.append((x, y, min(radius, 22.0), (sign_color.r, sign_color.g, sign_color.b)))
|
||||
elif not any((x - ox) ** 2 + (y - oy) ** 2 < (radius * 2.2) ** 2 for ox, oy in occupied):
|
||||
targets.append((x, y, radius, None))
|
||||
|
||||
dots = self._vision_dots
|
||||
used = [False] * len(dots)
|
||||
for tx, ty, tr, rgb in targets:
|
||||
best, best_d2 = -1, 1e18
|
||||
for i, d in enumerate(dots):
|
||||
if used[i] or (d.rgb is None) != (rgb is None):
|
||||
continue
|
||||
d2 = (d.x - tx) ** 2 + (d.y - ty) ** 2
|
||||
if d2 < best_d2:
|
||||
best, best_d2 = i, d2
|
||||
if best >= 0 and best_d2 <= (max(tr, dots[best].radius) * 3.0) ** 2:
|
||||
d = dots[best]
|
||||
used[best] = True
|
||||
d.tx, d.ty, d.tradius, d.talpha, d.rgb = tx, ty, tr, 1.0, rgb
|
||||
else:
|
||||
dots.append(VisionDot(x=tx, y=ty, tx=tx, ty=ty, radius=tr, tradius=tr, alpha=0.0, talpha=1.0, rgb=rgb))
|
||||
used.append(True)
|
||||
|
||||
for i, d in enumerate(dots):
|
||||
if not used[i]:
|
||||
d.talpha = 0.0
|
||||
|
||||
def _update_model(self, lead, path_x_array):
|
||||
"""Update model visualization data based on model message"""
|
||||
max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
|
||||
max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
|
||||
|
||||
# Update lane lines using raw points
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
lane_line.projected_points = self._map_line_to_polygon(
|
||||
lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance
|
||||
)
|
||||
|
||||
# Update road edges using raw points
|
||||
for road_edge in self._road_edges:
|
||||
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance)
|
||||
|
||||
# Update path using raw points
|
||||
if lead and lead.status:
|
||||
lead_d = lead.dRel * 2.0
|
||||
max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
|
||||
|
||||
max_idx = self._get_path_length_idx(path_x_array, max_distance)
|
||||
self._path.projected_points = self._map_line_to_polygon(
|
||||
self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False
|
||||
)
|
||||
|
||||
self._update_experimental_gradient()
|
||||
|
||||
def _update_experimental_gradient(self):
|
||||
"""Pre-calculate experimental mode gradient colors"""
|
||||
if not self._experimental_mode:
|
||||
return
|
||||
|
||||
max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x))
|
||||
|
||||
segment_colors = []
|
||||
gradient_stops = []
|
||||
|
||||
i = 0
|
||||
while i < max_len:
|
||||
# Some points (screen space) are out of frame (rect space)
|
||||
track_y = self._path.projected_points[i][1]
|
||||
if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate color based on acceleration (0 is bottom, 1 is top)
|
||||
lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
|
||||
|
||||
# speed up: 120, slow down: 0
|
||||
path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
|
||||
|
||||
saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
|
||||
lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
|
||||
alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
|
||||
|
||||
# Use HSL to RGB conversion
|
||||
color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
|
||||
|
||||
gradient_stops.append(lin_grad_point)
|
||||
segment_colors.append(color)
|
||||
|
||||
# Skip a point, unless next is last
|
||||
i += 1 + (1 if (i + 2) < max_len else 0)
|
||||
|
||||
# Store the gradient in the path object
|
||||
self._exp_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=segment_colors,
|
||||
stops=gradient_stops,
|
||||
)
|
||||
|
||||
def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
|
||||
speed_buff, lead_buff = 10.0, 40.0
|
||||
|
||||
# Calculate fill alpha
|
||||
fill_alpha = 0
|
||||
if d_rel < lead_buff:
|
||||
fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
|
||||
if v_rel < 0:
|
||||
fill_alpha += 255 * (-1 * (v_rel / speed_buff))
|
||||
fill_alpha = min(fill_alpha, 255)
|
||||
|
||||
# Calculate size and position. Distance-scaled orb radius (closer lead -> bigger orb).
|
||||
sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 2.35
|
||||
radius = sz * 1.1
|
||||
# point is in absolute screen coords; clamp against the rect's absolute bounds so the orb stays
|
||||
# fully on-screen (rect-relative bounds mis-placed it when the camera pane is offset, e.g. split nav)
|
||||
x = np.clip(point[0], rect.x + radius, rect.x + rect.width - radius)
|
||||
y = np.clip(point[1], rect.y + radius, rect.y + rect.height - radius)
|
||||
|
||||
return LeadVehicle(center=(float(x), float(y)), radius=float(radius), sz=float(sz), fill_alpha=int(fill_alpha))
|
||||
|
||||
def _draw_lane_lines(self):
|
||||
"""Draw lane lines and road edges"""
|
||||
for i, lane_line in enumerate(self._lane_lines):
|
||||
if lane_line.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
alpha = np.clip(self._lane_line_probs[i], 0.0, 0.7)
|
||||
color = rl.Color(255, 255, 255, int(alpha * 255))
|
||||
draw_polygon(self._rect, lane_line.projected_points, color)
|
||||
|
||||
for i, road_edge in enumerate(self._road_edges):
|
||||
if road_edge.projected_points.size == 0:
|
||||
continue
|
||||
|
||||
alpha = np.clip(1.0 - self._road_edge_stds[i], 0.0, 1.0)
|
||||
color = rl.Color(255, 0, 0, int(alpha * 255))
|
||||
draw_polygon(self._rect, road_edge.projected_points, color)
|
||||
|
||||
def _draw_path(self, sm):
|
||||
"""Draw path with dynamic coloring based on mode and throttle state."""
|
||||
if not self._path.projected_points.size:
|
||||
return
|
||||
|
||||
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
|
||||
self._blend_filter.update(int(allow_throttle))
|
||||
|
||||
|
||||
if self._experimental_mode:
|
||||
# Draw with acceleration coloring
|
||||
if len(self._exp_gradient.colors) > 1:
|
||||
draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
|
||||
else:
|
||||
draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30))
|
||||
else:
|
||||
# Blend throttle/no throttle colors based on transition
|
||||
blend_factor = round(self._blend_filter.x * 100) / 100
|
||||
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
|
||||
gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=blended_colors,
|
||||
stops=[0.0, 0.5, 1.0],
|
||||
)
|
||||
draw_polygon(self._rect, self._path.projected_points, gradient=gradient)
|
||||
|
||||
# concentric layers (outer faint -> inner bright) build a soft center-out glow using only
|
||||
# draw_circle, which is signature-stable across raylib versions (draw_circle_gradient is not)
|
||||
_VD_GLOW = ((1.0, 26), (0.72, 38), (0.48, 54), (0.26, 78))
|
||||
|
||||
def _draw_vision_dots(self):
|
||||
for dot in self._vision_dots:
|
||||
a = dot.alpha
|
||||
if a <= 0.02:
|
||||
continue
|
||||
x, y = int(dot.x), int(dot.y)
|
||||
cr, cg, cb = (40, 210, 200) if dot.rgb is None else dot.rgb
|
||||
for frac, base in self._VD_GLOW:
|
||||
rl.draw_circle(x, y, dot.radius * frac, rl.Color(cr, cg, cb, int(base * a)))
|
||||
|
||||
def _draw_track_dots(self):
|
||||
src = rl.Rectangle(0, 0, self._lead_orb.width, self._lead_orb.height)
|
||||
for dot in self._track_dots:
|
||||
cx, cy = dot.center
|
||||
r = dot.radius
|
||||
dest = rl.Rectangle(cx, cy, r * 2.0, r * 2.0)
|
||||
rl.draw_texture_pro(self._lead_orb, src, dest, rl.Vector2(r, r), 0.0, rl.Color(255, 255, 255, 90))
|
||||
|
||||
def _draw_lead_indicator(self):
|
||||
tint, _ = driving_confidence.colors()
|
||||
src = rl.Rectangle(0, 0, self._lead_orb.width, self._lead_orb.height)
|
||||
for lead in self._lead_vehicles:
|
||||
if lead.center is None:
|
||||
continue
|
||||
cx, cy = lead.center
|
||||
r = lead.radius
|
||||
alpha = int(np.clip(140 + 115 * (lead.fill_alpha / 255.0), 0, 255))
|
||||
dest = rl.Rectangle(cx, cy, r * 2.0, r * 2.0)
|
||||
rl.draw_texture_pro(self._lead_orb, src, dest, rl.Vector2(r, r), 0.0, rl.Color(tint.r, tint.g, tint.b, alpha))
|
||||
|
||||
@staticmethod
|
||||
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int:
|
||||
"""Get the index corresponding to the given path distance"""
|
||||
if len(pos_x_array) == 0:
|
||||
return 0
|
||||
indices = np.where(pos_x_array <= path_distance)[0]
|
||||
return indices[-1] if indices.size > 0 else 0
|
||||
|
||||
def _map_to_screen(self, in_x, in_y, in_z):
|
||||
"""Project a point in car space to screen space"""
|
||||
input_pt = np.array([in_x, in_y, in_z])
|
||||
pt = self._car_space_transform @ input_pt
|
||||
|
||||
if abs(pt[2]) < 1e-6:
|
||||
return None
|
||||
|
||||
x, y = pt[0] / pt[2], pt[1] / pt[2]
|
||||
|
||||
clip = self._clip_region
|
||||
if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
|
||||
return None
|
||||
|
||||
return (x, y)
|
||||
|
||||
def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, max_distance: float, allow_invert: bool = True) -> np.ndarray:
|
||||
"""Convert 3D line to 2D polygon for rendering."""
|
||||
if line.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Slice points and filter non-negative x-coordinates
|
||||
points = line[:max_idx + 1]
|
||||
|
||||
# Interpolate around max_idx so path end is smooth. Only when max_distance genuinely
|
||||
if 0 < max_idx < line.shape[0] - 1:
|
||||
p0 = line[max_idx]
|
||||
p1 = line[max_idx + 1]
|
||||
x0, x1 = p0[0], p1[0]
|
||||
if x0 <= max_distance <= x1:
|
||||
interp_y = np.interp(max_distance, [x0, x1], [p0[1], p1[1]])
|
||||
interp_z = np.interp(max_distance, [x0, x1], [p0[2], p1[2]])
|
||||
interp_point = np.array([max_distance, interp_y, interp_z], dtype=points.dtype)
|
||||
points = np.concatenate((points, interp_point[None, :]), axis=0)
|
||||
|
||||
points = points[points[:, 0] >= 0.5]
|
||||
if points.shape[0] == 0:
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
N = points.shape[0]
|
||||
# Generate left and right 3D points in one array using broadcasting
|
||||
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
|
||||
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
|
||||
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
|
||||
|
||||
# Transform all points to projected space in one operation
|
||||
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
|
||||
proj = proj.reshape(3, 2, N)
|
||||
left_proj = proj[:, 0, :]
|
||||
right_proj = proj[:, 1, :]
|
||||
|
||||
# Filter points where z is sufficiently large
|
||||
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
|
||||
if not np.any(valid_proj):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Compute screen coordinates
|
||||
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
|
||||
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
|
||||
|
||||
# Define clip region bounds
|
||||
clip = self._clip_region
|
||||
x_min, x_max = clip.x, clip.x + clip.width
|
||||
y_min, y_max = clip.y, clip.y + clip.height
|
||||
|
||||
# Filter points within clip region
|
||||
left_in_clip = (
|
||||
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
|
||||
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
|
||||
)
|
||||
right_in_clip = (
|
||||
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
|
||||
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
|
||||
)
|
||||
both_in_clip = left_in_clip & right_in_clip
|
||||
|
||||
if not np.any(both_in_clip):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
|
||||
# Select valid and clipped points
|
||||
left_screen = left_screen[:, both_in_clip]
|
||||
right_screen = right_screen[:, both_in_clip]
|
||||
|
||||
# Handle Y-coordinate inversion on hills
|
||||
if not allow_invert and left_screen.shape[1] > 1:
|
||||
y = left_screen[1, :] # y-coordinates
|
||||
keep = y == np.minimum.accumulate(y)
|
||||
if not np.any(keep):
|
||||
return np.empty((0, 2), dtype=np.float32)
|
||||
left_screen = left_screen[:, keep]
|
||||
right_screen = right_screen[:, keep]
|
||||
|
||||
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
|
||||
|
||||
@staticmethod
|
||||
def _hsla_to_color(h, s, l, a):
|
||||
rgb = colorsys.hls_to_rgb(h, l, s)
|
||||
return rl.Color(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255),
|
||||
int(a * 255)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _blend_colors(begin_colors, end_colors, t):
|
||||
if t >= 1.0:
|
||||
return end_colors
|
||||
if t <= 0.0:
|
||||
return begin_colors
|
||||
|
||||
inv_t = 1.0 - t
|
||||
return [rl.Color(
|
||||
int(inv_t * start.r + t * end.r),
|
||||
int(inv_t * start.g + t * end.g),
|
||||
int(inv_t * start.b + t * end.b),
|
||||
int(inv_t * start.a + t * end.a)
|
||||
) for start, end in zip(begin_colors, end_colors, strict=True)]
|
||||
265
iqpilot/selfdrive/ui/soundd.py
Normal file
265
iqpilot/selfdrive/ui/soundd.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from collections import deque
|
||||
import math
|
||||
import numpy as np
|
||||
import time
|
||||
import threading
|
||||
import wave
|
||||
|
||||
|
||||
from iqpilot.cereal import car, messaging, custom
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.common.realtime import Ratekeeper
|
||||
from iqpilot.common.utils import retry
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
|
||||
from iqpilot.system import micd
|
||||
from iqpilot.system.hardware import HARDWARE
|
||||
|
||||
from iqpilot.selfdrive.ui.alert_sound_filter import AlertSoundFilter
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
SAMPLE_BUFFER = 4096 # (approx 100ms)
|
||||
WEBRTC_AUDIO_BUFFER_FRAMES = SAMPLE_RATE # 1s max queued remote audio
|
||||
WEBRTC_AUDIO_PREBUFFER_FRAMES = SAMPLE_RATE // 5 # 200ms before starting remote audio playback
|
||||
MAX_VOLUME = 1.0
|
||||
MIN_VOLUME = 0.1
|
||||
ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate
|
||||
SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds
|
||||
FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
|
||||
|
||||
AMBIENT_DB = 30 # DB where MIN_VOLUME is applied
|
||||
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
|
||||
|
||||
VOLUME_BASE = 20
|
||||
if HARDWARE.get_device_type() == "tizi":
|
||||
VOLUME_BASE = 10
|
||||
|
||||
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
|
||||
AudibleAlertIQ = custom.IQState.AudibleAlert
|
||||
|
||||
|
||||
sound_list_iq: dict[int, tuple[str, int | None, float]] = {
|
||||
# AudibleAlertIQ, file name, play count (none for infinite)
|
||||
AudibleAlertIQ.promptSingleLow: ("prompt_single_low.wav", 1, MAX_VOLUME),
|
||||
AudibleAlertIQ.promptSingleHigh: ("prompt_single_high.wav", 1, MAX_VOLUME),
|
||||
}
|
||||
|
||||
sound_list: dict[int, tuple[str, int | None, float]] = {
|
||||
# AudibleAlert, file name, play count (none for infinite)
|
||||
AudibleAlert.engage: ("engage.wav", 1, MAX_VOLUME),
|
||||
AudibleAlert.disengage: ("disengage.wav", 1, MAX_VOLUME),
|
||||
AudibleAlert.refuse: ("refuse.wav", 1, MAX_VOLUME),
|
||||
|
||||
AudibleAlert.prompt: ("prompt.wav", 1, MAX_VOLUME),
|
||||
AudibleAlert.promptRepeat: ("prompt.wav", None, MAX_VOLUME),
|
||||
AudibleAlert.promptDistracted: ("prompt_distracted.wav", None, MAX_VOLUME),
|
||||
|
||||
AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME),
|
||||
AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME),
|
||||
|
||||
**sound_list_iq,
|
||||
}
|
||||
|
||||
def check_selfdrive_timeout_alert(sm):
|
||||
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
|
||||
|
||||
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
|
||||
if sm['selfdriveState'].enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < 10:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Soundd(AlertSoundFilter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.load_sounds()
|
||||
|
||||
self.current_alert = AudibleAlert.none
|
||||
self.current_volume = MIN_VOLUME
|
||||
self.current_sound_frame = 0
|
||||
|
||||
self.ramp_start_volume = MIN_VOLUME
|
||||
self.ramp_start_time = 0.
|
||||
|
||||
self.selfdrive_timeout_alert = False
|
||||
|
||||
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
|
||||
self.webrtc_audio: deque[np.ndarray] = deque()
|
||||
self.webrtc_audio_frames = 0
|
||||
self.webrtc_audio_lock = threading.Lock()
|
||||
self.webrtc_audio_playing = False
|
||||
|
||||
def load_sounds(self):
|
||||
self.loaded_sounds: dict[int, np.ndarray] = {}
|
||||
|
||||
# Load all sounds
|
||||
for sound in sound_list:
|
||||
filename, play_count, volume = sound_list[sound]
|
||||
|
||||
with wave.open(BASEDIR + "/iqpilot/selfdrive/assets/sounds/" + filename, 'r') as wavefile:
|
||||
assert wavefile.getnchannels() == 1
|
||||
assert wavefile.getsampwidth() == 2
|
||||
assert wavefile.getframerate() == SAMPLE_RATE
|
||||
|
||||
length = wavefile.getnframes()
|
||||
self.loaded_sounds[sound] = np.frombuffer(wavefile.readframes(length), dtype=np.int16).astype(np.float32) / (2**16/2)
|
||||
|
||||
def get_sound_data(self, frames): # get "frames" worth of data from the current alert sound, looping when required
|
||||
|
||||
ret = np.zeros(frames, dtype=np.float32)
|
||||
|
||||
if self.permits(self.current_alert):
|
||||
num_loops = sound_list[self.current_alert][1]
|
||||
sound_data = self.loaded_sounds[self.current_alert]
|
||||
written_frames = 0
|
||||
|
||||
current_sound_frame = self.current_sound_frame % len(sound_data)
|
||||
loops = self.current_sound_frame // len(sound_data)
|
||||
|
||||
while written_frames < frames and (num_loops is None or loops < num_loops):
|
||||
available_frames = sound_data.shape[0] - current_sound_frame
|
||||
frames_to_write = min(available_frames, frames - written_frames)
|
||||
ret[written_frames:written_frames+frames_to_write] = sound_data[current_sound_frame:current_sound_frame+frames_to_write]
|
||||
written_frames += frames_to_write
|
||||
self.current_sound_frame += frames_to_write
|
||||
current_sound_frame = self.current_sound_frame % len(sound_data)
|
||||
loops = self.current_sound_frame // len(sound_data)
|
||||
|
||||
return ret * self.current_volume
|
||||
|
||||
def add_webrtc_audio(self, audio_data) -> None:
|
||||
data = np.frombuffer(audio_data.data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
if data.size == 0:
|
||||
return
|
||||
|
||||
with self.webrtc_audio_lock:
|
||||
self.webrtc_audio.append(data)
|
||||
self.webrtc_audio_frames += data.size
|
||||
while self.webrtc_audio_frames > WEBRTC_AUDIO_BUFFER_FRAMES:
|
||||
self.webrtc_audio_frames -= self.webrtc_audio.popleft().size
|
||||
|
||||
def get_webrtc_audio_data(self, frames: int) -> np.ndarray:
|
||||
ret = np.zeros(frames, dtype=np.float32)
|
||||
|
||||
with self.webrtc_audio_lock:
|
||||
if not self.webrtc_audio_playing:
|
||||
if self.webrtc_audio_frames < WEBRTC_AUDIO_PREBUFFER_FRAMES:
|
||||
return ret
|
||||
self.webrtc_audio_playing = True
|
||||
|
||||
written_frames = 0
|
||||
while written_frames < frames and self.webrtc_audio:
|
||||
data = self.webrtc_audio[0]
|
||||
frames_to_write = min(data.size, frames - written_frames)
|
||||
ret[written_frames:written_frames+frames_to_write] = data[:frames_to_write]
|
||||
written_frames += frames_to_write
|
||||
self.webrtc_audio_frames -= frames_to_write
|
||||
|
||||
if frames_to_write == data.size:
|
||||
self.webrtc_audio.popleft()
|
||||
else:
|
||||
self.webrtc_audio[0] = data[frames_to_write:]
|
||||
|
||||
if written_frames < frames:
|
||||
self.webrtc_audio_playing = False
|
||||
|
||||
return ret
|
||||
|
||||
def webrtc_audio_thread(self) -> None:
|
||||
webrtc_audio_sock = messaging.sub_sock('webrtcAudioData')
|
||||
while True:
|
||||
msg = messaging.recv_one(webrtc_audio_sock)
|
||||
if msg is not None:
|
||||
self.add_webrtc_audio(msg.webrtcAudioData)
|
||||
|
||||
def callback(self, data_out: np.ndarray, frames: int, time, status) -> None:
|
||||
if status:
|
||||
cloudlog.warning(f"soundd stream over/underflow: {status}")
|
||||
audio = self.get_sound_data(frames) + self.get_webrtc_audio_data(frames)
|
||||
data_out[:frames, 0] = np.clip(audio, -1.0, 1.0)
|
||||
|
||||
def update_alert(self, new_alert):
|
||||
current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_alert])
|
||||
if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once):
|
||||
if new_alert == AudibleAlert.warningImmediate:
|
||||
self.ramp_start_volume = self.current_volume
|
||||
self.ramp_start_time = time.monotonic()
|
||||
self.current_alert = new_alert
|
||||
self.current_sound_frame = 0
|
||||
|
||||
def get_audible_alert(self, sm):
|
||||
if sm.updated['selfdriveState']:
|
||||
new_alert = sm['selfdriveState'].alertSound.raw
|
||||
self.update_alert(new_alert)
|
||||
elif check_selfdrive_timeout_alert(sm):
|
||||
self.update_alert(AudibleAlert.warningImmediate)
|
||||
self.selfdrive_timeout_alert = True
|
||||
elif self.selfdrive_timeout_alert:
|
||||
self.update_alert(AudibleAlert.none)
|
||||
self.selfdrive_timeout_alert = False
|
||||
|
||||
def calculate_volume(self, weighted_db):
|
||||
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
|
||||
return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
|
||||
|
||||
@retry(attempts=10, delay=3)
|
||||
def get_stream(self, sd):
|
||||
# reload sounddevice to reinitialize portaudio
|
||||
sd._terminate()
|
||||
sd._initialize()
|
||||
return sd.OutputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER)
|
||||
|
||||
def soundd_thread(self):
|
||||
# sounddevice must be imported after forking processes
|
||||
import sounddevice as sd
|
||||
|
||||
sm = messaging.SubMaster(['selfdriveState', 'soundPressure'])
|
||||
threading.Thread(target=self.webrtc_audio_thread, daemon=True).start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
self._stream_loop(sd, sm)
|
||||
except Exception:
|
||||
# Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here
|
||||
# crash-loops the process and selfdrived raises a takeover alert mid-drive over alert
|
||||
# sounds - stay alive and keep retrying instead; recovers if the DSP comes back.
|
||||
cloudlog.exception("soundd: audio stream unavailable, retrying")
|
||||
time.sleep(10)
|
||||
|
||||
def _stream_loop(self, sd, sm):
|
||||
with self.get_stream(sd) as stream:
|
||||
rk = Ratekeeper(20)
|
||||
|
||||
cloudlog.info(f"soundd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
|
||||
while True:
|
||||
sm.update(0)
|
||||
|
||||
self.refresh()
|
||||
|
||||
if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert
|
||||
self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb)
|
||||
self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x))
|
||||
|
||||
self.get_audible_alert(sm)
|
||||
|
||||
# Ramp up immediate warning sound over 4s
|
||||
if self.current_alert == AudibleAlert.warningImmediate:
|
||||
elapsed = time.monotonic() - self.ramp_start_time
|
||||
ramp_vol = float(np.interp(elapsed, [0, ALERT_RAMP_TIME], [self.ramp_start_volume, MAX_VOLUME]))
|
||||
self.current_volume = max(self.current_volume, ramp_vol)
|
||||
|
||||
rk.keep_time()
|
||||
|
||||
assert stream.active
|
||||
|
||||
|
||||
def main():
|
||||
s = Soundd()
|
||||
s.soundd_thread()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
iqpilot/selfdrive/ui/tests/.gitignore
vendored
Normal file
9
iqpilot/selfdrive/ui/tests/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
test
|
||||
test_translations
|
||||
test_ui/report_1
|
||||
test_ui/raylib_report
|
||||
|
||||
diff/*.mp4
|
||||
diff/*.html
|
||||
diff/.coverage
|
||||
diff/htmlcov/
|
||||
0
iqpilot/selfdrive/ui/tests/__init__.py
Normal file
0
iqpilot/selfdrive/ui/tests/__init__.py
Normal file
35
iqpilot/selfdrive/ui/tests/cycle_offroad_alerts.py
Executable file
35
iqpilot/selfdrive/ui/tests/cycle_offroad_alerts.py
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
|
||||
from iqpilot.common.basedir import BASEDIR
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from iqpilot.system.updated.updated import parse_release_notes
|
||||
|
||||
if __name__ == "__main__":
|
||||
params = Params()
|
||||
|
||||
with open(os.path.join(BASEDIR, "iqpilot/selfdrive/selfdrived/alerts_offroad.json")) as f:
|
||||
offroad_alerts = json.load(f)
|
||||
|
||||
t = 10 if len(sys.argv) < 2 else int(sys.argv[1])
|
||||
while True:
|
||||
print("setting alert update")
|
||||
params.put_bool("UpdateAvailable", True)
|
||||
params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR))
|
||||
|
||||
time.sleep(t)
|
||||
params.put_bool("UpdateAvailable", False)
|
||||
|
||||
# cycle through normal alerts
|
||||
for a in offroad_alerts:
|
||||
print("setting alert:", a)
|
||||
set_offroad_alert(a, True)
|
||||
time.sleep(t)
|
||||
set_offroad_alert(a, False)
|
||||
|
||||
print("no alert")
|
||||
time.sleep(t)
|
||||
23
iqpilot/selfdrive/ui/tests/test_feedbackd.py
Normal file
23
iqpilot/selfdrive/ui/tests/test_feedbackd.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.system.manager.process_config import managed_processes
|
||||
|
||||
|
||||
@pytest.mark.linux
|
||||
def test_feedbackd_publishes_bookmark():
|
||||
publisher = messaging.PubMaster(["bookmarkButton"])
|
||||
subscriber = messaging.SubMaster(["userBookmark"])
|
||||
process = managed_processes["feedbackd"]
|
||||
process.start()
|
||||
try:
|
||||
assert publisher.wait_for_readers_to_update("bookmarkButton", timeout=5)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not subscriber.updated["userBookmark"]:
|
||||
publisher.send("bookmarkButton", messaging.new_message("bookmarkButton"))
|
||||
subscriber.update(100)
|
||||
assert subscriber.updated["userBookmark"]
|
||||
finally:
|
||||
process.stop()
|
||||
38
iqpilot/selfdrive/ui/tests/test_local_routes.py
Normal file
38
iqpilot/selfdrive/ui/tests/test_local_routes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"")
|
||||
|
||||
|
||||
def test_list_local_routes_from_segment_directories(tmp_path):
|
||||
route_name = "00000051--3141cf1d76"
|
||||
_touch(tmp_path / f"{route_name}--0" / "qcamera.ts")
|
||||
_touch(tmp_path / f"{route_name}--1" / "qlog.zst")
|
||||
|
||||
routes = list_local_routes(tmp_path)
|
||||
|
||||
assert len(routes) == 1
|
||||
assert routes[0].name == route_name
|
||||
assert routes[0].segment_count == 2
|
||||
assert routes[0].cameras == ("road",)
|
||||
|
||||
|
||||
def test_list_local_routes_from_single_segment_directory(tmp_path):
|
||||
route_name = "00000052--3141cf1d77"
|
||||
_touch(tmp_path / f"{route_name}--0" / "qcamera.ts")
|
||||
|
||||
routes = list_local_routes(tmp_path)
|
||||
|
||||
assert len(routes) == 1
|
||||
assert routes[0].name == route_name
|
||||
assert routes[0].subtitle == "1:00 · Road Cam"
|
||||
|
||||
|
||||
def test_list_local_routes_ignores_invalid_entries(tmp_path):
|
||||
_touch(tmp_path / "not-a-route" / "fcamera.hevc")
|
||||
|
||||
assert list_local_routes(tmp_path) == []
|
||||
71
iqpilot/selfdrive/ui/tests/test_nav_helpers.py
Normal file
71
iqpilot/selfdrive/ui/tests/test_nav_helpers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position, resolve_mapbox_token
|
||||
|
||||
|
||||
def test_resolve_mapbox_token_reads_param(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
params.put("MapboxToken", "pk.test-token")
|
||||
|
||||
assert resolve_mapbox_token(params) == "pk.test-token"
|
||||
|
||||
|
||||
def test_resolve_mapbox_token_missing_returns_empty(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
|
||||
assert resolve_mapbox_token(params) == ""
|
||||
|
||||
|
||||
def test_current_or_last_gps_position_uses_last_position_param(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
params.put("LastGPSPosition", json.dumps({
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194,
|
||||
"bearing": 91.5,
|
||||
}))
|
||||
|
||||
lat, lon, bearing, valid = current_or_last_gps_position(params)
|
||||
|
||||
assert valid
|
||||
assert lat == 37.7749
|
||||
assert lon == -122.4194
|
||||
assert bearing == 91.5
|
||||
|
||||
|
||||
def test_current_or_last_gps_position_uses_iqloc_position_param(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
params.put("LastGPSPositionIQLoc", json.dumps({
|
||||
"latitude": 34.0522,
|
||||
"longitude": -118.2437,
|
||||
"bearingDeg": 12.0,
|
||||
}))
|
||||
|
||||
lat, lon, bearing, valid = current_or_last_gps_position(params)
|
||||
|
||||
assert valid
|
||||
assert lat == 34.0522
|
||||
assert lon == -118.2437
|
||||
assert bearing == 12.0
|
||||
|
||||
|
||||
def test_current_or_last_gps_position_accepts_lat_lon_aliases(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
params.put("LastGPSPosition", json.dumps({
|
||||
"lat": 40.7128,
|
||||
"lng": -74.006,
|
||||
}))
|
||||
|
||||
lat, lon, bearing, valid = current_or_last_gps_position(params)
|
||||
|
||||
assert valid
|
||||
assert lat == 40.7128
|
||||
assert lon == -74.006
|
||||
assert bearing == 0.0
|
||||
|
||||
|
||||
def test_current_or_last_gps_position_rejects_zero_position(tmp_path):
|
||||
params = Params(tmp_path.as_posix())
|
||||
params.put("LastGPSPosition", "{}")
|
||||
|
||||
assert current_or_last_gps_position(params) == (0.0, 0.0, 0.0, False)
|
||||
72
iqpilot/selfdrive/ui/tests/test_nav_map_utils.py
Normal file
72
iqpilot/selfdrive/ui/tests/test_nav_map_utils.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from iqpilot.ui.onroad.nav_map_utils import (
|
||||
build_mapbox_static_url,
|
||||
build_mapbox_tile_url,
|
||||
choose_nav_camera,
|
||||
mercator_world_px,
|
||||
mercator_world_px_at_zoom,
|
||||
project_nav_point,
|
||||
project_nav_polyline,
|
||||
tile_world_size,
|
||||
)
|
||||
|
||||
|
||||
def test_mercator_world_px_changes_with_longitude():
|
||||
x1, y1 = mercator_world_px(41.8826, -87.6393, 16.0)
|
||||
x2, y2 = mercator_world_px(41.8826, -87.6293, 16.0)
|
||||
|
||||
assert x2 > x1
|
||||
assert abs(y2 - y1) < 1.0
|
||||
|
||||
|
||||
def test_project_nav_point_centers_current_position():
|
||||
x, y = project_nav_point(41.8826, -87.6393, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
|
||||
|
||||
assert round(x, 3) == 210.0
|
||||
assert round(y, 3) == 210.0
|
||||
|
||||
|
||||
def test_project_nav_polyline_preserves_point_count():
|
||||
points = [
|
||||
SimpleNamespace(latitude=41.8826, longitude=-87.6422),
|
||||
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
|
||||
SimpleNamespace(latitude=41.8830, longitude=-87.6366),
|
||||
]
|
||||
|
||||
projected = project_nav_polyline(points, 41.8826, -87.6393, 16.0, 90.0, 420.0, 420.0)
|
||||
|
||||
assert len(projected) == len(points)
|
||||
|
||||
|
||||
def test_choose_nav_camera_looks_ahead_of_vehicle():
|
||||
points = [
|
||||
SimpleNamespace(latitude=41.8826, longitude=-87.6393),
|
||||
SimpleNamespace(latitude=41.8835, longitude=-87.6355),
|
||||
]
|
||||
|
||||
center_lat, center_lon, zoom = choose_nav_camera(41.8826, -87.6393, 90.0, points, 420.0, 420.0, 16.2)
|
||||
|
||||
assert center_lon > -87.6393
|
||||
assert 16.0 <= zoom <= 17.8
|
||||
|
||||
|
||||
def test_build_mapbox_static_url_contains_expected_components():
|
||||
url = build_mapbox_static_url(41.8826, -87.6393, 16.2, 90.0, 420, 420)
|
||||
|
||||
assert "navigation-night-v1/static/" in url
|
||||
assert "-87.639300,41.882600,16.20,90.0,0/420x420@2x" in url
|
||||
|
||||
|
||||
def test_build_mapbox_tile_url_contains_expected_components():
|
||||
url = build_mapbox_tile_url(16, 10619, 24322)
|
||||
|
||||
assert "navigation-night-v1/tiles/256/16/10619/24322@2x" in url
|
||||
|
||||
|
||||
def test_world_size_and_world_px_align_at_integer_zoom():
|
||||
world_size = tile_world_size(16)
|
||||
x, y = mercator_world_px_at_zoom(41.8826, -87.6393, 16)
|
||||
|
||||
assert 0.0 <= x <= world_size
|
||||
assert 0.0 <= y <= world_size
|
||||
95
iqpilot/selfdrive/ui/tests/test_nav_search.py
Normal file
95
iqpilot/selfdrive/ui/tests/test_nav_search.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from iqpilot.selfdrive.ui.lib import nav_search
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def get(self, key, *args, **kwargs):
|
||||
return {
|
||||
"AmapWebServiceKey": "amap-key",
|
||||
"OsmLocationName": "CN",
|
||||
"MapboxToken": "mapbox-key",
|
||||
}.get(key)
|
||||
|
||||
|
||||
def test_china_search_uses_amap_without_calling_mapbox(monkeypatch):
|
||||
search = nav_search.NavSearch.__new__(nav_search.NavSearch)
|
||||
search._params = FakeParams()
|
||||
search._seq = 1
|
||||
search._results = []
|
||||
search._searching = True
|
||||
search._amap_adcode = ""
|
||||
import threading
|
||||
search._lock = threading.Lock()
|
||||
|
||||
amap_client = SimpleNamespace(
|
||||
is_mainland_china_configured=lambda *args, **kwargs: True,
|
||||
get_key=lambda *args, **kwargs: "amap-key",
|
||||
status=lambda: "ok",
|
||||
reverse_adcode=lambda *args, **kwargs: "",
|
||||
autocomplete=lambda *args, **kwargs: [],
|
||||
)
|
||||
monkeypatch.setattr(nav_search, "_amap_client", amap_client)
|
||||
monkeypatch.setattr(nav_search, "current_or_last_gps_position", lambda *_: (39.9, 116.4, 0.0, True))
|
||||
monkeypatch.setattr(amap_client, "reverse_adcode", lambda *args, **kwargs: "110000")
|
||||
monkeypatch.setattr(amap_client, "autocomplete", lambda *args, **kwargs: [
|
||||
SimpleNamespace(
|
||||
name="Tiananmen",
|
||||
address="Dongcheng, Beijing",
|
||||
provider_id="B000A83M61",
|
||||
latitude=39.9087,
|
||||
longitude=116.3975,
|
||||
),
|
||||
])
|
||||
monkeypatch.setattr(nav_search.requests, "get", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("Mapbox called")))
|
||||
|
||||
search._do_search("Tiananmen", 1)
|
||||
|
||||
assert len(search._results) == 1
|
||||
assert search._results[0].provider == "amap"
|
||||
assert search._results[0].has_coords
|
||||
|
||||
|
||||
def test_non_china_search_preserves_mapbox_request(monkeypatch):
|
||||
search = nav_search.NavSearch.__new__(nav_search.NavSearch)
|
||||
search._params = FakeParams()
|
||||
search._session = "session"
|
||||
search._seq = 1
|
||||
search._results = []
|
||||
search._searching = True
|
||||
search._amap_adcode = ""
|
||||
import threading
|
||||
search._lock = threading.Lock()
|
||||
captured = {}
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"suggestions": []}
|
||||
|
||||
def get(url, *, params, timeout):
|
||||
captured.update(url=url, params=params, timeout=timeout)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(nav_search, "current_or_last_gps_position", lambda *_: (41.3, -90.2, 0.0, True))
|
||||
monkeypatch.setattr(nav_search, "resolve_mapbox_token", lambda *_: "mapbox-key")
|
||||
monkeypatch.setattr(nav_search, "_amap_client", None)
|
||||
monkeypatch.setattr(nav_search.requests, "get", get)
|
||||
|
||||
search._do_search("Home", 1)
|
||||
|
||||
assert captured["url"] == f"{nav_search.SEARCHBOX}/suggest"
|
||||
assert captured["params"] == {
|
||||
"q": "Home",
|
||||
"access_token": "mapbox-key",
|
||||
"session_token": "session",
|
||||
"limit": nav_search.MAX_RESULTS,
|
||||
"language": "en",
|
||||
"proximity": "-90.2,41.3",
|
||||
}
|
||||
assert captured["timeout"] == 8
|
||||
39
iqpilot/selfdrive/ui/tests/test_offline_raster_pipeline.py
Normal file
39
iqpilot/selfdrive/ui/tests/test_offline_raster_pipeline.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sqlite3
|
||||
|
||||
from scripts.iqpilot.package_xyz_tiles_to_mbtiles import build_mbtiles
|
||||
from scripts.iqpilot.render_raster_tiles_from_vector_mbtiles import tile_range_for_bounds
|
||||
|
||||
|
||||
PNG_1X1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
|
||||
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def test_tile_range_for_bounds_returns_non_empty_ranges():
|
||||
x_range, y_range = tile_range_for_bounds((-88.15, 41.65, -88.02, 41.76), 14)
|
||||
assert len(list(x_range)) > 0
|
||||
assert len(list(y_range)) > 0
|
||||
|
||||
|
||||
def test_build_mbtiles_from_xyz_tiles(tmp_path):
|
||||
source = tmp_path / "xyz"
|
||||
tile_dir = source / "14" / "2625"
|
||||
tile_dir.mkdir(parents=True)
|
||||
(tile_dir / "6335@2x.png").write_bytes(PNG_1X1)
|
||||
|
||||
output = tmp_path / "offline.mbtiles"
|
||||
build_mbtiles(source, output, bounds="-88.15,41.65,-88.02,41.76")
|
||||
|
||||
conn = sqlite3.connect(output)
|
||||
try:
|
||||
fmt = conn.execute("SELECT value FROM metadata WHERE name='format'").fetchone()[0]
|
||||
bounds = conn.execute("SELECT value FROM metadata WHERE name='bounds'").fetchone()[0]
|
||||
count = conn.execute("SELECT COUNT(*) FROM tiles").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert fmt == "png"
|
||||
assert bounds == "-88.15,41.65,-88.02,41.76"
|
||||
assert count == 1
|
||||
128
iqpilot/selfdrive/ui/tests/test_offline_tiles.py
Normal file
128
iqpilot/selfdrive/ui/tests/test_offline_tiles.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
from iqpilot.ui.onroad import offline_tiles
|
||||
|
||||
|
||||
PNG_1X1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
|
||||
b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def _write_mbtiles(path):
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE metadata (name text, value text)")
|
||||
conn.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
|
||||
conn.execute("INSERT INTO metadata (name, value) VALUES ('format', 'png')")
|
||||
conn.execute("INSERT INTO metadata (name, value) VALUES ('minzoom', '1')")
|
||||
conn.execute("INSERT INTO metadata (name, value) VALUES ('maxzoom', '3')")
|
||||
conn.execute(
|
||||
"INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)",
|
||||
(1, 1, offline_tiles.xyz_to_tms_y(1, 0), PNG_1X1),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_xyz_to_tms_y():
|
||||
assert offline_tiles.xyz_to_tms_y(1, 0) == 1
|
||||
assert offline_tiles.xyz_to_tms_y(1, 1) == 0
|
||||
|
||||
|
||||
def test_find_offline_mbtiles_path_from_env(tmp_path, monkeypatch):
|
||||
mbtiles = tmp_path / "demo.mbtiles"
|
||||
_write_mbtiles(mbtiles)
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_MBTILES_ENV, str(mbtiles))
|
||||
assert offline_tiles.find_offline_mbtiles_path() == mbtiles
|
||||
|
||||
|
||||
def test_find_offline_xyz_root(tmp_path, monkeypatch):
|
||||
root = tmp_path / "tiles"
|
||||
(root / "15" / "10500").mkdir(parents=True)
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
|
||||
assert offline_tiles.find_offline_xyz_root() == root
|
||||
|
||||
|
||||
def test_load_raster_tile_blob_from_mbtiles(tmp_path):
|
||||
mbtiles = tmp_path / "offline.mbtiles"
|
||||
_write_mbtiles(mbtiles)
|
||||
conn = offline_tiles.open_mbtiles(mbtiles)
|
||||
try:
|
||||
assert offline_tiles.mbtiles_is_raster(conn) is True
|
||||
assert offline_tiles.mbtiles_zoom_bounds(conn) == (1, 3)
|
||||
assert offline_tiles.load_raster_tile_blob(conn, 1, 1, 0) == PNG_1X1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_load_raster_tile_blob_from_xyz_dir(tmp_path, monkeypatch):
|
||||
root = tmp_path / "offline_tiles"
|
||||
tile_path = root / "14" / "2625"
|
||||
tile_path.mkdir(parents=True)
|
||||
(tile_path / "6335@2x.png").write_bytes(PNG_1X1)
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root))
|
||||
assert offline_tiles.xyz_zoom_bounds(root) == (14, 14)
|
||||
assert offline_tiles.load_raster_xyz_tile_blob(root, 14, 2625, 6335) == PNG_1X1
|
||||
|
||||
|
||||
def test_find_offline_region_root_by_bounds(tmp_path, monkeypatch):
|
||||
root = tmp_path / "offline_maps"
|
||||
il = root / "regions" / "illinois"
|
||||
ca = root / "regions" / "california"
|
||||
(il / "tiles").mkdir(parents=True)
|
||||
(ca / "tiles").mkdir(parents=True)
|
||||
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
|
||||
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
|
||||
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
|
||||
assert offline_tiles.find_offline_region_root(41.88, -87.63) == il
|
||||
assert offline_tiles.find_offline_region_root(34.05, -118.24) == ca
|
||||
|
||||
|
||||
def test_find_offline_mbtiles_path_uses_selected_region(tmp_path, monkeypatch):
|
||||
root = tmp_path / "offline_maps"
|
||||
il = root / "regions" / "illinois"
|
||||
ca = root / "regions" / "california"
|
||||
(il / "tiles").mkdir(parents=True)
|
||||
(ca / "tiles").mkdir(parents=True)
|
||||
(il / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-91.6,36.9,-87.4,42.6"}}))
|
||||
(ca / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-124.5,32.4,-114.1,42.1"}}))
|
||||
_write_mbtiles(il / "tiles" / "offline.mbtiles")
|
||||
_write_mbtiles(ca / "tiles" / "offline.mbtiles")
|
||||
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
|
||||
assert offline_tiles.find_offline_mbtiles_path(41.88, -87.63) == il / "tiles" / "offline.mbtiles"
|
||||
assert offline_tiles.find_offline_mbtiles_path(34.05, -118.24) == ca / "tiles" / "offline.mbtiles"
|
||||
|
||||
|
||||
def test_day_variant_selection(tmp_path, monkeypatch):
|
||||
root = tmp_path / "offline_maps"
|
||||
region = root / "regions" / "us_state.IL"
|
||||
(region / "tiles").mkdir(parents=True)
|
||||
(region / "manifest.json").write_text(json.dumps({"mbtiles": {"bounds": "-88.3,41.5,-87.8,41.9"}}))
|
||||
_write_mbtiles(region / "tiles" / "offline.mbtiles")
|
||||
monkeypatch.setenv(offline_tiles.OFFLINE_TILE_ROOT_ENV, str(root / "tiles"))
|
||||
offline_tiles._region_roots_cache = None
|
||||
offline_tiles._region_bounds_cache.clear()
|
||||
|
||||
# no day variant yet: day request falls back to the night set
|
||||
night = region / "tiles" / "offline.mbtiles"
|
||||
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == night
|
||||
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
|
||||
|
||||
# day variant installed: day requests prefer it, night unchanged
|
||||
_write_mbtiles(region / "tiles" / "offline_day.mbtiles")
|
||||
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=True) == region / "tiles" / "offline_day.mbtiles"
|
||||
assert offline_tiles.find_offline_mbtiles_path(41.7, -88.0, day=False) == night
|
||||
|
||||
|
||||
def test_solar_elevation_day_night():
|
||||
from iqpilot.ui.onroad.nav_map_utils import solar_elevation_deg
|
||||
# Chicago 2026-07-11: 18:00 UTC (1pm CDT) is day; 06:00 UTC (1am CDT) is night
|
||||
noon_utc = 1783792800.0 # 2026-07-11 18:00:00 UTC
|
||||
night_utc = noon_utc - 12 * 3600
|
||||
assert solar_elevation_deg(41.88, -87.63, noon_utc) > 30.0
|
||||
assert solar_elevation_deg(41.88, -87.63, night_utc) < -10.0
|
||||
27
iqpilot/selfdrive/ui/tests/test_raylib_ui.py
Normal file
27
iqpilot/selfdrive/ui/tests/test_raylib_ui.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import time
|
||||
import pytest
|
||||
from iqpilot.selfdrive.test.helpers import with_processes
|
||||
from iqpilot.system.ui.lib import application
|
||||
from iqpilot.system.ui.lib.utils import gui_style_color
|
||||
|
||||
|
||||
@pytest.mark.linux
|
||||
@with_processes(["ui"])
|
||||
def test_raylib_ui():
|
||||
"""Test initialization of the UI widgets is successful."""
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_style_colors_match_gui_style_abi(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(application.rl, "gui_set_style", lambda *args: calls.append(args))
|
||||
monkeypatch.setattr(application, "gui_style_color", lambda color: 27)
|
||||
application.GuiApplication._set_styles(None)
|
||||
assert [call[2] for call in calls[-3:]] == [27, 27, 27]
|
||||
|
||||
|
||||
def test_gui_style_color_uses_binding_value_type():
|
||||
color = application.rl.Color(229, 229, 229, 255)
|
||||
value_type = application.rl.ffi.typeof(application.rl.raylib.GuiSetStyle).args[2]
|
||||
expected = int(application.rl.ffi.cast(value_type, application.rl.color_to_int(color)))
|
||||
assert gui_style_color(color) == expected
|
||||
@@ -0,0 +1,56 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.selfdrive.ui.layouts.main import MainLayout, MainState
|
||||
|
||||
|
||||
def make_layout(current_mode):
|
||||
layout = object.__new__(MainLayout)
|
||||
layout._current_mode = current_mode
|
||||
layout._set_mode_calls = []
|
||||
layout._set_mode_for_state = lambda: layout._set_mode_calls.append(current_mode)
|
||||
return layout
|
||||
|
||||
|
||||
class FakeSm:
|
||||
def __init__(self, v_ego, carstate_valid):
|
||||
self.valid = {"carState": carstate_valid}
|
||||
self._v_ego = v_ego
|
||||
|
||||
def __getitem__(self, key):
|
||||
return SimpleNamespace(vEgo=self._v_ego)
|
||||
|
||||
|
||||
class TestSettingsInteractiveTimeout:
|
||||
def _run(self, current_mode, started, v_ego, carstate_valid=True):
|
||||
layout = make_layout(current_mode)
|
||||
fake = SimpleNamespace(started=started, sm=FakeSm(v_ego, carstate_valid))
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr("iqpilot.selfdrive.ui.layouts.main.ui_state", fake)
|
||||
try:
|
||||
layout._on_interactive_timeout()
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
return layout._set_mode_calls
|
||||
|
||||
def test_stationary_in_settings_stays(self):
|
||||
# parked/charging hybrid reads onroad; the timeout must not eject from Settings
|
||||
assert self._run(MainState.SETTINGS, started=True, v_ego=0.0) == []
|
||||
|
||||
def test_moving_in_settings_returns_to_road(self):
|
||||
assert self._run(MainState.SETTINGS, started=True, v_ego=5.0) == [MainState.SETTINGS]
|
||||
|
||||
def test_onroad_layout_always_handled(self):
|
||||
assert self._run(MainState.ONROAD, started=True, v_ego=0.0) == [MainState.ONROAD]
|
||||
|
||||
def test_home_layout_always_handled(self):
|
||||
assert self._run(MainState.HOME, started=True, v_ego=0.0) == [MainState.HOME]
|
||||
|
||||
def test_offroad_in_settings_handled(self):
|
||||
# car off (offroad): existing behavior is unchanged
|
||||
assert self._run(MainState.SETTINGS, started=False, v_ego=0.0) == [MainState.SETTINGS]
|
||||
|
||||
def test_invalid_carstate_treated_as_moving(self):
|
||||
# if speed is unknown, fail safe to the road view rather than trapping in settings
|
||||
assert self._run(MainState.SETTINGS, started=True, v_ego=0.0, carstate_valid=False) == [MainState.SETTINGS]
|
||||
34
iqpilot/selfdrive/ui/tests/test_soundd.py
Normal file
34
iqpilot/selfdrive/ui/tests/test_soundd.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from iqpilot.cereal import car
|
||||
from iqpilot.selfdrive.ui import soundd
|
||||
|
||||
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
|
||||
|
||||
|
||||
class TestSoundd:
|
||||
def test_check_selfdrive_timeout_alert(self, monkeypatch):
|
||||
class FakeSubMaster:
|
||||
recv_time = {'selfdriveState': 100.0}
|
||||
|
||||
def __init__(self, enabled):
|
||||
self.state = SimpleNamespace(enabled=enabled)
|
||||
|
||||
def __getitem__(self, service):
|
||||
assert service == 'selfdriveState'
|
||||
return self.state
|
||||
|
||||
enabled = FakeSubMaster(True)
|
||||
disabled = FakeSubMaster(False)
|
||||
|
||||
monkeypatch.setattr(soundd.time, "monotonic", lambda: 100.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
|
||||
assert not soundd.check_selfdrive_timeout_alert(enabled)
|
||||
|
||||
monkeypatch.setattr(soundd.time, "monotonic", lambda: 101.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
|
||||
assert soundd.check_selfdrive_timeout_alert(enabled)
|
||||
assert not soundd.check_selfdrive_timeout_alert(disabled)
|
||||
|
||||
monkeypatch.setattr(soundd.time, "monotonic", lambda: 110.0 + soundd.SELFDRIVE_STATE_TIMEOUT)
|
||||
assert not soundd.check_selfdrive_timeout_alert(enabled)
|
||||
|
||||
# TODO: add test with micd for checking that soundd actually outputs sounds
|
||||
48
iqpilot/selfdrive/ui/tests/test_translations.py
Normal file
48
iqpilot/selfdrive/ui/tests/test_translations.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import gettext
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from iqpilot.system.ui.lib.multilang import LANGUAGES_FILE, TRANSLATIONS_DIR
|
||||
|
||||
|
||||
FORMAT_ARG = re.compile(r"%(?:\([^)]+\))?[#0+\-]?(?:\d+|\*)?(?:\.\d+|\.\*)?[hlL]?[diouxXeEfFgGcrsa%]")
|
||||
|
||||
|
||||
with LANGUAGES_FILE.open(encoding="utf-8") as stream:
|
||||
LANGUAGES = json.load(stream)
|
||||
|
||||
|
||||
def load_catalog(language_code: str) -> dict[str | tuple[str, int], str]:
|
||||
with TRANSLATIONS_DIR.joinpath(f"app_{language_code}.mo").open("rb") as stream:
|
||||
return gettext.GNUTranslations(stream)._catalog
|
||||
|
||||
|
||||
def message_keys(catalog: dict[str | tuple[str, int], str]) -> set[str]:
|
||||
return {key for key in catalog if isinstance(key, str) and key}
|
||||
|
||||
|
||||
def format_args(text: str) -> list[str]:
|
||||
return sorted(match for match in FORMAT_ARG.findall(text) if match != "%%")
|
||||
|
||||
|
||||
def test_language_codes_are_unique():
|
||||
assert len(LANGUAGES) == len(set(LANGUAGES.values()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language_code", LANGUAGES.values(), ids=LANGUAGES.keys())
|
||||
def test_translation_catalog_is_complete(language_code):
|
||||
source = load_catalog("en")
|
||||
translated = load_catalog(language_code)
|
||||
assert message_keys(translated) == message_keys(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language_code", LANGUAGES.values(), ids=LANGUAGES.keys())
|
||||
def test_translation_catalog_entries(language_code):
|
||||
catalog = load_catalog(language_code)
|
||||
for source, translated in catalog.items():
|
||||
if not isinstance(source, str) or not source:
|
||||
continue
|
||||
assert translated
|
||||
assert format_args(translated) == format_args(source)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user