IQ.Pilot Release Commit @ 0babf78

This commit is contained in:
IQ.Lvbs CI [bot]
2026-07-27 01:40:11 -05:00
parent 6fb5c0141c
commit b39791a93f
425 changed files with 12180 additions and 6137 deletions

34
system/flockd.service Normal file
View File

@@ -0,0 +1,34 @@
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
[Unit]
Description=Konn3kt Flock/ALPR RF Detector
Documentation=https://gitlvb.teallvbs.xyz/teal/iqpilot
After=bluetooth.service dbus.service NetworkManager.service
Wants=bluetooth.service dbus.service
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
User=comma
AmbientCapabilities=CAP_SYS_NICE
Environment="IQPILOT_SOURCE_ROOT=/data/openpilot/openpilot"
Environment="PYTHONPATH=/usr/libexec/iqpilot/python:/data/openpilot"
Environment="PYTHONSAFEPATH=1"
Environment="PATH=/usr/local/venv/bin:/usr/sbin:/usr/bin:/sbin:/bin"
WorkingDirectory=/data/openpilot
ExecStartPre=/bin/bash -c 'for i in $(seq 1 120); do if [ -x /usr/libexec/iqpilot/iqpilot_bundle_runner ]; then exit 0; fi; echo "Waiting for iqpilot_bundle_runner..."; sleep 5; done; exit 1'
ExecStartPre=/bin/bash -c 'if [ -f /data/openpilot/artifacts/runtime/ensure_private_installed.sh ]; then bash /data/openpilot/artifacts/runtime/ensure_private_installed.sh || true; fi'
ExecStart=/bin/bash -lc 'exec /usr/libexec/iqpilot/iqpilot_bundle_runner --bundle iqpilot_hephaestusd_private --mode python-module --entry iqpilot_private.konn3kt.flockd.flockd --daemon-name flockd'
TimeoutStartSec=600
Restart=always
RestartSec=15
StandardOutput=journal
StandardError=journal
PrivateTmp=yes
NoNewPrivileges=false
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

View File

@@ -200,6 +200,9 @@ class HardwareBase(ABC):
def reboot_modem(self):
pass
def recover_sim_detection(self) -> bool:
return False
def get_networks(self):
return None

View File

@@ -204,6 +204,7 @@ def hw_state_thread(end_event, hw_queue):
modem_configured = False
modem_missing_count = 0
modem_restart_count = 0
sim_detection_recovered = False
while not end_event.is_set():
# these are expensive calls. update every 10s
@@ -254,6 +255,10 @@ def hw_state_thread(end_event, hw_queue):
HARDWARE.configure_modem()
modem_configured = True
if modem_configured and not sim_detection_recovered and HARDWARE.recover_sim_detection():
cloudlog.event("sim missing with hot-swap detect armed, rebooting modem with detect disabled", error=True)
sim_detection_recovered = True
prev_hw_state = hw_state
except Exception:
cloudlog.exception("Error getting hardware state")
@@ -462,7 +467,7 @@ def hardware_thread(end_event, hw_queue) -> None:
startup_conditions["device_booted"] = startup_conditions.get("device_booted", False) or HARDWARE.booted()
# user-forced status (Always Offroad can be temporarily overridden)
offroad_mode = params.get_bool("OffroadMode")
offroad_mode = params.get_bool("IQAlwaysOffroad")
force_onroad_until = params.get("ForceOnroadUntil", return_default=True)
now = int(time.time())
force_onroad_active = offroad_mode and force_onroad_until > now
@@ -549,15 +554,17 @@ def hardware_thread(end_event, hw_queue) -> None:
som_power_draw = HARDWARE.get_som_power_draw()
msg.deviceState.somPowerDrawW = som_power_draw
# FastSleep deep standby: shed heavy processes at low battery instead of shutting down,
# recover on ignition or once the alternator is charging
# FastSleep deep standby: shed heavy processes once parked with the screen idled off
# (or at low battery) instead of shutting down, recover on ignition or once the
# alternator is charging
fast_sleep = params.get_bool("FastSleep")
if fast_sleep and not tesla_no_sleep:
if low_power:
if onroad_conditions["ignition"] or power_monitor.car_voltage_mV >= (VBATT_LOW_POWER_EXIT * 1e3):
low_power = False
else:
low_power = power_monitor.should_enter_low_power(onroad_conditions["ignition"], in_car, off_ts)
screen_off = msg.deviceState.screenBrightnessPercent < 1e-3
low_power = power_monitor.should_enter_low_power(onroad_conditions["ignition"], in_car, off_ts, screen_off)
else:
low_power = False

View File

@@ -13,11 +13,13 @@ CAR_CHARGING_RATE_W = 45
VBATT_PAUSE_CHARGING = 11.8 # Lower limit on the LPF car battery voltage
# FastSleep (deep standby): enter low power at the normal shutdown voltage, shut down
# at a lower floor, exit once the alternator is charging
# FastSleep (deep standby): enter low power once parked with the screen idled off, or
# immediately at the normal shutdown voltage; shut down at a lower floor, exit once the
# alternator is charging
VBATT_LOW_POWER_ENTRY = 11.8
VBATT_LOW_POWER_EXIT = 12.8
VBATT_HARD_SHUTDOWN = 11.5
LOW_POWER_ENTRY_TIME_S = 300
MAX_TIME_OFFROAD_S = 30*3600
MIN_ON_TIME_S = 3600
DELAY_SHUTDOWN_TIME_S = 300 # Wait at least DELAY_SHUTDOWN_TIME_S seconds after offroad_time to shutdown.
@@ -124,14 +126,18 @@ class PowerMonitoring:
return 0 < iq_max_time_val_s <= offroad_time
# FastSleep: see if we should enter low power mode instead of shutting down
def should_enter_low_power(self, ignition: bool, in_car: bool, offroad_timestamp: float | None) -> bool:
def should_enter_low_power(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, screen_off: bool) -> bool:
if offroad_timestamp is None or ignition or not in_car:
return False
if not self.params.get_bool("FastSleep"):
return False
offroad_time = time.monotonic() - offroad_timestamp
return (self.car_voltage_mV < (VBATT_LOW_POWER_ENTRY * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
# a healthy battery rests above VBATT_LOW_POWER_ENTRY, so parked entry must be
# time-based; the voltage trigger stays as the sagging-battery fast path
low_voltage = (self.car_voltage_mV < (VBATT_LOW_POWER_ENTRY * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
parked_idle = screen_off and offroad_time > LOW_POWER_ENTRY_TIME_S
return low_voltage or parked_idle
# See if we need to shutdown
def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool):
@@ -141,12 +147,15 @@ class PowerMonitoring:
now = time.monotonic()
should_shutdown = False
offroad_time = (now - offroad_timestamp)
vbatt_min = VBATT_HARD_SHUTDOWN if self.params.get_bool("FastSleep") else VBATT_PAUSE_CHARGING
fast_sleep = self.params.get_bool("FastSleep")
vbatt_min = VBATT_HARD_SHUTDOWN if fast_sleep else VBATT_PAUSE_CHARGING
low_voltage_shutdown = (self.car_voltage_mV < (vbatt_min * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
should_shutdown |= self.max_time_offroad_exceeded(offroad_time)
should_shutdown |= low_voltage_shutdown
should_shutdown |= (self.car_battery_capacity_uWh <= 0)
# the 30 Wh bookkeeping model empties within hours at offroad draw regardless of the
# real battery state; under FastSleep the measured voltage floors govern instead
should_shutdown |= (self.car_battery_capacity_uWh <= 0) and not fast_sleep
should_shutdown &= not ignition
should_shutdown &= (not self.params.get_bool("DisablePowerDown"))
should_shutdown &= in_car

View File

@@ -2,7 +2,8 @@ import pytest
from openpilot.common.params import Params
from openpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \
CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S
CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S, \
VBATT_HARD_SHUTDOWN, LOW_POWER_ENTRY_TIME_S
# Create fake time
ssb = 0.
@@ -232,3 +233,59 @@ class TestPowerMonitoring:
result = pm.max_time_offroad_exceeded(offroad_time_s)
assert result == expected_result
# FastSleep must not shut down on the empty bookkeeping model while voltage is healthy
def test_fast_sleep_ignores_battery_capacity_model(self, mocker):
self.params.put_bool("FastSleep", True)
self.params.put("MaxTimeOffroad", 0)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 0
start_time = ssb
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
pm.calculate(GOOD_VOLTAGE, False)
assert not pm.should_shutdown(False, True, start_time, True)
finally:
self.params.put_bool("FastSleep", False)
# FastSleep still shuts down below the hard voltage floor
def test_fast_sleep_hard_voltage_floor(self, mocker):
self.params.put_bool("FastSleep", True)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
start_time = ssb
for _ in range(DELAY_SHUTDOWN_TIME_S + 100):
pm.calculate((VBATT_HARD_SHUTDOWN - 0.5) * 1e3, False)
assert pm.should_shutdown(False, True, start_time, True)
finally:
self.params.put_bool("FastSleep", False)
def test_fast_sleep_low_power_entry(self, mocker):
self.params.put_bool("FastSleep", True)
try:
pm_patch(mocker, "HARDWARE.get_current_power_draw", 0)
# parked with the screen idled off: time-based entry at healthy voltage
pm = PowerMonitoring()
start_time = ssb
for _ in range(LOW_POWER_ENTRY_TIME_S + 10):
pm.calculate(GOOD_VOLTAGE, False)
assert pm.should_enter_low_power(False, True, start_time, screen_off=True)
assert not pm.should_enter_low_power(False, True, start_time, screen_off=False)
assert not pm.should_enter_low_power(True, True, start_time, screen_off=True)
assert not pm.should_enter_low_power(False, False, start_time, screen_off=True)
# sagging battery: voltage entry regardless of screen state
pm = PowerMonitoring()
start_time = ssb
for _ in range(100):
pm.calculate((VBATT_HARD_SHUTDOWN + 0.1) * 1e3, False)
assert pm.should_enter_low_power(False, True, start_time, screen_off=False)
self.params.put_bool("FastSleep", False)
assert not pm.should_enter_low_power(False, True, start_time, screen_off=True)
finally:
self.params.put_bool("FastSleep", False)

View File

@@ -78,17 +78,17 @@
},
{
"name": "system",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79.img.xz",
"hash": "a0bf6d22e1134fc6c47158d63901c06324ea3b3808ed2fec35801a888cf3d526",
"hash_raw": "036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img.xz",
"hash": "0c9e7dee6c7365600c77b33c4996456459d04aad005e3e41bf6ee2e8af74ceb9",
"hash_raw": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a",
"size": 6291456000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "9a08b97618dceceed48205ec674330bb97c7f76487a6cca2f68288aaa42aaf8e",
"ondevice_hash": "1ee589d3d728a03561718383bf640171f1aef048573e46fe17239617c7c19fff",
"alt": {
"hash": "036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79.img",
"hash": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img",
"size": 6291456000
}
}

View File

@@ -1 +1 @@
Xmaq5VKDz8zpg4uJI4cTwgkgKp+HzFlL+5pzHfWvJXqVSNnlOZ/H5dNzm2MScLgzjnxcRYxferbIXjTyZKrzAw==
+LyP/p4uBBxbTwRwPrwR/2qPaHkAXmlFkg0GdxOBuujPY89GxP4t5SNKxirqiw9LmMcfA0JfVwMeR3QD9rQ6Dg==

View File

@@ -67,17 +67,17 @@
},
{
"name": "system",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79.img.xz",
"hash": "a0bf6d22e1134fc6c47158d63901c06324ea3b3808ed2fec35801a888cf3d526",
"hash_raw": "036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img.xz",
"hash": "0c9e7dee6c7365600c77b33c4996456459d04aad005e3e41bf6ee2e8af74ceb9",
"hash_raw": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a",
"size": 6291456000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "9a08b97618dceceed48205ec674330bb97c7f76487a6cca2f68288aaa42aaf8e",
"ondevice_hash": "1ee589d3d728a03561718383bf640171f1aef048573e46fe17239617c7c19fff",
"alt": {
"hash": "036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-036bb0bcf945115c6488efadcdb56a42192201290a5fe4572ac7b46549146c79.img",
"hash": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a",
"url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img",
"size": 6291456000
}
}

View File

@@ -1 +1 @@
IeObYseNfOAYVt6hNKkFMX+RmBAQBU9iiwbox8y6sUdZZs7ZdrUqWJ1ou6yxxsx2JCkgOCQk8z9yT5frz5CeDg==
RCYk34Ifjx7S9PIhPm6p49vFMhamPwvw8Lfbb6a6HO8sUOi3J2PcY4VDUcqQZWU6+Am++HP3nJSea8OmAsckAg==

View File

@@ -69,6 +69,9 @@ NetworkStrength = log.DeviceState.NetworkStrength
MM_MODEM_ACCESS_TECHNOLOGY_UMTS = 1 << 5
MM_MODEM_ACCESS_TECHNOLOGY_LTE = 1 << 14
# MMModemStateFailedReason
MM_MODEM_STATE_FAILED_REASON_SIM_MISSING = 2
def affine_irq(val, action):
irqs = get_irqs_for_action(action)
@@ -605,6 +608,32 @@ class Tici(HardwareBase):
os.system(f"sudo cp {tf.name} {dest}")
os.system(f"sudo nmcli con load {dest}")
def recover_sim_detection(self) -> bool:
# A worn SIM-tray presence switch can read "removed" while the SIM pads still make
# contact; with hot-swap detect armed (AT+QSIMDET=1) the modem never powers the SIM
# and lands in failed/sim-missing. Disabling detect and rebooting the modem makes it
# probe the SIM electrically. Safe to retry on failure: firing disarms the QSIMDET
# gate, so a genuinely SIM-less device gets at most one extra modem reboot per boot.
if self.get_device_type() not in ("tici", "tizi"):
return False
try:
modem = self.get_modem()
state = modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if state != MM_MODEM_STATE.FAILED:
return False
reason = modem.Get(MM_MODEM, 'StateFailedReason', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)
if reason != MM_MODEM_STATE_FAILED_REASON_SIM_MISSING:
return False
detect = str(modem.Command('AT+QSIMDET?', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)).strip()
if not detect.startswith('+QSIMDET: 1'):
return False
modem.Command('AT+QSIMDET=0,0', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
modem.Command('AT+CFUN=1,1', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
return True
except Exception:
return False
def reboot_modem(self):
modem = None
try:

View File

@@ -0,0 +1,42 @@
#!/usr/bin/bash
set -e
SERVICE_FILE="/data/openpilot/system/flockd.service"
SERVICE_NAME="flockd.service"
SERVICE_OVERRIDE="/etc/systemd/system/${SERVICE_NAME}"
SERVICE_BAKED="/lib/systemd/system/${SERVICE_NAME}"
echo "Installing Flock RF detector systemd service..."
if [ -f "$SERVICE_BAKED" ] && grep -q "/usr/libexec/iqpilot/iqpilot_bundle_runner" "$SERVICE_BAKED"; then
echo "Using IQ.OS baked ${SERVICE_NAME}; removing stale override if present..."
sudo mount -o remount,rw /
sudo rm -f "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
sudo mount -o remount,ro /
else
if [ ! -f "$SERVICE_FILE" ]; then
echo "ERROR: Service file not found at $SERVICE_FILE"
exit 1
fi
echo "IQ.OS baked unit unavailable; installing fallback override into /etc/systemd/system..."
sudo cp "$SERVICE_FILE" "$SERVICE_OVERRIDE"
sudo systemctl daemon-reload
fi
echo "Enabling $SERVICE_NAME to start at boot..."
sudo systemctl enable "$SERVICE_NAME"
echo "Starting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"
echo ""
echo "Service status:"
sudo systemctl status "$SERVICE_NAME" --no-pager
echo ""
echo "Useful commands:"
echo " sudo systemctl status flockd - Check service status"
echo " sudo systemctl restart flockd - Restart service"
echo " sudo journalctl -u flockd -f - View live logs"

View File

@@ -28,6 +28,13 @@ const int SEGMENT_LENGTH = LOGGERD_TEST ? atoi(getenv("LOGGERD_SEGMENT_LENGTH"))
constexpr char PRESERVE_ATTR_NAME[] = "user.preserve";
constexpr char PRESERVE_ATTR_VALUE = '1';
// 2.5x the stock 526x330 qcamera, rounded up to even. The msm_vidc encoder rejects
// VIDIOC_S_FMT with ENOTSUPP (524) on an odd width or height, which throws out of
// encoder_thread and SIGABRTs all of encoderd -- taking fcamera/dcamera/ecamera with it.
constexpr int QCAM_WIDTH = 1316;
constexpr int QCAM_HEIGHT = 826;
static_assert(QCAM_WIDTH % 2 == 0 && QCAM_HEIGHT % 2 == 0, "qcamera dimensions must be even");
struct EncoderSettings {
cereal::EncodeIndex::Type encode_type;
int bitrate;
@@ -43,10 +50,10 @@ struct EncoderSettings {
}
static EncoderSettings QcamEncoderSettings() {
// qcamera.ts is the small "dashcam" copy uploaded to konn3kt. Stock 256kbps @ 526x330 is potato;
// bump the bitrate to match the higher resolution below. Still H264/.ts (web/HLS compatible) and
// ~1/4 the bitrate of fcamera.hevc, so the file stays small. Override with QCAM_BITRATE if needed.
int _qcam_bitrate = getenv("QCAM_BITRATE") ? atoi(getenv("QCAM_BITRATE")) : 1'600'000;
// Keep Konn3kt route uploads useful without turning each 60-second qcamera
// segment into a 26 MB file. This is still a substantial improvement over
// stock qcam, while CBR keeps storage and upload usage predictable.
int _qcam_bitrate = getenv("QCAM_BITRATE") ? atoi(getenv("QCAM_BITRATE")) : 2'400'000;
return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, .bitrate = _qcam_bitrate, .gop_size = 15};
}
@@ -140,8 +147,8 @@ const EncoderInfo qcam_encoder_info = {
.filename = "qcamera.ts",
.cbr = true, // enforce the bitrate so upload size stays predictable (no VBR overshoot)
.get_settings = [](int){return EncoderSettings::QcamEncoderSettings();},
.frame_width = 1052, // 2x the stock 526x330, same road-cam aspect ratio
.frame_height = 660,
.frame_width = QCAM_WIDTH,
.frame_height = QCAM_HEIGHT,
.include_audio = Params().getBool("RecordAudio"),
INIT_ENCODE_FUNCTIONS(QRoadEncode),
};

View File

@@ -11,7 +11,6 @@ import threading
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
def unblock_stdout() -> None:
# get a non-blocking stdout
@@ -103,51 +102,3 @@ def save_bootlog():
t = threading.Thread(target=fn, args=(tmp, ))
t.daemon = True
t.start()
# REVERT ME! ---------------------------------------------------------------
# One-shot migration carrying values across the sunny -> IQ param key renames.
# Old keys are no longer registered in params_keys.h, so their values are read
# straight off disk, copied to the new IQ key, and the stale file removed.
# Delete this block (and its manager_init() call) once fielded devices have
# booted past it at least once.
_RENAMED_PARAMS = {
"QuietMode": "IQAlertSilence",
"SpeedLimitMode": "IQSpeedAssistMode",
"SpeedLimitPolicy": "IQSpeedAssistPolicy",
"SpeedLimitOffsetType": "IQSpeedAssistOffsetType",
"SpeedLimitValueOffset": "IQSpeedAssistValueOffset",
"LaneTurnDesire": "IQLaneTurnDesire",
"LaneTurnValue": "IQLaneTurnValue",
"BlinkerPauseLateralControl": "IQBlinkerPauseLateral",
"BlinkerMinLateralControlSpeed": "IQBlinkerMinLateralSpeed",
"DevUIInfo": "IQDevUIInfo",
}
def migrate_renamed_params(params: Params | None = None) -> None:
"""REVERT ME! carry stored values across the sunny->IQ key renames, then drop the old files.
Copies at the file level: on-disk param values are raw bytes, so this preserves the exact
stored representation and sidesteps put()'s typed-value check for BOOL/INT/FLOAT keys.
"""
p = params if params is not None else Params()
for old, new in _RENAMED_PARAMS.items():
try:
old_path = p.get_param_path(old)
if not os.path.isfile(old_path):
continue
new_path = p.get_param_path(new)
if not os.path.exists(new_path):
with open(old_path, "rb") as f:
value = f.read()
tmp = new_path + ".tmp"
with open(tmp, "wb") as f:
f.write(value)
f.flush()
os.fsync(f.fileno())
os.rename(tmp, new_path)
os.remove(old_path)
except Exception:
cloudlog.exception(f"param rename migration failed for {old} -> {new}")
# END REVERT ME! ------------------------------------------------------------

View File

@@ -17,7 +17,7 @@ from openpilot.common.params import Params, ParamKeyFlag
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE
from openpilot.system.loggerd.crash_recovery import recover_unclean_segments
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog, heal_param_perms, migrate_renamed_params
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog, heal_param_perms
from openpilot.system.manager.process import ensure_running
from openpilot.system.manager.process_config import managed_processes
from openpilot.iqpilot.konn3kt.registration import register, UNREGISTERED_DONGLE_ID
@@ -40,7 +40,6 @@ def manager_init() -> None:
build_metadata = get_build_metadata()
params = Params()
migrate_renamed_params(params) # REVERT ME! sunny->IQ param key migration (runs before defaults are filled)
params.clear_all(ParamKeyFlag.CLEAR_ON_MANAGER_START)
params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION)
params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION)
@@ -50,7 +49,7 @@ def manager_init() -> None:
# device boot mode
if params.get("DeviceBootMode") == 1: # start in Always Offroad mode
params.put_bool("OffroadMode", True)
params.put_bool("IQAlwaysOffroad", True)
if params.get_bool("RecordFrontLock"):
params.put_bool("RecordFront", True)

View File

@@ -181,6 +181,32 @@ class NativeProcess(ManagerProcess):
self.shutting_down = False
def _normalize_bundle_modes(bundle: str) -> None:
import json
candidates = []
if env_root := os.environ.get("IQPILOT_PROPRIETARY_ROOT"):
candidates += [os.path.join(env_root, bundle), env_root]
candidates += [
os.path.join(BASEDIR, ".iqpilot", "bundles", bundle),
os.path.join(os.path.dirname(BASEDIR), ".iqpilot", "bundles", bundle),
os.path.join(BASEDIR, "artifacts", bundle),
]
root = next((c for c in candidates if os.path.isfile(os.path.join(c, "manifest.json"))), None)
if root is None:
return
try:
with open(os.path.join(root, "manifest.json")) as f:
manifest = json.load(f)
for rel, meta in manifest.items():
if not (isinstance(meta, dict) and "mode" in meta and "sha256" in meta):
continue
path = os.path.join(root, rel)
if os.path.isfile(path) and (os.stat(path).st_mode & 0o777) != meta["mode"]:
os.chmod(path, meta["mode"])
except Exception:
cloudlog.exception(f"failed to normalize bundle modes for {bundle}")
class BundleProcess(NativeProcess):
def __init__(self, name, bundle, entry, should_run, enabled=True, sigkill=False, restart_if_crash=False):
self.bundle = bundle
@@ -204,6 +230,11 @@ class BundleProcess(NativeProcess):
sigkill=sigkill,
)
def start(self) -> None:
if self.proc is None:
_normalize_bundle_modes(self.bundle)
super().start()
class PythonProcess(ManagerProcess):
def __init__(self, name, module, should_run, enabled=True, sigkill=False, restart_if_crash=False):

View File

@@ -1,9 +1,9 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
from openpilot.system.ui.iqpilot.widgets.list_view import IQButtonAction
from openpilot.system.ui.iqwidgets.widgets.list_view import IQButtonAction
class NoElideButtonAction(IQButtonAction):
class WideButtonAction(IQButtonAction):
def get_width_hint(self):
return super().get_width_hint() + 1

View File

@@ -7,9 +7,9 @@ import time
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.iqpilot.lib.styles import ink, metrics
from openpilot.system.ui.iqwidgets.lib.styles import ink, metrics
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.iqpilot.lib import canvas
from openpilot.system.ui.iqwidgets.lib import canvas
from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets.toggle import Toggle
@@ -899,8 +899,8 @@ def progress_item(title):
from dataclasses import dataclass, field
from openpilot.common.params import Params
from openpilot.system.ui.iqpilot.lib.styles import ink
from openpilot.system.ui.iqpilot.widgets.helpers.glyphs import draw_star
from openpilot.system.ui.iqwidgets.lib.styles import ink
from openpilot.system.ui.iqwidgets.widgets.helpers.glyphs import draw_star
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
@@ -925,13 +925,13 @@ _FRAME_PAD = 50
@dataclass
class TreeNode:
class PickerItem:
ref: str
data: dict = field(default_factory=dict)
@dataclass
class TreeFolder:
class PickerGroup:
folder: str
nodes: list
@@ -999,7 +999,7 @@ class _TreeRow(Button):
return super()._handle_mouse_release(mouse_pos)
class TreeOptionDialog(MultiOptionDialog):
class PickerDialog(MultiOptionDialog):
"""Folder/leaf picker with search, favourites, and a pinned current selection."""
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
@@ -1018,7 +1018,7 @@ class TreeOptionDialog(MultiOptionDialog):
self.on_exit = on_exit
self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref))
self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')]
self.search_title = search_title or tr("Enter search query")
self.search_title = search_title or tr("Type to search")
self.search_subtitle = search_subtitle
self._search_rect: rl.Rectangle | None = None
self._search_pressed = False

View File

@@ -26,7 +26,7 @@ from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.multilang import multilang
from openpilot.common.realtime import Ratekeeper
from openpilot.system.ui.iqpilot.lib.application import IQAppHooks
from openpilot.system.ui.iqwidgets.lib.application import IQAppHooks
from openpilot.system.ui.lib.screen_recorder import ScreenRecorder
_DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20, 'tici': 20}.get(HARDWARE.get_device_type(), 60)))

View File

@@ -33,10 +33,19 @@ EMOJI_REGEX = re.compile(
flags=re.UNICODE
)
_emoji_font_loaded = False
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
global _emoji_font
if _emoji_font is None:
_emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109)
global _emoji_font, _emoji_font_loaded
if not _emoji_font_loaded:
_emoji_font_loaded = True
try:
# FONT_DIR is an importlib.resources path. Inside the setup zipapp it points into the archive,
# so str() yields a path through the .zip that PIL can't open ("cannot open resource"). Read
# the bytes and hand PIL a file object so it works both on disk and inside the zipapp.
_emoji_font = ImageFont.truetype(io.BytesIO(FONT_DIR.joinpath("NotoColorEmoji.ttf").read_bytes()), 109)
except Exception:
_emoji_font = None # never crash the whole UI over an emoji glyph
return _emoji_font
def find_emoji(text):
@@ -44,12 +53,15 @@ def find_emoji(text):
def emoji_tex(emoji):
if emoji not in _cache:
font = _load_emoji_font()
if font is None:
return None
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((0, 0), emoji, font=_load_emoji_font(), embedded_color=True)
draw.text((0, 0), emoji, font=font, embedded_color=True)
with io.BytesIO() as buffer:
img.save(buffer, format="PNG")
l = buffer.tell()
buffer.seek(0)
_cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l))
return _cache[emoji]
return _cache.get(emoji)

View File

@@ -67,6 +67,9 @@ class MeteredType(IntEnum):
NO = 2
_WARNED_UNSUPPORTED_NETWORKS: set[tuple[int, int, int]] = set()
def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType:
wpa_props = wpa_flags | rsn_flags
@@ -83,7 +86,10 @@ def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityTyp
# WPA2, WPA2+WPA3 mixed, or WPA — all handled via WPA key_mgmt (NM negotiates SAE if available)
return SecurityType.WPA2
else:
cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}")
_key = (flags, wpa_flags, rsn_flags)
if _key not in _WARNED_UNSUPPORTED_NETWORKS:
_WARNED_UNSUPPORTED_NETWORKS.add(_key)
cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}")
return SecurityType.UNSUPPORTED
@@ -630,6 +636,7 @@ class WifiManager:
cloudlog.warning("No WiFi device found")
return
self._set_device_autoconnect(True)
self._connecting_to_ssid = ssid
self._router_main.send(new_method_call(self._nm, 'ActivateConnection', 'ooo',
(conn_path, self._wifi_device, "/")))
@@ -639,6 +646,37 @@ class WifiManager:
else:
threading.Thread(target=worker, daemon=True).start()
def disconnect_connection(self, ssid: str, block: bool = False):
def worker():
if self._router_main is None:
cloudlog.warning(f"WiFi not ready while disconnecting {ssid}")
return
if ssid not in self._get_connections():
return
# the profile stays saved and untouched; without clearing autoconnect on the device
# NetworkManager re-associates within seconds
self._set_device_autoconnect(False)
self._connecting_to_ssid = ""
self._deactivate_connection(ssid)
self._update_networks()
self._enqueue_callbacks(self._disconnected)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def _set_device_autoconnect(self, enabled: bool) -> None:
if self._router_main is None or self._wifi_device is None:
return
dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE)
reply = self._router_main.send_and_get_reply(Properties(dev_addr).set('Autoconnect', 'b', enabled))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to set device autoconnect={enabled}: {reply}')
def _deactivate_connection(self, ssid: str):
target_conn_path = self._get_connections().get(ssid, None)
if target_conn_path is None:

View File

@@ -50,6 +50,8 @@ INSTALLER_URL_PATH = "/tmp/installer_url"
# "<user>/<branch>" maps to a GitHub fork. IQ.OS uses the DRM "magic" compositor, not Wayland,
# so comma's downloaded installers (installer.comma.ai) crash on launch; clone the fork directly.
GITHUB_FORK_URL = "https://github.com/{user}/openpilot.git"
# IQ.Pilot lives on the IQ Lvbs git server; github.com/IQLvbs is DMCA'd and dead.
GIT_URL_OVERRIDES = {"IQLvbs": "https://git.konn3kt.com/IQ.Lvbs/IQ.Pilot.git"}
CONTINUE = """#!/usr/bin/env bash
@@ -756,7 +758,7 @@ class Setup(Widget):
pass
def _fork_install_thread(self, user: str, branch: str):
git_url = GITHUB_FORK_URL.format(user=user)
git_url = GIT_URL_OVERRIDES.get(user) or GITHUB_FORK_URL.format(user=user)
label = f"{user}/{branch}"
try:
subprocess.run(["rm", "-rf", TMP_INSTALL_PATH], check=False)
@@ -774,7 +776,8 @@ class Setup(Widget):
subprocess.run(["git", "-C", TMP_INSTALL_PATH, "submodule", "update", "--init"], check=False)
run_cmd(["rm", "-f", VALID_CACHE_PATH])
run_cmd(["rm", "-rf", INSTALL_PATH])
# sudo: a prior *run* install can leave root-owned .pyc here that a comma-user rm can't delete.
run_cmd(["sudo", "rm", "-rf", INSTALL_PATH])
run_cmd(["mv", TMP_INSTALL_PATH, INSTALL_PATH])
self._ble_progress("installing", 90)
@@ -882,6 +885,11 @@ class Setup(Widget):
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
# comma's ELF installer does `rm -rf /data/openpilot` as the comma user and asserts it
# succeeds; a prior *run* install leaves root-owned __pycache__ .pyc it can't delete, so it
# aborts before continue.sh and bounces back to setup. Clear the old tree first (sudo) so the
# installer's rm succeeds.
subprocess.run(["sudo", "rm", "-rf", INSTALL_PATH], check=False)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:

View File

@@ -51,6 +51,8 @@ INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
GITHUB_FORK_URL = "https://github.com/{user}/openpilot.git"
# IQ.Pilot lives on the IQ Lvbs git server; github.com/IQLvbs is DMCA'd and dead.
GIT_URL_OVERRIDES = {"IQLvbs": "https://git.konn3kt.com/IQ.Lvbs/IQ.Pilot.git"}
CONTINUE = """#!/usr/bin/env bash
@@ -589,7 +591,7 @@ class Setup(Widget):
pass
def _fork_install_thread(self, user: str, branch: str):
git_url = GITHUB_FORK_URL.format(user=user)
git_url = GIT_URL_OVERRIDES.get(user) or GITHUB_FORK_URL.format(user=user)
label = f"{user}/{branch}"
fail_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
try:
@@ -609,7 +611,8 @@ class Setup(Widget):
subprocess.run(["git", "-C", TMP_INSTALL_PATH, "submodule", "update", "--init"], check=False)
run_cmd(["rm", "-f", VALID_CACHE_PATH])
run_cmd(["rm", "-rf", INSTALL_PATH])
# sudo: a prior *run* install can leave root-owned .pyc here that a comma-user rm can't delete.
run_cmd(["sudo", "rm", "-rf", INSTALL_PATH])
run_cmd(["mv", TMP_INSTALL_PATH, INSTALL_PATH])
self._ble_progress("installing", 90)
@@ -718,6 +721,11 @@ class Setup(Widget):
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
# comma's ELF installer does `rm -rf /data/openpilot` as the comma user and asserts it
# succeeds; a prior *run* install leaves root-owned __pycache__ .pyc it can't delete, so it
# aborts before continue.sh and bounces back to setup. Clear the old tree first (sudo) so the
# installer's rm succeeds.
subprocess.run(["sudo", "rm", "-rf", INSTALL_PATH], check=False)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:

View File

@@ -406,7 +406,8 @@ class Label(Widget):
line_pos.x += width_before.x
tex = emoji_tex(emoji)
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
if tex is not None:
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
@@ -849,8 +850,9 @@ class UnifiedLabel(Widget):
# Draw emoji
tex = emoji_tex(emoji)
emoji_scale = self._font_size / tex.height * FONT_SCALE
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
if tex is not None:
emoji_scale = self._font_size / tex.height * FONT_SCALE
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
# Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end

View File

@@ -17,10 +17,10 @@ from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
if gui_app.iqpilot_ui():
from openpilot.system.ui.iqpilot.widgets.list_view import button_item
from openpilot.system.ui.iqpilot.widgets.list_view import IQListItem as ListItem
from openpilot.system.ui.iqpilot.widgets.list_view import IQToggleAction as ToggleAction
from openpilot.system.ui.iqpilot.widgets.list_view import IQMultipleButtonAction as MultipleButtonAction
from openpilot.system.ui.iqwidgets.widgets.list_view import button_item
from openpilot.system.ui.iqwidgets.widgets.list_view import IQListItem as ListItem
from openpilot.system.ui.iqwidgets.widgets.list_view import IQToggleAction as ToggleAction
from openpilot.system.ui.iqwidgets.widgets.list_view import IQMultipleButtonAction as MultipleButtonAction
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
try:
@@ -66,6 +66,7 @@ class UIState(IntEnum):
NEEDS_AUTH = 2
SHOW_FORGET_CONFIRM = 3
FORGETTING = 4
DISCONNECTING = 5
class NavButton(Widget):
@@ -328,6 +329,7 @@ class WifiManagerUI(Widget):
self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING
self._password_retry: bool = False # for NEEDS_AUTH
self.btn_width: int = 200
self.disconnect_btn_width: int = 300
self.scroll_panel = GuiScrollPanel()
self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
self._load_icons()
@@ -335,6 +337,7 @@ class WifiManagerUI(Widget):
self._networks: list[Network] = []
self._networks_buttons: dict[str, Button] = {}
self._forget_networks_buttons: dict[str, Button] = {}
self._disconnect_networks_buttons: dict[str, Button] = {}
self._wifi_manager.add_callbacks(need_auth=self._on_need_auth,
activated=self._on_activated,
@@ -417,7 +420,9 @@ class WifiManagerUI(Widget):
def _draw_network_item(self, rect, network: Network):
spacing = 50
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT)
show_disconnect = network.is_connected and self.state not in (UIState.CONNECTING, UIState.DISCONNECTING)
reserved = self.btn_width * 2 + (self.disconnect_btn_width + spacing if show_disconnect else 0)
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - reserved, ITEM_HEIGHT)
signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
security_icon_rect = rl.Rectangle(signal_icon_rect.x - spacing - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
@@ -434,6 +439,10 @@ class WifiManagerUI(Widget):
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = tr("FORGETTING...")
elif self.state == UIState.DISCONNECTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = tr("DISCONNECTING...")
elif network.security_type == SecurityType.UNSUPPORTED:
self._networks_buttons[network.ssid].set_enabled(False)
else:
@@ -455,6 +464,15 @@ class WifiManagerUI(Widget):
)
self._forget_networks_buttons[network.ssid].render(forget_btn_rect)
if show_disconnect:
disconnect_btn_rect = rl.Rectangle(
forget_btn_rect.x - self.btn_width - spacing,
forget_btn_rect.y,
self.btn_width,
80,
)
self._disconnect_networks_buttons[network.ssid].render(disconnect_btn_rect)
self._draw_status_icon(security_icon_rect, network)
self._draw_signal_strength_icon(signal_icon_rect, network)
@@ -470,6 +488,9 @@ class WifiManagerUI(Widget):
self.state = UIState.SHOW_FORGET_CONFIRM
self._state_network = network
def _disconnect_networks_buttons_callback(self, network):
self.disconnect_network(network)
def _draw_status_icon(self, rect, network: Network):
"""Draw the status icon based on network's connection state"""
icon_file = None
@@ -506,6 +527,11 @@ class WifiManagerUI(Widget):
self._state_network = network
self._wifi_manager.forget_connection(network.ssid)
def disconnect_network(self, network: Network):
self.state = UIState.DISCONNECTING
self._state_network = network
self._wifi_manager.disconnect_connection(network.ssid)
def _on_network_updated(self, networks: list[Network]):
self._networks = networks
for n in self._networks:
@@ -515,6 +541,9 @@ class WifiManagerUI(Widget):
self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
font_size=45)
self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
self._disconnect_networks_buttons[n.ssid] = Button(tr("Disconnect"), partial(self._disconnect_networks_buttons_callback, n),
button_style=ButtonStyle.FORGET_WIFI, font_size=45)
self._disconnect_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
def _on_need_auth(self, ssid):
network = next((n for n in self._networks if n.ssid == ssid), None)
@@ -532,7 +561,7 @@ class WifiManagerUI(Widget):
self.state = UIState.IDLE
def _on_disconnected(self):
if self.state == UIState.CONNECTING:
if self.state in (UIState.CONNECTING, UIState.DISCONNECTING):
self.state = UIState.IDLE