Compare commits

..

67 Commits

Author SHA1 Message Date
James Vecellio-Grant
3d63ed8cd6 Delete tools/profile_params.py 2026-01-05 07:58:24 -08:00
discountchubbs
debe933d79 how'd that get pushed down on git merge so nasty 2026-01-05 07:56:23 -08:00
discountchubbs
ebdfc60cb5 Merge remote-tracking branch 'origin/paramwatcher' into watcher
# Conflicts:
#	selfdrive/ui/sunnypilot/layouts/settings/settings.py
#	selfdrive/ui/sunnypilot/ui_state.py
2026-01-05 07:00:49 -08:00
James Vecellio-Grant
1a6e0c2781 Merge branch 'master' into paramwatcher 2026-01-05 07:54:30 -07:00
discountchubbs
e989633aba lint 2025-12-28 10:43:24 -07:00
James Vecellio-Grant
593a48f808 Merge branch 'master' into paramwatcher 2025-12-28 10:39:38 -07:00
discountchubbs
c324123e8b i raised the buffer and forgot 2025-12-13 07:50:11 -08:00
discountchubbs
9f6264eca9 maybe cut the quality by half 2025-12-13 07:45:06 -08:00
discountchubbs
f77457cebc jpg 2025-12-13 07:43:43 -08:00
discountchubbs
6a193ed2d8 why you show lfs 2025-12-13 07:33:13 -08:00
discountchubbs
a416de477a blob? 2025-12-13 07:27:31 -08:00
discountchubbs
785e87376a Update image links to raw URLs for GitHub LFS compatibility 2025-12-13 07:25:22 -08:00
James Vecellio-Grant
72bc73d4fe Merge branch 'paramwatcher' into watcher 2025-12-13 07:17:49 -08:00
discountchubbs
7692d35a63 Merge remote-tracking branch 'origin/master' into paramwatcher 2025-12-13 07:16:54 -08:00
discountchubbs
e0a27514e2 Research report 2025-12-13 07:16:18 -08:00
James Vecellio-Grant
6e06bcb14f Merge branch 'paramwatcher' into watcher 2025-12-11 06:54:40 -08:00
discountchubbs
bc07cecfa0 buffer 2025-12-11 06:54:17 -08:00
discountchubbs
b737989e64 not needed 2025-12-08 05:54:50 -08:00
James Vecellio-Grant
5fbc358fd5 Merge branch 'paramwatcher' into watcher 2025-12-08 05:54:18 -08:00
discountchubbs
ce30d815f7 except "." 2025-12-08 05:53:52 -08:00
James Vecellio
fdde1aa6a1 this is hindsight 2025-12-07 19:26:36 -08:00
discountchubbs
961b2a2d30 drive back the darkness 2025-12-07 18:20:50 -08:00
discountchubbs
f3d39d481a wat 2025-12-07 17:13:58 -08:00
James Vecellio-Grant
6e037d80ff Merge branch 'paramwatcher' into watcher 2025-12-07 17:13:06 -08:00
discountchubbs
907bc5cf06 pyzmq considered... rejected.
I created a service handler in pyzmq and memory tested and it was in the MegaBytes, comparable to direct Params access, fudge that.
2025-12-07 17:12:39 -08:00
James Vecellio-Grant
b3ff268f89 Merge branch 'paramwatcher' into watcher 2025-12-07 14:05:37 -08:00
discountchubbs
42e08515e6 make the link go to LxCx 2025-12-07 12:22:15 -08:00
discountchubbs
d0ec46dc5d cp 2025-12-07 12:14:54 -08:00
discountchubbs
48a8802298 cp analysis needs high level 2025-12-07 12:12:43 -08:00
discountchubbs
79971b9eb2 move limitations to end 2025-12-07 12:11:49 -08:00
discountchubbs
7ba21f9f1b 2025 2025-12-07 11:57:27 -08:00
discountchubbs
6b4118ab27 dates 2025-12-07 11:55:27 -08:00
discountchubbs
0844424ad1 update 2025-12-07 11:47:14 -08:00
James Vecellio-Grant
5901c9b41f Merge branch 'paramwatcher' into watcher 2025-12-07 11:45:20 -08:00
discountchubbs
d52ce19c15 update readme 2025-12-07 11:44:34 -08:00
discountchubbs
05cc9a14e2 reset thread 2025-12-07 11:33:43 -08:00
discountchubbs
18f8956e0e params 2025-12-07 11:23:49 -08:00
discountchubbs
0aa6f22c26 watch 2025-12-07 11:15:58 -08:00
James Vecellio-Grant
c90f262ce7 Merge branch 'paramwatcher' into watcher 2025-12-07 11:08:48 -08:00
discountchubbs
e8ee5a23f0 my little soda pop 2025-12-07 11:08:16 -08:00
discountchubbs
4a189f828a oopsie 2025-12-07 10:13:36 -08:00
James Vecellio-Grant
072e18faef Merge branch 'paramwatcher' into watcher 2025-12-07 10:12:56 -08:00
James Vecellio
3b1fddfde9 60% comparison for slow computers 2025-12-07 10:12:14 -08:00
discountchubbs
bddec6971e debounce 2025-12-07 10:06:23 -08:00
discountchubbs
34e02b6ae5 Revert "Merge remote-tracking branch 'origin/paramwatcher' into watcher"
This reverts commit c98cc5d40a, reversing
changes made to 4a0d8063e5.
2025-12-07 10:03:04 -08:00
discountchubbs
c98cc5d40a Merge remote-tracking branch 'origin/paramwatcher' into watcher 2025-12-07 10:02:55 -08:00
discountchubbs
4a0d8063e5 Merge remote-tracking branch 'origin/paramwatcher' into watcher 2025-12-07 10:02:41 -08:00
discountchubbs
e2e52bcccb Merge remote-tracking branch 'origin/paramwatcher' into paramwatcher 2025-12-07 09:59:57 -08:00
discountchubbs
ccf86b7b72 cb 2025-12-07 09:59:47 -08:00
James Vecellio-Grant
483894cfc8 Merge branch 'master' into watcher 2025-12-07 09:59:04 -08:00
James Vecellio-Grant
a678554122 Merge branch 'master' into paramwatcher 2025-12-07 09:58:35 -08:00
discountchubbs
bfd3eab260 rm 2025-12-07 09:56:45 -08:00
discountchubbs
f5aedbce6e unit/int tests 2025-12-07 09:45:18 -08:00
discountchubbs
4f860dd397 Merge remote-tracking branch 'origin/watcher' into watcher 2025-12-07 08:10:59 -08:00
discountchubbs
f308d9ab17 markdown 2025-12-07 08:10:50 -08:00
James Vecellio-Grant
9226222ad4 Merge branch 'master' into watcher 2025-12-06 14:44:23 -08:00
discountchubbs
3e317a8b4d give me ALL the params access. limit sys reading by 500x 2025-12-06 14:43:45 -08:00
James Vecellio
0eae4e0b3b layout 2025-12-05 19:44:19 -08:00
James Vecellio
37ffa5ed21 layout 2025-12-05 19:44:14 -08:00
James Vecellio
05e3eaf2fc certified freak 2025-12-05 15:59:13 -08:00
discountchubbs
c8fc344d68 watch this paramwatcher 2025-12-05 13:17:04 -08:00
James Vecellio-Grant
264948e5ff Update param_watcher.py 2025-12-05 08:55:02 -08:00
James Vecellio-Grant
9d87beac8e Merge branch 'master' into watcher 2025-12-05 08:52:18 -08:00
James Vecellio-Grant
2e0bc80f94 Update param_watcher.py 2025-12-05 08:50:31 -08:00
James Vecellio-Grant
4b8781886a Update ui_state.py 2025-12-05 08:43:08 -08:00
James Vecellio-Grant
97edff5e5c Merge branch 'master' into watcher 2025-12-04 06:06:29 -08:00
discountchubbs
a81570a6c2 file system watcher 👀 2025-12-03 22:23:37 -08:00
53 changed files with 3098 additions and 82 deletions

View File

@@ -217,6 +217,7 @@ class Car:
if can_rcv_valid and REPLAY:
self.can_log_mono_time = messaging.log_from_bytes(can_strs[0]).logMonoTime
self.v_cruise_helper.update_speed_limit_assist(self.is_metric, self.sm['longitudinalPlanSP'])
self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric)
if self.sm['carControl'].enabled and not self.CC_prev.enabled:
# Use CarState w/ buttons from the step selfdrived enables on

View File

@@ -53,6 +53,7 @@ class VCruiseHelper(VCruiseHelperSP):
if not self.CP.pcmCruise or (not self.CP_SP.pcmCruiseSpeed and _enabled):
# if stock cruise is completely disabled, then we can use our own set speed logic
self._update_v_cruise_non_pcm(CS, _enabled, is_metric)
self.update_speed_limit_assist_v_cruise_non_pcm()
self.v_cruise_cluster_kph = self.v_cruise_kph
self.update_button_timers(CS, enabled)
else:
@@ -104,6 +105,12 @@ class VCruiseHelper(VCruiseHelperSP):
if not self.button_change_states[button_type]["enabled"]:
return
# Speed Limit Assist for Non PCM long cars.
# True: Disallow set speed changes when user confirmed the target set speed during preActive state
# False: Allow set speed changes as SLA is not requesting user confirmation
if self.update_speed_limit_assist_pre_active_confirmed(button_type):
return
long_press, v_cruise_delta = VCruiseHelperSP.update_v_cruise_delta(self, long_press, v_cruise_delta)
if long_press and self.v_cruise_kph % v_cruise_delta != 0: # partial interval
self.v_cruise_kph = CRUISE_NEAREST_FUNC[button_type](self.v_cruise_kph / v_cruise_delta) * v_cruise_delta

View File

@@ -1,4 +1,3 @@
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
@@ -35,7 +34,7 @@ DESCRIPTIONS = {
class DeveloperLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._is_release = self._params.get_bool("IsReleaseBranch")
# Build items and keep references for callbacks/state updates

View File

@@ -3,7 +3,6 @@ import math
from cereal import messaging, log
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -35,7 +34,7 @@ class DeviceLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None

View File

@@ -1,5 +1,5 @@
from cereal import log
from openpilot.common.params import Params, UnknownKeyName
from openpilot.common.params import UnknownKeyName
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
@@ -41,7 +41,7 @@ DESCRIPTIONS = {
class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._is_release = self._params.get_bool("IsReleaseBranch")
# param, title, desc, icon, needs_restart
@@ -198,11 +198,6 @@ class TogglesLayout(Widget):
self._update_experimental_mode_icon()
# 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:

View File

@@ -226,9 +226,7 @@ class ModelsLayout(Widget):
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
live_delay: bool = ui_state.params.get_bool("LagdToggle")
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
self.lagd_toggle.action_item.set_state(live_delay)
self.delay_control.set_visible(not live_delay and advanced_controls)
new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100
if self.lane_turn_value_control.action_item.value_change_step != new_step:

View File

@@ -28,6 +28,7 @@ from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.lib.multilang import tr_noop
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller_tici import Scroller
@@ -196,6 +197,10 @@ class SettingsLayoutSP(OP.SettingsLayout):
return False
def set_current_panel(self, panel_type: OP.PanelType):
super().set_current_panel(panel_type)
ui_state.set_active_layout(self._panels[self._current_panel].instance)
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()

View File

@@ -336,7 +336,6 @@ class SunnylinkLayout(Widget):
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled)
self.handle_backup_restore_progress()

View File

@@ -55,5 +55,4 @@ class HyundaiSettings(BrandSettings):
self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled)
self.longitudinal_tuning_item.set_description(long_tuning_desc)
self.longitudinal_tuning_item.show_description(True)
self.longitudinal_tuning_item.action_item.set_selected_button(tuning_param)
self.longitudinal_tuning_item.set_visible(self.alpha_long_available)

View File

@@ -0,0 +1,41 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
def update_item_from_param(item, key, params):
if not (action := getattr(item, 'action_item', None)):
return
if hasattr(action, 'set_state'):
action.set_state(params.get_bool(key))
elif hasattr(action, 'set_value'):
action.set_value(params.get(key, return_default=True))
else:
try:
val = int(params.get(key, return_default=True))
if hasattr(action, 'selected_button'):
action.selected_button = val
if hasattr(action, 'current_value'):
action.current_value = val
except (ValueError, TypeError):
pass
def sync_layout_params(layout, param_name, params):
targets = []
if toggles := getattr(layout, '_toggles', None):
targets.extend([(item, k) for k, item in toggles.items()])
items = getattr(layout, 'items', []) or getattr(getattr(layout, '_scroller', None), '_items', [])
for item in items:
action = getattr(item, 'action_item', None)
if key := getattr(action, 'param_key', None) or getattr(getattr(action, 'toggle', None), 'param_key', None):
targets.append((item, key))
for item, key in targets:
if param_name is None or key == param_name:
update_item_from_param(item, key, params)

View File

@@ -5,8 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from cereal import messaging, log, custom
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.common.params import Params
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
from openpilot.selfdrive.ui.sunnypilot.ui_helpers import sync_layout_params
OpenpilotState = log.SelfdriveState.OpenpilotState
MADSState = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState
@@ -15,14 +18,27 @@ MADSState = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemSt
class UIStateSP:
def __init__(self):
self.params = Params()
self.params.add_watcher(self.on_param_change)
self.params.start()
self.sm_services_ext = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"gpsLocation", "liveTorqueParameters", "carStateSP", "carParamsSP", "liveDelay"
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay"
]
self.sunnylink_state = SunnylinkState()
self.active_layout = None
self.changed_params = set()
def set_active_layout(self, layout):
self.active_layout = layout
if layout:
sync_layout_params(layout, None, self.params)
def on_param_change(self, param_name):
self.changed_params.add(param_name)
self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True)
self.global_brightness_override: int = self.params.get("Brightness", return_default=True)
def update(self) -> None:
if self.sunnylink_enabled:
@@ -30,6 +46,17 @@ class UIStateSP:
else:
self.sunnylink_state.stop()
if not self.params.is_watching():
cloudlog.warning("ParamWatcher thread died, restarting...")
self.params.start()
if self.changed_params:
while self.changed_params:
self.changed_params.pop()
if self.active_layout:
sync_layout_params(self.active_layout, None, self.params)
@staticmethod
def update_status(ss, ss_sp, onroad_evt) -> str:
state = ss.state
@@ -77,6 +104,7 @@ class UIStateSP:
self.chevron_metrics = self.params.get("ChevronInfo")
self.active_bundle = self.params.get("ModelManager_ActiveBundle")
self.custom_interactive_timeout = self.params.get("InteractivityTimeout", return_default=True)
self.global_brightness_override = self.params.get("Brightness", return_default=True)
class DeviceSP:

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from enum import Enum
from cereal import messaging, car, log
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
@@ -36,7 +35,6 @@ class UIState(UIStateSP):
def _initialize(self):
UIStateSP.__init__(self)
self.params = Params()
self.sm = messaging.SubMaster(
[
"modelV2",
@@ -258,9 +256,18 @@ class Device(DeviceSP):
else:
clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
if gui_app.sunnypilot_ui():
if ui_state.global_brightness_override <= 0:
min_global_brightness = 1 if ui_state.global_brightness_override < 0 else 30
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [min_global_brightness, 100]))
else:
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
brightness = round(self._brightness_filter.update(clipped_brightness))
if gui_app.sunnypilot_ui() and ui_state.global_brightness_override > 0:
brightness = ui_state.global_brightness_override
if not self._awake:
brightness = 0

129
sunnypilot/common/README.md Normal file
View File

@@ -0,0 +1,129 @@
<center>
# Comparative Analysis of Parameter Getter Methods: Params vs. ParamWatcher
James (sunnypilot Developer) <br>
December 13, 2025
</center>
## <br><br> Abstract
This research report examines the inefficiencies in standard parameter access methods within sunnypilot and proposes an optimized alternative, ParamWatcher. The standard `Params::get()` method incurs significant CPU and memory overhead due to repeated file I/O operations (sunnypilot, 2025). ParamWatcher utilizes OS-level file system events (inotify on Linux, FSEvents on macOS) to maintain an in-memory cache, reducing I/O to near zero (Linux man-pages, 2025-c; Apple Inc., n.d.-a). Empirical benchmarks with ~10 million parameter accesses demonstrate a 14.5x CPU speedup and flat memory usage (514.7 KB vs. 497.7 KB for base Params, with only 17 KB overhead). The implementation employs a process-local singleton pattern for efficiency in multi-process architectures (Gamma et al., 1994). Results indicate ParamWatcher eliminates UI stutters and GC pauses, enhancing system responsiveness without compromising data freshness.
**Keywords:** parameter access, file I/O optimization, event-driven caching, autonomous driving systems, performance benchmarking
## Introduction
In sunnypilot, efficient parameter management is important for real-time system access. The standard `Params::get()` method, implemented in C++ and wrapped in Python, performs full file I/O cycles for each access, leading to high CPU overhead and memory churn (sunnypilot, 2025). This is particularly problematic in UI loops where parameters are queried frequently (e.g., 50 toggles at 20 FPS equates to ~1,000 reads/second).
This inefficiency stems from architectural mismatches: C++ streams are designed for throughput, not latency (cppreference.com, n.d.-a). Each call triggers kernel mode switches, heap allocations, and garbage collection in Python, causing UI stutters (Linux man-pages, 2025-a; Linux man-pages, 2025-b).
### Inefficiencies in Standard Parameter Access
The standard `Params::get()` method executes a full file I/O lifecycle—opening, allocating, reading, and closing—for every function call. This results in significant CPU overhead and memory churn due to the frequency of these operations in the user interface loop.
#### System Overhead Analysis
- **System Call Overhead**: Every read operation requires context switches into kernel mode. The `Params::get` function calls `util::read_file` (sunnypilot, 2025), which subsequently invokes `std::ifstream` (sunnypilot, 2025).
- **Impact**: Frequent context switching degrades performance (Linux man-pages, 2025-a; Linux man-pages, 2025-b).
- **C++ Stream Overhead**: The use of `std::ifstream` introduces additional overhead for maintaining stream state and buffering compared to raw file descriptors (cppreference.com, n.d.-a; Codezup, 2025).
- **Memory Churn**: The instantiation of `std::string result(size, '\0');` forces heap allocation and deallocation during every call (sunnypilot, 2025). This stresses the memory allocator and can lead to fragmentation (cppreference.com, n.d.-b).
This report introduces ParamWatcher, an event-driven caching solution using OS file system events. It shifts from polling to notifications, caching converted values in static RAM. I propose that ParamWatcher achieves minimum 10x+ CPU gains with bounded non increasing memory, improving sunnypilot's performance both on latency and responsiveness.
## Method
### Materials
- **System:** sunnypilot, running on macOS, Ubuntu/Linux, comma 3x, and comma four.
- **Parameters:** 231 defined keys in `param_keys.h`.
- **Tools:** Python 3, tracemalloc for memory profiling, time.perf_counter for CPU timing, ctypes for OS integration (Python Software Foundation, 2025).
### Implementation Details
ParamWatcher provides cross-platform file system monitoring using ctypes for direct OS integration (Python Software Foundation, 2025).
#### Linux Implementation
On Linux, ParamWatcher uses the inotify subsystem for efficient file change detection (Linux man-pages, 2025-c). It loads `libc.so.6` to access system calls, initializes an inotify instance, and watches the parameters directory for events like `IN_MODIFY` and `IN_CLOSE_WRITE` (Linux Kernel Organization, 2005). Events are polled with `select.epoll()` and parsed using `struct.unpack_from()` to avoid ctypes overhead. Filenames are extracted and passed to cache invalidation, ensuring real-time updates without polling (Codezup, 2025).
- **Library Loading**: `libc = ctypes.CDLL('libc.so.6')` loads the standard C library to access system calls.
- **Initialization**: `inotify_init()` is called to create a new inotify instance, returning a file descriptor.
- **Watch Setup**: `inotify_add_watch(fd, path, mask)` registers the parameters directory. The mask includes `IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE` (Linux Kernel Organization, 2005) to capture all relevant file changes.
- **Event Loop**:
- **Polling**: `select.epoll()` is used to efficiently wait for activity on the file descriptor without busy-waiting.
- **Reading**: When events occur, `os.read(fd, 2048)` retrieves the raw binary event data.
- **Parsing**: The code uses Python's `struct` module (`struct.unpack_from("iIII", ...)`) to parse the C-style `inotify_event` structures directly from the buffer, avoiding the overhead of defining `ctypes` structures.
- **Handling**: Extracted filenames are passed to `_trigger_callbacks`, which invalidates the specific cache entry (`self._cache.pop(path, None)`), forcing a fresh read on the next access.
#### macOS Implementation
On macOS, ParamWatcher leverages FSEvents from CoreServices for directory monitoring (Apple Inc., n.d.-a). It defines a C-compatible callback using `CFUNCTYPE`, creates an `FSEventStream` with `kFSEventStreamCreateFlagFileEvents`, and schedules it on the run loop (Apple Inc., n.d.-b). Events are filtered for modifications, creations, and renames (Apple Inc., n.d.-c), triggering cache invalidation for affected parameters.
- **Framework Loading**: `ctypes.cdll.LoadLibrary` loads `CoreServices` and `CoreFoundation`.
- **Callback Definition**: `CFUNCTYPE` is used to define a C-compatible callback function. This function is invoked by the OS whenever a change occurs in the watched directory.
- **Stream Creation**: `FSEventStreamCreate` creates a stream for the target directory. The `kFSEventStreamCreateFlagFileEvents` flag is used to request file-level granularity where available.
- **Event Filtering**: The callback filters events using flags such as `kFSEventStreamEventFlagItemCreated` and `kFSEventStreamEventFlagItemModified` to ensure only relevant file changes trigger updates (Apple Inc., n.d.-c).
- **Scheduling**: `FSEventStreamScheduleWithRunLoop` attaches the stream to the current thread's run loop (Apple Inc., n.d.-b).
- **Execution**: `CFRunLoopRun()` starts the event loop. This passes control to the OS, which wakes the thread only when necessary.
- **Handling**: Inside the callback, the code iterates through the changed paths provided by the OS. It extracts the filename and calls `_trigger_callbacks` to invalidate the cache for that specific parameter.
### Procedure
Benchmarks simulated heavy load:
- **Memory Test:** ~10 million gets (43,290 loops over 231 keys), measured with tracemalloc.
- **CPU Test:** Same load, timed with perf_counter.
- Comparisons: Base Params vs. ParamWatcher.
## Results
### Memory Usage
Using tracemalloc for peak memory measurement during ~10 million parameter accesses (43,290 loops over 231 keys), base Params peaked at 497.7 KB, while ParamWatcher peaked at 514.7 KB (17 KB overhead). ParamWatcher's memory remained flat post-initialization, preventing churn.
| Condition | Memory (KB) | Overhead |
|-----------|-------------|----------|
| Base Params | 497.7 | - |
| ParamWatcher | 514.7 | 17 KB |
### CPU Performance
ParamWatcher was 14.5x faster: 4.52s vs. 65.43s to complete ~10 million param gets.
| Condition | Time (s) | Speedup |
|-----------|----------|---------|
| Base Params | 65.43 | 1x |
| ParamWatcher | 4.52 | 14.5x |
### Scalability
No degradation at scale; cache invalidation maintained freshness.
## Discussion
ParamWatcher successfully optimizes parameter access, delivering substantial CPU gains with minimal memory overhead. The event-driven approach eliminates I/O bottlenecks, reducing GC pressure and UI stutters (cppreference.com, n.d.-b). The 17 KB memory overhead is negligible compared to the megabytes of churn from base Params, ensuring bounded usage in multi-process environments via the singleton pattern (Gamma et al., 1994).
Results demonstrate scalability without degradation, with cache invalidation maintaining data freshness. This optimization enhances system responsiveness.
Limitations include potential event latency in high-load scenarios (<10 ms, imperceptible for UI) and increased complexity from background threads.
Trade-offs: Static RAM (~17 KB) vs. dynamic churn; benefits outweigh costs for param-heavy workloads.
## <br>References
Apple Inc. (n.d.-a). *File System Events*. Retrieved from https://developer.apple.com/documentation/coreservices/file_system_events
Apple Inc. (n.d.-b). *CFRunLoop*. Retrieved from https://developer.apple.com/documentation/corefoundation/cfrunloop
Apple Inc. (n.d.-c). *FSEventStreamEventFlags*. Retrieved from https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags
Codezup. (2025). *Efficient File I/O in C++*. Retrieved from https://codezup.com/efficient-file-io-cpp-best-practices/
cppreference.com. (n.d.-a). *std::basic_ifstream*. Retrieved from https://en.cppreference.com/w/cpp/io/basic_ifstream
cppreference.com. (n.d.-b). *std::basic_string*. Retrieved from https://en.cppreference.com/w/cpp/string/basic_string/basic_string
Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley.
Linux Kernel Organization. (2005). *include/uapi/linux/inotify.h*. Retrieved from https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/inotify.h
Linux man-pages. (2025-a). *open(2)*. Retrieved from https://man7.org/linux/man-pages/man2/open.2.html
Linux man-pages. (2025-b). *read(2)*. Retrieved from https://man7.org/linux/man-pages/man2/read.2.html
Linux man-pages. (2025-c). *inotify(7)*. Retrieved from https://man7.org/linux/man-pages/man7/inotify.7.html
Python Software Foundation. (2025). *ctypes — A foreign function library for Python*. Retrieved from https://docs.python.org/3/library/ctypes.html
sunnypilot. (2025). *common/params.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/params.cc#L180C1-L206C2
sunnypilot. (2025). *common/util.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/util.cc#L79C1-L117C2

View File

View File

@@ -0,0 +1,193 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import os
import platform
import struct
import select
import threading
import time
import ctypes
import ctypes.util
import traceback
from ctypes import c_void_p, c_size_t, POINTER, c_uint32, c_uint64
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
IN_MODIFY = 0x00000002
IN_CREATE = 0x00000100
IN_DELETE = 0x00000200
IN_MOVED_TO = 0x00000080
IN_CLOSE_WRITE = 0x00000008
class ParamWatcher(Params):
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
super().__init__()
cloudlog.warning("ParamWatcher initialized")
self._cache = {}
self._last_trigger = {}
self._version = {}
self._lock = threading.Lock()
self._callbacks = []
self.last_accessed_param = None
self._initialized = True
self.start()
def start(self):
if getattr(self, '_thread', None) and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run_watcher, daemon=True)
self._thread.start()
def is_watching(self):
return getattr(self, '_thread', None) and self._thread.is_alive()
def add_watcher(self, callback):
if callback not in self._callbacks:
self._callbacks.append(callback)
def _trigger_callbacks(self, path):
with self._lock:
if (now := time.monotonic()) - self._last_trigger.get(path, 0) < 0.1:
return
self._last_trigger[path] = now
self._version[path] = self._version.get(path, 0) + 1
self._cache.pop(path, None)
for callback in self._callbacks:
try:
callback(path)
except Exception:
cloudlog.exception("Param watcher callback failed")
def _get_cached(self, key, getter, sig):
k = str(key)
with self._lock:
bucket = self._cache.get(k)
if bucket and sig in bucket:
if bucket[sig][0] == self._version.get(k, 0):
return bucket[sig][1]
start_ver = self._version.get(k, 0)
val = getter()
with self._lock:
if self._version.get(k, 0) != start_ver:
val = getter()
self._cache.setdefault(k, {})[sig] = (self._version.get(k, 0), val)
return val
def get(self, key, block=False, return_default=False):
self.last_accessed_param = key
if block:
return super().get(key, block, return_default)
fetcher = super().get
return self._get_cached(key, lambda: fetcher(key, block, return_default), (block, return_default))
def get_bool(self, key, block=False):
self.last_accessed_param = key
if block:
return super().get_bool(key, block)
fetcher = super().get_bool
return self._get_cached(key, lambda: fetcher(key, block), ("bool", block))
def _run_watcher(self):
system = platform.system()
while True:
try:
if system == "Linux":
self._run_linux()
elif system == "Darwin":
self._run_darwin()
except Exception:
cloudlog.exception("Param watcher crashed")
time.sleep(2)
def _run_linux(self):
path = Paths.params_root()
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
libc.inotify_add_watch(fd, path.encode(), IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE)
try:
poll = select.epoll()
poll.register(fd, select.EPOLLIN)
while True:
for fileno, _ in poll.poll():
if fileno == fd:
buffer = os.read(fd, 2048)
i = 0
while i + 16 <= len(buffer):
_, mask, _, name_len = struct.unpack_from("iIII", buffer, i)
if mask & (IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE):
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode()
if not name.startswith("."):
self._trigger_callbacks(name)
i += 16 + name_len
finally:
if 'poll' in locals():
poll.unregister(fd)
poll.close()
os.close(fd)
def _run_darwin(self):
CS = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreServices"))
CF = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
kCFAllocatorDefault = c_void_p(0)
kCFStringEncodingUTF8 = 0x08000100
kFSEventStreamCreateFlagFileEvents = 0x00000010
kFSEventStreamEventFlagItemCreated = 0x00000100
kFSEventStreamEventFlagItemRemoved = 0x00000200
kFSEventStreamEventFlagItemRenamed = 0x00000800
kFSEventStreamEventFlagItemModified = 0x00001000
CF.CFStringCreateWithCString.restype = c_void_p
CF.CFStringCreateWithCString.argtypes = [c_void_p, ctypes.c_char_p, c_uint32]
CF.CFArrayCreate.restype = c_void_p
CF.CFArrayCreate.argtypes = [c_void_p, POINTER(c_void_p), c_size_t, c_void_p]
CS.FSEventStreamCreate.restype = c_void_p
CS.FSEventStreamCreate.argtypes = [c_void_p, c_void_p, c_void_p, c_void_p, c_uint64, ctypes.c_double, c_uint32]
CS.FSEventStreamScheduleWithRunLoop.argtypes = [c_void_p, c_void_p, c_void_p]
CS.FSEventStreamStart.argtypes = [c_void_p]
CF.CFRunLoopGetCurrent.restype = c_void_p
def _cb(stream, ctx, num, paths, flags, ids):
try:
paths_arr = ctypes.cast(paths, POINTER(c_void_p))
flags_arr = ctypes.cast(flags, POINTER(c_uint32))
for i in range(num):
path = ctypes.cast(paths_arr[i], ctypes.c_char_p).value
if path and (flags_arr[i] & (kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemRemoved |
kFSEventStreamEventFlagItemRenamed | kFSEventStreamEventFlagItemModified)):
self._trigger_callbacks(os.path.basename(path.decode('utf-8').rstrip('/')))
except Exception:
traceback.print_exc()
self._darwin_cb = ctypes.CFUNCTYPE(None, c_void_p, c_void_p, c_size_t, c_void_p, POINTER(c_uint32), POINTER(c_uint64))(_cb)
path_str = Paths.params_root().encode('utf-8')
cf_path = CF.CFStringCreateWithCString(kCFAllocatorDefault, path_str, kCFStringEncodingUTF8)
cf_paths = CF.CFArrayCreate(kCFAllocatorDefault, (c_void_p * 1)(cf_path), 1, None)
stream = CS.FSEventStreamCreate(kCFAllocatorDefault, self._darwin_cb, None, cf_paths, -1, 0.05, kFSEventStreamCreateFlagFileEvents)
run_loop = CF.CFRunLoopGetCurrent()
kDefaultMode = CF.CFStringCreateWithCString(kCFAllocatorDefault, b"kCFRunLoopDefaultMode", kCFStringEncodingUTF8)
CS.FSEventStreamScheduleWithRunLoop(stream, run_loop, kDefaultMode)
CS.FSEventStreamStart(stream)
CF.CFRunLoopRun()

View File

@@ -0,0 +1,4 @@
from openpilot.common.params_pyx import ParamKeyFlag, ParamKeyType, UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher as Params
__all__ = ["Params", "ParamKeyFlag", "ParamKeyType", "UnknownKeyName"]

View File

View File

@@ -0,0 +1,94 @@
import time
import pytest
import threading
import tracemalloc
from openpilot.common.params import Params
from openpilot.common.params_pyx import UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher
class TestParamWatcher:
BYTES_KEYS = ["LocationFilterInitialState", "UpdaterCurrentReleaseNotes", "UpdaterNewReleaseNotes"]
BOOL_KEYS = [
"IsMetric", "AdbEnabled", "AlwaysOnDM", "ExperimentalMode",
"ExperimentalModeConfirmed", "DisengageOnAccelerator",
"OpenpilotEnabledToggle", "RecordAudio", "RecordFront"
]
_key_counter = 0
@pytest.fixture(autouse=True)
def setup_method(self):
self.params = Params()
self.key_index = TestParamWatcher._key_counter
TestParamWatcher._key_counter += 1
self.bytes_key = self.BYTES_KEYS[self.key_index % len(self.BYTES_KEYS)]
self.bool_key = self.BOOL_KEYS[self.key_index % len(self.BOOL_KEYS)]
@pytest.fixture
def param_watcher(self):
ParamWatcher._instance = None
param_watch = ParamWatcher()
param_watch.start()
assert param_watch.is_watching(), "ParamWatcher thread died"
return param_watch
def teardown_method(self):
for key in (self.bytes_key, self.bool_key):
try:
self.params.remove(key)
except UnknownKeyName:
pass
def test_watcher_detects_change(self, param_watcher):
val = b"123"
self.params.put(self.bytes_key, val)
assert param_watcher.get(self.bytes_key) == val
def test_watcher_get_bool(self, param_watcher):
self.params.put_bool(self.bool_key, True)
assert param_watcher.get_bool(self.bool_key) is True # First read should populate internal cache
def test_performance_comparison(self, param_watcher):
plain_params = self.params
for key in self.BYTES_KEYS:
plain_params.put(key, b"x" * 10000)
param_watcher.get(key)
for key in self.BOOL_KEYS:
plain_params.put_bool(key, True)
param_watcher.get_bool(key)
def bench(get_bytes, get_bool):
tracemalloc.start()
start_time = time.process_time()
for _ in range(1000):
for key in self.BYTES_KEYS:
get_bytes(key)
for key in self.BOOL_KEYS:
get_bool(key)
duration = time.process_time() - start_time
_, memory = tracemalloc.get_traced_memory()
tracemalloc.stop()
return duration, memory
plain_cpu, plain_memory = bench(plain_params.get, plain_params.get_bool)
watcher_cpu, watcher_memory = bench(param_watcher.get, param_watcher.get_bool)
# ParamWatcher *should* be significantly faster and use less memory than Params()
assert watcher_cpu < plain_cpu * 0.6, f"PW CPU ({watcher_cpu:.4f}s) should be < 60% of Param call ({plain_cpu:.4f}s)"
assert watcher_memory < plain_memory * 0.5, f"PW Memory ({watcher_memory}B) should be < 50% of Param call ({plain_memory}B)"
def test_cache_invalidation_simulation(self, param_watcher):
self.params.put(self.bytes_key, b"old")
assert param_watcher.get(self.bytes_key) == b"old"
time.sleep(0.2)
event = threading.Event()
param_watcher.add_watcher(lambda key: event.set())
param_watcher._trigger_callbacks(self.bytes_key)
assert event.wait(timeout=2), "Callback not triggered"
self.params.put(self.bytes_key, b"new")
assert param_watcher.get(self.bytes_key) == b"new"

View File

@@ -0,0 +1,5 @@
import os
from openpilot.common.basedir import BASEDIR
MAPD_BIN_DIR = os.path.join(BASEDIR, 'third_party/mapd_pfeiferj')
MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd')

View File

@@ -0,0 +1,22 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.common.swaglog import cloudlog
LOOK_AHEAD_HORIZON_TIME = 15. # s. Time horizon for look ahead of turn speed sections to provide on liveMapDataSP msg.
_DEBUG = False
_CLOUDLOG_DEBUG = False
ROAD_NAME_TIMEOUT = 30 # secs
R = 6373000.0 # approximate radius of earth in mts
QUERY_RADIUS = 3000 # mts. Radius to use on OSM data queries.
QUERY_RADIUS_OFFLINE = 2250 # mts. Radius to use on offline OSM data queries.
def get_debug(msg, log_to_cloud=True):
if _CLOUDLOG_DEBUG and log_to_cloud:
cloudlog.debug(msg)
if _DEBUG:
print(msg)

View File

@@ -0,0 +1,65 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from abc import abstractmethod, ABC
import cereal.messaging as messaging
from openpilot.common.params import Params
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot.navd.helpers import coordinate_from_param
MAX_SPEED_LIMIT = V_CRUISE_UNSET * CV.KPH_TO_MS
class BaseMapData(ABC):
def __init__(self):
self.params = Params()
self.sm = messaging.SubMaster(['liveLocationKalman'])
self.pm = messaging.PubMaster(['liveMapDataSP'])
self.localizer_valid = False
self.last_bearing = None
self.last_position = coordinate_from_param("LastGPSPositionLLK", self.params)
@abstractmethod
def update_location(self) -> None:
pass
@abstractmethod
def get_current_speed_limit(self) -> float:
pass
@abstractmethod
def get_next_speed_limit_and_distance(self) -> tuple[float, float]:
pass
@abstractmethod
def get_current_road_name(self) -> str:
pass
def publish(self) -> None:
speed_limit = self.get_current_speed_limit()
next_speed_limit, next_speed_limit_distance = self.get_next_speed_limit_and_distance()
mapd_sp_send = messaging.new_message('liveMapDataSP')
mapd_sp_send.valid = self.sm['liveLocationKalman'].gpsOK
live_map_data = mapd_sp_send.liveMapDataSP
live_map_data.speedLimitValid = bool(MAX_SPEED_LIMIT > speed_limit > 0)
live_map_data.speedLimit = speed_limit
live_map_data.speedLimitAheadValid = bool(MAX_SPEED_LIMIT > next_speed_limit > 0)
live_map_data.speedLimitAhead = next_speed_limit
live_map_data.speedLimitAheadDistance = next_speed_limit_distance
live_map_data.roadName = self.get_current_road_name()
self.pm.send('liveMapDataSP', mapd_sp_send)
def tick(self) -> None:
self.sm.update(0)
self.update_location()
self.publish()

View File

@@ -0,0 +1,56 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
# DISCLAIMER: This code is intended principally for development and debugging purposes.
# Although it provides a standalone entry point to the program, users should refer
# to the actual implementations for consumption. Usage outside of development scenarios
# is not advised and could lead to unpredictable results.
import threading
import traceback
from cereal import messaging
from openpilot.common.gps import get_gps_location_service
from openpilot.common.params import Params
from openpilot.common.realtime import config_realtime_process
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Policy
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_resolver import SpeedLimitResolver
from openpilot.sunnypilot.mapd.live_map_data import get_debug
def excepthook(args):
get_debug(f'MapD: Threading exception:\n{args}')
traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback)
def live_map_data_sp_thread():
config_realtime_process([0, 1, 2, 3], 5)
params = Params()
gps_location_service = get_gps_location_service(params)
while True:
live_map_data_sp_thread_debug(gps_location_service)
def live_map_data_sp_thread_debug(gps_location_service):
_sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service])
_sub_master.update()
v_ego = _sub_master['carState'].vEgo
_resolver = SpeedLimitResolver()
_resolver.policy = Policy.car_state_priority
_resolver.update(v_ego, _sub_master)
print(_resolver.speed_limit, _resolver.distance, _resolver.source)
def main():
threading.excepthook = excepthook
live_map_data_sp_thread()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import json
import math
import platform
from cereal import log
from openpilot.common.params import Params
from openpilot.sunnypilot.mapd.live_map_data.base_map_data import BaseMapData
from openpilot.sunnypilot.navd.helpers import Coordinate
class OsmMapData(BaseMapData):
def __init__(self):
super().__init__()
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
def update_location(self) -> None:
location = self.sm['liveLocationKalman']
self.localizer_valid = (location.status == log.LiveLocationKalman.Status.valid) and location.positionGeodetic.valid
if self.localizer_valid:
self.last_bearing = math.degrees(location.calibratedOrientationNED.value[2])
self.last_position = Coordinate(location.positionGeodetic.value[0], location.positionGeodetic.value[1])
if self.last_position is None:
return
params = {
"latitude": self.last_position.latitude,
"longitude": self.last_position.longitude,
}
if self.last_bearing is not None:
params['bearing'] = self.last_bearing
self.mem_params.put("LastGPSPosition", json.dumps(params))
def get_current_speed_limit(self) -> float:
return float(self.mem_params.get("MapSpeedLimit") or 0.0)
def get_current_road_name(self) -> str:
return str(self.mem_params.get("RoadName") or "")
def get_next_speed_limit_and_distance(self) -> tuple[float, float]:
next_speed_limit_section_str = self.mem_params.get("NextMapSpeedLimit")
next_speed_limit_section = next_speed_limit_section_str if next_speed_limit_section_str else {}
next_speed_limit = next_speed_limit_section.get('speedlimit', 0.0)
next_speed_limit_latitude = next_speed_limit_section.get('latitude')
next_speed_limit_longitude = next_speed_limit_section.get('longitude')
next_speed_limit_distance = 0.0
if next_speed_limit_latitude and next_speed_limit_longitude:
next_speed_limit_coordinates = Coordinate(next_speed_limit_latitude, next_speed_limit_longitude)
next_speed_limit_distance = (self.last_position or Coordinate(0, 0)).distance_to(next_speed_limit_coordinates)
return next_speed_limit, next_speed_limit_distance

View File

@@ -0,0 +1,42 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
# DISCLAIMER: This code is intended principally for development and debugging purposes.
# Although it provides a standalone entry point to the program, users should refer
# to the actual implementations for consumption. Usage outside of development scenarios
# is not advised and could lead to unpredictable results.
import threading
import traceback
from openpilot.common.realtime import Ratekeeper, config_realtime_process
from openpilot.sunnypilot.mapd.live_map_data import get_debug
from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData
def excepthook(args):
get_debug(f'MapD: Threading exception:\n{args}')
traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback)
def live_map_data_sp_thread():
config_realtime_process([0, 1, 2, 3], 5)
live_map_sp = OsmMapData()
rk = Ratekeeper(1, print_delay_threshold=None)
while True:
live_map_sp.tick()
rk.keep_time()
def main():
threading.excepthook = excepthook
live_map_data_sp_thread()
if __name__ == "__main__":
main()

154
sunnypilot/mapd/mapd_installer.py Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import logging
import os
import stat
import time
import traceback
import requests
from pathlib import Path
from urllib.request import urlopen
from cereal import messaging
from openpilot.common.params import Params
from openpilot.system.hardware.hw import Paths
from openpilot.common.spinner import Spinner
from openpilot.system.version import is_prebuilt
from openpilot.sunnypilot.mapd import MAPD_PATH, MAPD_BIN_DIR
import openpilot.system.sentry as sentry
VERSION = "v1.12.0"
URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd"
def update_installed_version(version: str, params: Params = None) -> None:
if params is None:
params = Params()
params.put("MapdVersion", version)
class MapdInstallManager:
def __init__(self, spinner_ref: Spinner):
self._spinner = spinner_ref
self._params = Params()
def download(self) -> None:
self.ensure_directories_exist()
self._download_file()
update_installed_version(VERSION, self._params)
def check_and_download(self) -> None:
if self.download_needed():
self.download()
def download_needed(self) -> bool:
return not os.path.exists(MAPD_PATH) or self.get_installed_version() != VERSION
@staticmethod
def ensure_directories_exist() -> None:
if not os.path.exists(Paths.mapd_root()):
os.makedirs(Paths.mapd_root())
if not os.path.exists(MAPD_BIN_DIR):
os.makedirs(MAPD_BIN_DIR)
@staticmethod
def _safe_write_and_set_executable(file_path: Path, content: bytes) -> None:
with open(file_path, 'wb') as output:
output.write(content)
output.flush()
os.fsync(output.fileno())
current_permissions = stat.S_IMODE(os.lstat(file_path).st_mode)
os.chmod(file_path, current_permissions | stat.S_IEXEC)
def _download_file(self, num_retries=5) -> None:
temp_file = Path(MAPD_PATH + ".tmp")
download_timeout = 60
for cnt in range(num_retries):
try:
response = requests.get(URL, stream=True, timeout=download_timeout)
response.raise_for_status()
self._safe_write_and_set_executable(temp_file, response.content)
# No exceptions encountered. Safe to replace original file.
temp_file.replace(MAPD_PATH)
return
except requests.exceptions.ReadTimeout:
self._spinner.update(f"ReadTimeout caught. Timeout is [{download_timeout}]. Retrying download... [{cnt}]")
time.sleep(0.5)
except requests.exceptions.RequestException as e:
self._spinner.update(f"RequestException caught: {e}. Retrying download... [{cnt}]")
time.sleep(0.5)
# Delete temp file if the process was not successful.
if temp_file.exists():
temp_file.unlink()
logging.error("Failed to download file after all retries")
def get_installed_version(self) -> str:
return str(self._params.get("MapdVersion") or "")
def wait_for_internet_connection(self, return_on_failure: bool = False) -> bool:
max_retries = 10
for retries in range(max_retries + 1):
self._spinner.update(f"Waiting for internet connection... [{retries}/{max_retries}]")
time.sleep(2)
try:
_ = urlopen('https://sentry.io', timeout=10)
return True
except Exception as e:
print(f'Wait for internet failed: {e}')
if return_on_failure and retries == max_retries:
return False
return False
def non_prebuilt_install(self) -> None:
sm = messaging.SubMaster(['deviceState'])
metered = sm['deviceState'].networkMetered
if metered:
self._spinner.update("Can't proceed with mapd install since network is metered!")
time.sleep(5)
return
try:
self.ensure_directories_exist()
if not self.download_needed():
self._spinner.update("Mapd is good!")
time.sleep(0.1)
return
if self.wait_for_internet_connection(return_on_failure=True):
self._spinner.update(f"Downloading pfeiferj's mapd [{self.get_installed_version()}] => [{VERSION}].")
time.sleep(0.1)
self.check_and_download()
self._spinner.close()
except Exception:
for i in range(6):
self._spinner.update("Failed to download OSM maps won't work until properly downloaded!" +
"Try again manually rebooting. " +
f"Boot will continue in {5 - i}s...")
time.sleep(1)
sentry.init(sentry.SentryProject.SELFDRIVE)
traceback.print_exc()
sentry.capture_exception()
if __name__ == "__main__":
spinner = Spinner()
install_manager = MapdInstallManager(spinner)
install_manager.ensure_directories_exist()
if is_prebuilt():
debug_msg = f"[DEBUG] This is prebuilt, no mapd install required. VERSION: [{VERSION}], Param [{install_manager.get_installed_version()}]"
spinner.update(debug_msg)
update_installed_version(VERSION)
else:
spinner.update(f"Checking if mapd is installed and valid. Prebuilt [{is_prebuilt()}]")
install_manager.non_prebuilt_install()

144
sunnypilot/mapd/mapd_manager.py Executable file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import json
import platform
import os
import glob
import shutil
from datetime import datetime
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper, config_realtime_process
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData
from openpilot.system.hardware.hw import Paths
from openpilot.sunnypilot.mapd import MAPD_PATH
from openpilot.sunnypilot.mapd.mapd_installer import VERSION, update_installed_version
# PFEIFER - MAPD {{
params = Params()
mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params
# }} PFEIFER - MAPD
def get_files_for_cleanup() -> list[str]:
paths = [
f"{Paths.mapd_root()}/db",
f"{Paths.mapd_root()}/v*"
]
files_to_remove = []
for path in paths:
if os.path.exists(path):
files = glob.glob(path + '/**', recursive=True)
files_to_remove.extend(files)
# check for version and mapd files
if not os.path.isfile(MAPD_PATH):
files_to_remove.append(MAPD_PATH)
return files_to_remove
def cleanup_old_osm_data(files_to_remove: list[str]) -> None:
for file in files_to_remove:
# Remove trailing slash if path is file
if file.endswith('/') and os.path.isfile(file[:-1]):
file = file[:-1]
# Try to remove as file or symbolic link first
if os.path.islink(file) or os.path.isfile(file):
os.remove(file)
elif os.path.isdir(file): # If it's a directory
shutil.rmtree(file, ignore_errors=False)
def request_refresh_osm_location_data(nations: list[str], states: list[str] = None) -> None:
params.put("OsmDownloadedDate", str(datetime.now().timestamp()))
params.put_bool("OsmDbUpdatesCheck", False)
osm_download_locations = {
"nations": nations,
"states": states or []
}
print(f"Downloading maps for {json.dumps(osm_download_locations)}")
mem_params.put("OSMDownloadLocations", osm_download_locations)
def filter_nations_and_states(nations: list[str], states: list[str] = None) -> tuple[list[str], list[str]]:
"""Filters and prepares nation and state data for OSM map download.
If the nation is 'US' and a specific state is provided, the nation 'US' is removed from the list.
If the nation is 'US' and the state is 'All', the 'All' is removed from the list.
The idea behind these filters is that if a specific state in the US is provided,
there's no need to download map data for the entire US. Conversely,
if the state is unspecified (i.e., 'All'), we intend to download map data for the whole US,
and 'All' isn't a valid state name, so it's removed.
Parameters:
nations (list): A list of nations for which the map data is to be downloaded.
states (list, optional): A list of states for which the map data is to be downloaded. Defaults to None.
Returns:
tuple: Two lists. The first list is filtered nations and the second list is filtered states.
"""
if "US" in nations and states and not any(x.lower() == "all" for x in states):
# If a specific state in the US is provided, remove 'US' from nations
nations.remove("US")
elif "US" in nations and states and any(x.lower() == "all" for x in states):
# If 'All' is provided as a state (case invariant), remove those instances from states
states = [x for x in states if x.lower() != "all"]
elif "US" not in nations and states and any(x.lower() == "all" for x in states):
states.remove("All")
return nations, states or []
def update_osm_db() -> None:
if params.get_bool("OsmDbUpdatesCheck"):
cleanup_old_osm_data(get_files_for_cleanup())
country = params.get("OsmLocationName", return_default=True)
state = params.get("OsmStateName", return_default=True)
filtered_nations, filtered_states = filter_nations_and_states([country], [state])
request_refresh_osm_location_data(filtered_nations, filtered_states)
if not mem_params.get("OSMDownloadBounds"):
mem_params.put("OSMDownloadBounds", "")
if not mem_params.get("LastGPSPosition"):
mem_params.put("LastGPSPosition", "{}")
def main_thread():
update_installed_version(VERSION, params)
config_realtime_process([0, 1, 2, 3], 5)
rk = Ratekeeper(1, print_delay_threshold=None)
live_map_sp = OsmMapData()
# Create folder needed for OSM
try:
os.mkdir(Paths.mapd_root())
except FileExistsError:
pass
except PermissionError:
cloudlog.exception(f"mapd: failed to make {Paths.mapd_root()}")
while True:
show_alert = get_files_for_cleanup() and params.get_bool("OsmLocal")
set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.")
update_osm_db()
live_map_sp.tick()
rk.keep_time()
def main():
main_thread()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1 @@
fdb3b49ee19956e6ce09fdc3373cbba557f1263b2180e9f344c1d4053852284b

View File

@@ -0,0 +1,19 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.sunnypilot import get_file_hash
from openpilot.sunnypilot.mapd import MAPD_PATH
from openpilot.sunnypilot.mapd.update_version import MAPD_HASH_PATH
class TestMapdVersion:
def test_compare_versions(self):
mapd_hash = get_file_hash(MAPD_PATH)
with open(MAPD_HASH_PATH) as f:
current_hash = f.read().strip()
assert current_hash == mapd_hash, "Run sunnypilot/mapd/update_version.py to update the current mapd version and hash"

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import argparse
import os
import re
from openpilot.sunnypilot import get_file_hash
from openpilot.common.basedir import BASEDIR
from openpilot.sunnypilot.mapd import MAPD_PATH
MAPD_HASH_PATH = os.path.join(BASEDIR, "sunnypilot", "mapd", "tests", "mapd_hash")
MAPD_VERSION_PATH = os.path.join(BASEDIR, "sunnypilot", "mapd", "mapd_installer.py")
def update_mapd_hash():
mapd_hash = get_file_hash(MAPD_PATH)
with open(MAPD_HASH_PATH, "w") as f:
f.write(mapd_hash)
print(f"Generated and updated new mapd hash to {MAPD_HASH_PATH}")
def get_current_mapd_version(path: str) -> str:
print("[GET CURRENT MAPD VERSION]")
with open(path) as f:
for line in f:
if line.strip().startswith("VERSION"):
# Match VERSION = 'v1.11.0' or VERSION="v1.11.0" (with optional spaces)
match = re.search(r'VERSION\s*=\s*[\'"]([^\'"]+)[\'"]', line)
if match:
ver = match.group(1)
print(f'Current mapd version: "{ver}"')
return ver
else:
print("[ERROR] VERSION line found but no quoted value detected.")
return ""
print("[ERROR] VERSION not found in file!")
return ""
def update_mapd_version(ver: str, path: str):
print("[CHANGE CURRENT MAPD VERSION]")
with open(path) as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
if not found and line.startswith("VERSION ="):
new_lines.append(f'VERSION = "{ver}"\n')
found = True
new_lines.extend(lines[lines.index(line) + 1:])
break
else:
new_lines.append(line)
if not found:
print("[ERROR] VERSION line not found! Aborting without writing.")
return
with open(path, "w") as f:
f.writelines(new_lines)
print(f'New mapd version: "{ver}"')
print("[DONE]")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update mapd version and hash")
parser.add_argument("--new_ver", type=str, help="New mapd version")
args = parser.parse_args()
if not args.new_ver:
print("Warning: No new mapd version provided. Use --new_ver to specify")
print("Example:")
print(" python sunnypilot/mapd/update_version.py --new_ver \"v1.12.0\"")
print("Current mapd version and hash will not be updated! (aborted)")
exit(0)
current_ver = get_current_mapd_version(MAPD_VERSION_PATH)
new_ver = f"{args.new_ver}"
if current_ver == new_ver:
print(f'Proposed mapd version: "{new_ver}"')
confirm = input("Proposed mapd version is the same as the current mapd version. Confirm? (y/n): ").upper().strip()
if confirm != "Y":
print("Current mapd version and hash will not be updated! (aborted)")
exit(0)
update_mapd_version(new_ver, MAPD_VERSION_PATH)
update_mapd_hash()

View File

View File

@@ -6,12 +6,16 @@ See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
from cereal import car
from cereal import car, custom
from opendbc.car import structs
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.helpers import get_minimum_set_speed
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import ACTIVE_STATES as SLA_ACTIVE_STATES
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import compare_cluster_target
ButtonType = car.CarState.ButtonEvent.Type
SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState
CRUISE_BUTTON_TIMER = {ButtonType.decelCruise: 0, ButtonType.accelCruise: 0,
ButtonType.setCruise: 0, ButtonType.resumeCruise: 0,
@@ -50,6 +54,16 @@ class VCruiseHelperSP:
self.enable_button_timers = CRUISE_BUTTON_TIMER
# Speed Limit Assist
self.sla_state = SpeedLimitAssistState.disabled
self.prev_sla_state = SpeedLimitAssistState.disabled
self.has_speed_limit = False
self.speed_limit_final_last = 0.
self.speed_limit_final_last_kph = 0.
self.prev_speed_limit_final_last_kph = 0.
self.req_plus = False
self.req_minus = False
def read_custom_set_speed_params(self) -> None:
self.custom_acc_enabled = self.params.get_bool("CustomAccIncrementsEnabled")
self.short_increment = self.params.get("CustomAccShortPressIncrement", return_default=True)
@@ -92,3 +106,33 @@ class VCruiseHelperSP:
return enabled and self.enabled_prev
return enabled
def update_speed_limit_assist(self, is_metric, LP_SP: custom.LongitudinalPlanSP) -> None:
resolver = LP_SP.speedLimit.resolver
self.has_speed_limit = resolver.speedLimitValid or resolver.speedLimitLastValid
self.speed_limit_final_last = LP_SP.speedLimit.resolver.speedLimitFinalLast
self.speed_limit_final_last_kph = self.speed_limit_final_last * CV.MS_TO_KPH
self.sla_state = LP_SP.speedLimit.assist.state
self.req_plus, self.req_minus = compare_cluster_target(self.v_cruise_cluster_kph * CV.KPH_TO_MS,
self.speed_limit_final_last, is_metric)
@property
def update_speed_limit_final_last_changed(self) -> bool:
return self.has_speed_limit and bool(self.speed_limit_final_last_kph != self.prev_speed_limit_final_last_kph)
def update_speed_limit_assist_pre_active_confirmed(self, button_type: car.CarState.ButtonEvent.Type) -> bool:
if self.sla_state == SpeedLimitAssistState.preActive or self.prev_sla_state == SpeedLimitAssistState.preActive:
if button_type == ButtonType.decelCruise and self.req_minus:
return True
if button_type == ButtonType.accelCruise and self.req_plus:
return True
return False
def update_speed_limit_assist_v_cruise_non_pcm(self) -> None:
if self.sla_state in SLA_ACTIVE_STATES and (self.prev_sla_state not in SLA_ACTIVE_STATES or
self.update_speed_limit_final_last_changed):
self.v_cruise_kph = np.clip(round(self.speed_limit_final_last_kph, 1), self.v_cruise_min, V_CRUISE_MAX)
self.prev_sla_state = self.sla_state
self.prev_speed_limit_final_last_kph = self.speed_limit_final_last_kph

View File

@@ -11,6 +11,7 @@ from opendbc.car.interfaces import CarInterfaceBase
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_speed_limit_assist_availability
import openpilot.system.sentry as sentry
@@ -85,6 +86,10 @@ def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsS
cloudlog.warning("openpilot Longitudinal Control and ICBM not available, cleaning up params")
params.remove("DynamicExperimentalControl")
params.remove("CustomAccIncrementsEnabled")
params.remove("SmartCruiseControlVision")
params.remove("SmartCruiseControlMap")
set_speed_limit_assist_availability(CP, CP_SP, params)
def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None:

View File

@@ -0,0 +1,9 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.common.constants import CV
MIN_V = 20 * CV.KPH_TO_MS # Do not operate under 20 km/h

View File

@@ -0,0 +1,261 @@
import json
import math
import platform
from cereal import custom
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.navd.helpers import coordinate_from_param, Coordinate
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState
ACTIVE_STATES = (MapState.turning, )
ENABLED_STATES = (MapState.enabled, MapState.overriding, *ACTIVE_STATES)
R = 6373000.0 # approximate radius of earth in meters
TO_RADIANS = math.pi / 180
TO_DEGREES = 180 / math.pi
TARGET_JERK = -0.6 # m/s^3 There's some jounce limits that are not consistent so we're fudging this some
TARGET_ACCEL = -1.2 # m/s^2 should match up with the long planner limit
TARGET_OFFSET = 1.0 # seconds - This controls how soon before the curve you reach the target velocity. It also helps
# reach the target velocity when inaccuracies in the distance modeling logic would cause overshoot.
# The value is multiplied against the target velocity to determine the additional distance. This is
# done to keep the distance calculations consistent but results in the offset actually being less
# time than specified depending on how much of a speed differential there is between v_ego and the
# target velocity.
def velocities_from_param(param: str, params: Params):
if params is None:
params = Params()
json_str = params.get(param)
if json_str is None:
return None
velocities = json.loads(json_str)
return velocities
def calculate_accel(t, target_jerk, a_ego):
return a_ego + target_jerk * t
def calculate_velocity(t, target_jerk, a_ego, v_ego):
return v_ego + a_ego * t + target_jerk/2 * (t ** 2)
def calculate_distance(t, target_jerk, a_ego, v_ego):
return t * v_ego + a_ego/2 * (t ** 2) + target_jerk/6 * (t ** 3)
# points should be in radians
# output is meters
def distance_to_point(ax, ay, bx, by):
a = math.sin((bx-ax)/2)*math.sin((bx-ax)/2) + math.cos(ax) * math.cos(bx)*math.sin((by-ay)/2)*math.sin((by-ay)/2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return R * c # in meters
class SmartCruiseControlMap:
v_target: float = 0
a_target: float = 0.
v_ego: float = 0.
a_ego: float = 0.
output_v_target: float = V_CRUISE_UNSET
output_a_target: float = 0.
def __init__(self):
self.params = Params()
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
self.enabled = self.params.get_bool("SmartCruiseControlMap")
self.long_enabled = False
self.long_override = False
self.is_enabled = False
self.is_active = False
self.state = MapState.disabled
self.v_cruise = 0
self.target_lat = 0.0
self.target_lon = 0.0
self.frame = -1
self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0)
self.target_velocities = velocities_from_param("MapTargetVelocities", self.mem_params) or []
def get_v_target_from_control(self) -> float:
if self.is_active:
return max(self.v_target, MIN_V)
return V_CRUISE_UNSET
def get_a_target_from_control(self) -> float:
return self.a_ego
def update_params(self):
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.enabled = self.params.get_bool("SmartCruiseControlMap")
def update_calculations(self) -> None:
self.last_position = coordinate_from_param("LastGPSPosition", self.mem_params) or Coordinate(0.0, 0.0)
lat = self.last_position.latitude
lon = self.last_position.longitude
self.target_velocities = velocities_from_param("MapTargetVelocities", self.mem_params) or []
if self.last_position is None or self.target_velocities is None:
return
min_dist = 1000
min_idx = 0
distances = []
# find our location in the path
for i in range(len(self.target_velocities)):
target_velocity = self.target_velocities[i]
tlat = target_velocity["latitude"]
tlon = target_velocity["longitude"]
d = distance_to_point(lat * TO_RADIANS, lon * TO_RADIANS, tlat * TO_RADIANS, tlon * TO_RADIANS)
distances.append(d)
if d < min_dist:
min_dist = d
min_idx = i
# only look at values from our current position forward
forward_points = self.target_velocities[min_idx:]
forward_distances = distances[min_idx:]
# find velocities that we are within the distance we need to adjust for
valid_velocities = []
for i in range(len(forward_points)):
target_velocity = forward_points[i]
tlat = target_velocity["latitude"]
tlon = target_velocity["longitude"]
tv = target_velocity["velocity"]
if tv > self.v_ego:
continue
d = forward_distances[i]
a_diff = (self.a_ego - TARGET_ACCEL)
accel_t = abs(a_diff / TARGET_JERK)
min_accel_v = calculate_velocity(accel_t, TARGET_JERK, self.a_ego, self.v_ego)
max_d = 0
if tv > min_accel_v:
# calculate time needed based on target jerk
a = 0.5 * TARGET_JERK
b = self.a_ego
c = self.v_ego - tv
t_a = -1 * ((b**2 - 4 * a * c) ** 0.5 + b) / 2 * a
t_b = ((b**2 - 4 * a * c) ** 0.5 - b) / 2 * a
if not isinstance(t_a, complex) and t_a > 0:
t = t_a
else:
t = t_b
if isinstance(t, complex):
continue
max_d = max_d + calculate_distance(t, TARGET_JERK, self.a_ego, self.v_ego)
else:
t = accel_t
max_d = calculate_distance(t, TARGET_JERK, self.a_ego, self.v_ego)
# calculate additional time needed based on target accel
t = abs((min_accel_v - tv) / TARGET_ACCEL)
max_d += calculate_distance(t, 0, TARGET_ACCEL, min_accel_v)
if d < max_d + tv * TARGET_OFFSET:
valid_velocities.append((float(tv), tlat, tlon))
# Find the smallest velocity we need to adjust for
min_v = 100.0
target_lat = 0.0
target_lon = 0.0
for tv, lat, lon in valid_velocities:
if tv < min_v:
min_v = tv
target_lat = lat
target_lon = lon
if self.v_target < min_v and not (self.target_lat == 0 and self.target_lon == 0):
for i in range(len(forward_points)):
target_velocity = forward_points[i]
tlat = target_velocity["latitude"]
tlon = target_velocity["longitude"]
tv = target_velocity["velocity"]
if tv > self.v_ego:
continue
if tlat == self.target_lat and tlon == self.target_lon and tv == self.v_target:
return
# not found so let's reset
self.v_target = 0.0
self.target_lat = 0.0
self.target_lon = 0.0
self.v_target = min_v
self.target_lat = target_lat
self.target_lon = target_lon
def _update_state_machine(self) -> tuple[bool, bool]:
# ENABLED, TURNING
if self.state != MapState.disabled:
if not self.long_enabled or not self.enabled:
self.state = MapState.disabled
elif self.long_override:
self.state = MapState.overriding
else:
# ENABLED
if self.state == MapState.enabled:
if self.v_cruise > self.v_target != 0:
self.state = MapState.turning
# TURNING
elif self.state == MapState.turning:
if self.v_cruise <= self.v_target or self.v_target == 0:
self.state = MapState.enabled
# OVERRIDING
elif self.state == MapState.overriding:
if not self.long_override:
if self.v_cruise > self.v_target != 0:
self.state = MapState.turning
else:
self.state = MapState.enabled
# DISABLED
elif self.state == MapState.disabled:
if self.long_enabled and self.enabled:
if self.long_override:
self.state = MapState.overriding
else:
self.state = MapState.enabled
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
return enabled, active
def update(self, long_enabled: bool, long_override: bool, v_ego, a_ego, v_cruise) -> None:
self.long_enabled = long_enabled
self.long_override = long_override
self.v_ego = v_ego
self.a_ego = a_ego
self.v_cruise = v_cruise
self.update_params()
self.update_calculations()
self.is_enabled, self.is_active = self._update_state_machine()
self.output_v_target = self.get_v_target_from_control()
self.output_a_target = self.get_a_target_from_control()
self.frame += 1

View File

@@ -0,0 +1,19 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import cereal.messaging as messaging
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import SmartCruiseControlMap
class SmartCruiseControl:
def __init__(self):
self.vision = SmartCruiseControlVision()
self.map = SmartCruiseControlMap()
def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, v_cruise: float) -> None:
self.map.update(long_enabled, long_override, v_ego, a_ego, v_cruise)
self.vision.update(sm, long_enabled, long_override, v_ego, a_ego, v_cruise)

View File

@@ -0,0 +1,58 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import platform
from cereal import custom
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import SmartCruiseControlMap
MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState
class TestSmartCruiseControlMap:
def setup_method(self):
self.params = Params()
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
self.reset_params()
self.scc_m = SmartCruiseControlMap()
def reset_params(self):
self.params.put_bool("SmartCruiseControlMap", True)
# TODO-SP: mock data from gpsLocation
self.params.put("LastGPSPosition", "{}")
self.params.put("MapTargetVelocities", "{}")
def test_initial_state(self):
assert self.scc_m.state == VisionState.disabled
assert not self.scc_m.is_active
assert self.scc_m.output_v_target == V_CRUISE_UNSET
assert self.scc_m.output_a_target == 0.
def test_system_disabled(self):
self.params.put_bool("SmartCruiseControlMap", False)
self.scc_m.enabled = self.params.get_bool("SmartCruiseControlMap")
for _ in range(int(10. / DT_MDL)):
self.scc_m.update(True, False, 0., 0., 0.)
assert self.scc_m.state == VisionState.disabled
assert not self.scc_m.is_active
def test_disabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_m.update(False, False, 0., 0., 0.)
assert self.scc_m.state == VisionState.disabled
def test_transition_disabled_to_enabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_m.update(True, False, 0., 0., 0.)
assert self.scc_m.state == VisionState.enabled
# TODO-SP: mock data from modelV2 to test other states

View File

@@ -0,0 +1,104 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
import cereal.messaging as messaging
from cereal import custom, log
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision
VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState
def generate_modelV2():
model = messaging.new_message('modelV2')
position = log.XYZTData.new_message()
speed = 30
position.x = [float(x) for x in (speed + 0.5) * np.array(ModelConstants.T_IDXS)]
model.modelV2.position = position
orientation = log.XYZTData.new_message()
curvature = 0.05
orientation.x = [float(curvature) for _ in ModelConstants.T_IDXS]
orientation.y = [0.0 for _ in ModelConstants.T_IDXS]
model.modelV2.orientation = orientation
orientationRate = log.XYZTData.new_message()
orientationRate.z = [float(z) for z in ModelConstants.T_IDXS]
model.modelV2.orientationRate = orientationRate
velocity = log.XYZTData.new_message()
velocity.x = [float(x) for x in (speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)]
velocity.x[0] = float(speed) # always start at current speed
model.modelV2.velocity = velocity
acceleration = log.XYZTData.new_message()
acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)]
acceleration.y = [float(y) for y in np.zeros_like(ModelConstants.T_IDXS)]
model.modelV2.acceleration = acceleration
return model
def generate_carState():
car_state = messaging.new_message('carState')
speed = 30
v_cruise = 50
car_state.carState.vEgo = float(speed)
car_state.carState.standstill = False
car_state.carState.vCruise = float(v_cruise * 3.6)
return car_state
def generate_controlsState():
controls_state = messaging.new_message('controlsState')
controls_state.controlsState.curvature = 0.05
return controls_state
class TestSmartCruiseControlVision:
def setup_method(self):
self.params = Params()
self.reset_params()
self.scc_v = SmartCruiseControlVision()
mdl = generate_modelV2()
cs = generate_carState()
controls_state = generate_controlsState()
self.sm = {'modelV2': mdl.modelV2, 'carState': cs.carState, 'controlsState': controls_state.controlsState}
def reset_params(self):
self.params.put_bool("SmartCruiseControlVision", True)
def test_initial_state(self):
assert self.scc_v.state == VisionState.disabled
assert not self.scc_v.is_active
assert self.scc_v.output_v_target == V_CRUISE_UNSET
assert self.scc_v.output_a_target == 0.
def test_system_disabled(self):
self.params.put_bool("SmartCruiseControlVision", False)
self.scc_v.enabled = self.params.get_bool("SmartCruiseControlVision")
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
assert self.scc_v.state == VisionState.disabled
assert not self.scc_v.is_active
def test_disabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, False, False, 0., 0., 0.)
assert self.scc_v.state == VisionState.disabled
def test_transition_disabled_to_enabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
assert self.scc_v.state == VisionState.enabled
# TODO-SP: mock modelV2 data to test other states

View File

@@ -0,0 +1,203 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
import cereal.messaging as messaging
from cereal import custom
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState
ACTIVE_STATES = (VisionState.entering, VisionState.turning, VisionState.leaving)
ENABLED_STATES = (VisionState.enabled, VisionState.overriding, *ACTIVE_STATES)
_ENTERING_PRED_LAT_ACC_TH = 1.3 # Predicted Lat Acc threshold to trigger entering turn state.
_ABORT_ENTERING_PRED_LAT_ACC_TH = 1.1 # Predicted Lat Acc threshold to abort entering state if speed drops.
_TURNING_LAT_ACC_TH = 1.6 # Lat Acc threshold to trigger turning state.
_LEAVING_LAT_ACC_TH = 1.3 # Lat Acc threshold to trigger leaving turn state.
_FINISH_LAT_ACC_TH = 1.1 # Lat Acc threshold to trigger the end of the turn cycle.
_A_LAT_REG_MAX = 2. # Maximum lateral acceleration
_NO_OVERSHOOT_TIME_HORIZON = 4. # s. Time to use for velocity desired based on a_target when not overshooting.
# Lookup table for the minimum smooth deceleration during the ENTERING state
# depending on the actual maximum absolute lateral acceleration predicted on the turn ahead.
_ENTERING_SMOOTH_DECEL_V = [-0.2, -1.] # min decel value allowed on ENTERING state
_ENTERING_SMOOTH_DECEL_BP = [1.3, 3.] # absolute value of lat acc ahead
# Lookup table for the acceleration for the TURNING state
# depending on the current lateral acceleration of the vehicle.
_TURNING_ACC_V = [0.5, 0., -0.4] # acc value
_TURNING_ACC_BP = [1.5, 2.3, 3.] # absolute value of current lat acc
_LEAVING_ACC = 0.5 # Conformable acceleration to regain speed while leaving a turn.
class SmartCruiseControlVision:
v_target: float = 0
a_target: float = 0.
v_ego: float = 0.
a_ego: float = 0.
output_v_target: float = V_CRUISE_UNSET
output_a_target: float = 0.
def __init__(self):
self.params = Params()
self.frame = -1
self.long_enabled = False
self.long_override = False
self.is_enabled = False
self.is_active = False
self.enabled = self.params.get_bool("SmartCruiseControlVision")
self.v_cruise_setpoint = 0.
self.state = VisionState.disabled
self.current_lat_acc = 0.
self.max_pred_lat_acc = 0.
def get_a_target_from_control(self) -> float:
return self.a_target
def get_v_target_from_control(self) -> float:
if self.is_active:
return max(self.v_target, MIN_V) + self.a_target * _NO_OVERSHOOT_TIME_HORIZON
return V_CRUISE_UNSET
def _update_params(self) -> None:
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.enabled = self.params.get_bool("SmartCruiseControlVision")
def _update_calculations(self, sm: messaging.SubMaster) -> None:
if not self.long_enabled:
return
else:
rate_plan = np.array(np.abs(sm['modelV2'].orientationRate.z))
vel_plan = np.array(sm['modelV2'].velocity.x)
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
# get the maximum lat accel from the model
predicted_lat_accels = rate_plan * vel_plan
self.max_pred_lat_acc = np.amax(predicted_lat_accels)
# get the maximum curve based on the current velocity
v_ego = max(self.v_ego, 0.1) # ensure a value greater than 0 for calculations
max_curve = self.max_pred_lat_acc / (v_ego**2)
# Get the target velocity for the maximum curve
self.v_target = (_A_LAT_REG_MAX / max_curve) ** 0.5
def _update_state_machine(self) -> tuple[bool, bool]:
# ENABLED, ENTERING, TURNING, LEAVING, OVERRIDING
if self.state != VisionState.disabled:
# longitudinal and feature disable always have priority in a non-disabled state
if not self.long_enabled or not self.enabled:
self.state = VisionState.disabled
elif self.long_override:
self.state = VisionState.overriding
else:
# ENABLED
if self.state == VisionState.enabled:
# Do not enter a turn control cycle if the speed is low.
if self.v_ego <= MIN_V:
pass
# If significant lateral acceleration is predicted ahead, then move to Entering turn state.
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.entering
# OVERRIDING
elif self.state == VisionState.overriding:
if not self.long_override:
self.state = VisionState.enabled
# ENTERING
elif self.state == VisionState.entering:
# Transition to Turning if current lateral acceleration is over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Abort if the predicted lateral acceleration drops
elif self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.enabled
# TURNING
elif self.state == VisionState.turning:
# Transition to Leaving if current lateral acceleration drops below a threshold.
if self.current_lat_acc <= _LEAVING_LAT_ACC_TH:
self.state = VisionState.leaving
# LEAVING
elif self.state == VisionState.leaving:
# Transition back to Turning if current lateral acceleration goes back over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Finish if current lateral acceleration goes below a threshold.
elif self.current_lat_acc < _FINISH_LAT_ACC_TH:
self.state = VisionState.enabled
# DISABLED
elif self.state == VisionState.disabled:
if self.long_enabled and self.enabled:
if self.long_override:
self.state = VisionState.overriding
else:
self.state = VisionState.enabled
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
return enabled, active
def _update_solution(self) -> float:
# DISABLED, ENABLED, OVERRIDING
if self.state not in ACTIVE_STATES:
# when not overshooting, calculate v_turn as the speed at the prediction horizon when following
# the smooth deceleration.
a_target = self.a_ego
# ENTERING
elif self.state == VisionState.entering:
# when not overshooting, target a smooth deceleration in preparation for a sharp turn to come.
a_target = np.interp(self.max_pred_lat_acc, _ENTERING_SMOOTH_DECEL_BP, _ENTERING_SMOOTH_DECEL_V)
# TURNING
elif self.state == VisionState.turning:
# When turning, we provide a target acceleration that is comfortable for the lateral acceleration felt.
a_target = np.interp(self.current_lat_acc, _TURNING_ACC_BP, _TURNING_ACC_V)
# LEAVING
elif self.state == VisionState.leaving:
# When leaving, we provide a comfortable acceleration to regain speed.
a_target = _LEAVING_ACC
else:
raise NotImplementedError(f"SCC-V state not supported: {self.state}")
return a_target
def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float,
v_cruise_setpoint: float) -> None:
self.long_enabled = long_enabled
self.long_override = long_override
self.v_ego = v_ego
self.a_ego = a_ego
self.v_cruise_setpoint = v_cruise_setpoint
self._update_params()
self._update_calculations(sm)
self.is_enabled, self.is_active = self._update_state_machine()
self.a_target = self._update_solution()
self.output_v_target = self.get_v_target_from_control()
self.output_a_target = self.get_a_target_from_control()
self.frame += 1

View File

@@ -0,0 +1,19 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
LIMIT_ADAPT_ACC = -1. # m/s^2 Ideal acceleration for the adapting (braking) phase when approaching speed limits.
LIMIT_MAX_MAP_DATA_AGE = 10. # s Maximum time to hold to map data, then consider it invalid inside limits controllers.
# Speed Limit Assist constants
PCM_LONG_REQUIRED_MAX_SET_SPEED = {
True: (33.3333, 36.1111), # km/h, (120, 130)
False: (31.2928, 35.7632), # mph, (70, 80)
}
CONFIRM_SPEED_THRESHOLD = {
True: 80, # km/h
False: 50, # mph
}

View File

@@ -0,0 +1,29 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.sunnypilot import IntEnumBase
class Policy(IntEnumBase):
car_state_only = 0
map_data_only = 1
car_state_priority = 2
map_data_priority = 3
combined = 4
class OffsetType(IntEnumBase):
off = 0
fixed = 1
percentage = 2
class Mode(IntEnumBase):
off = 0
information = 1
warning = 2
assist = 3

View File

@@ -0,0 +1,44 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from cereal import custom, car
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode
def compare_cluster_target(v_cruise_cluster: float, target_set_speed: float, is_metric: bool) -> tuple[bool, bool]:
speed_conv = CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH
v_cruise_cluster_conv = round(v_cruise_cluster * speed_conv)
target_set_speed_conv = round(target_set_speed * speed_conv)
req_plus = v_cruise_cluster_conv < target_set_speed_conv
req_minus = v_cruise_cluster_conv > target_set_speed_conv
return req_plus, req_minus
def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params = None) -> bool:
if params is None:
params = Params()
is_release = params.get_bool("IsReleaseSpBranch")
disallow_in_release = CP.brand == "tesla" and is_release
always_disallow = CP.brand == "rivian"
allowed = True
if disallow_in_release or always_disallow:
allowed = False
if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed:
allowed = False
if not allowed:
if params.get("SpeedLimitMode", return_default=True) == SpeedLimitMode.assist:
params.put("SpeedLimitMode", int(SpeedLimitMode.warning))
return allowed

View File

@@ -0,0 +1,414 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
from cereal import custom, car
from openpilot.common.params import Params
from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED, CONFIRM_SPEED_THRESHOLD
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import compare_cluster_target, set_speed_limit_assist_availability
ButtonType = car.CarState.ButtonEvent.Type
EventNameSP = custom.OnroadEventSP.EventName
SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState
SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source
ACTIVE_STATES = (SpeedLimitAssistState.active, SpeedLimitAssistState.adapting)
ENABLED_STATES = (SpeedLimitAssistState.preActive, SpeedLimitAssistState.pending, *ACTIVE_STATES)
DISABLED_GUARD_PERIOD = 0.5 # secs.
# secs. Time to wait after activation before considering temp deactivation signal.
PRE_ACTIVE_GUARD_PERIOD = {
True: 15,
False: 5,
}
SPEED_LIMIT_CHANGED_HOLD_PERIOD = 1 # secs. Time to wait after speed limit change before switching to preActive.
LIMIT_MIN_ACC = -1.5 # m/s^2 Maximum deceleration allowed for limit controllers to provide.
LIMIT_MAX_ACC = 1.0 # m/s^2 Maximum acceleration allowed for limit controllers to provide while active.
LIMIT_MIN_SPEED = 8.33 # m/s, Minimum speed limit to provide as solution on limit controllers.
LIMIT_SPEED_OFFSET_TH = -1. # m/s Maximum offset between speed limit and current speed for adapting state.
V_CRUISE_UNSET = 255.
CRUISE_BUTTONS_PLUS = (ButtonType.accelCruise, ButtonType.resumeCruise)
CRUISE_BUTTONS_MINUS = (ButtonType.decelCruise, ButtonType.setCruise)
CRUISE_BUTTON_CONFIRM_HOLD = 0.5 # secs.
class SpeedLimitAssist:
_speed_limit_final_last: float
_distance: float
v_ego: float
a_ego: float
v_offset: float
def __init__(self, CP: car.CarParams, CP_SP: custom.CarParamsSP):
self.params = Params()
self.CP = CP
self.CP_SP = CP_SP
self.frame = -1
self.long_engaged_timer = 0
self.pre_active_timer = 0
self.is_metric = self.params.get_bool("IsMetric")
set_speed_limit_assist_availability(self.CP, self.CP_SP, self.params)
self.enabled = self.params.get("SpeedLimitMode", return_default=True) == Mode.assist
self.long_enabled = False
self.long_enabled_prev = False
self.is_enabled = False
self.is_active = False
self.output_v_target = V_CRUISE_UNSET
self.output_a_target = 0.
self.v_ego = 0.
self.a_ego = 0.
self.v_offset = 0.
self.target_set_speed_conv = 0
self.prev_target_set_speed_conv = 0
self.v_cruise_cluster = 0.
self.v_cruise_cluster_prev = 0.
self.v_cruise_cluster_conv = 0
self.prev_v_cruise_cluster_conv = 0
self._has_speed_limit = False
self._speed_limit = 0.
self._speed_limit_final_last = 0.
self.speed_limit_prev = 0.
self.speed_limit_final_last_conv = 0
self.prev_speed_limit_final_last_conv = 0
self._distance = 0.
self.state = SpeedLimitAssistState.disabled
self._state_prev = SpeedLimitAssistState.disabled
self.pcm_op_long = CP.openpilotLongitudinalControl and CP.pcmCruise
self._plus_hold = 0.
self._minus_hold = 0.
self._last_carstate_ts = 0.
# TODO-SP: SLA's own output_a_target for planner
# Solution functions mapped to respective states
self.acceleration_solutions = {
SpeedLimitAssistState.disabled: self.get_current_acceleration_as_target,
SpeedLimitAssistState.inactive: self.get_current_acceleration_as_target,
SpeedLimitAssistState.preActive: self.get_current_acceleration_as_target,
SpeedLimitAssistState.pending: self.get_current_acceleration_as_target,
SpeedLimitAssistState.adapting: self.get_adapting_state_target_acceleration,
SpeedLimitAssistState.active: self.get_active_state_target_acceleration,
}
@property
def speed_limit_changed(self) -> bool:
return self._has_speed_limit and bool(self._speed_limit != self.speed_limit_prev)
@property
def v_cruise_cluster_changed(self) -> bool:
return bool(self.v_cruise_cluster_conv != self.prev_v_cruise_cluster_conv)
@property
def target_set_speed_confirmed(self) -> bool:
return bool(self.v_cruise_cluster_conv == self.target_set_speed_conv)
@property
def v_cruise_cluster_below_confirm_speed_threshold(self) -> bool:
return bool(self.v_cruise_cluster_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric])
def update_active_event(self, events_sp: EventsSP) -> None:
if self.v_cruise_cluster_below_confirm_speed_threshold:
events_sp.add(EventNameSP.speedLimitChanged)
else:
events_sp.add(EventNameSP.speedLimitActive)
def get_v_target_from_control(self) -> float:
if self._has_speed_limit:
if self.pcm_op_long and self.is_enabled:
return self._speed_limit_final_last
if not self.pcm_op_long and self.is_active:
return self._speed_limit_final_last
# Fallback
return V_CRUISE_UNSET
# TODO-SP: SLA's own output_a_target for planner
def get_a_target_from_control(self) -> float:
return self.a_ego
def update_params(self) -> None:
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.is_metric = self.params.get_bool("IsMetric")
set_speed_limit_assist_availability(self.CP, self.CP_SP, self.params)
self.enabled = self.params.get("SpeedLimitMode", return_default=True) == Mode.assist
def update_car_state(self, CS: car.CarState) -> None:
now = time.monotonic()
self._last_carstate_ts = now
for b in CS.buttonEvents:
if not b.pressed:
if b.type in CRUISE_BUTTONS_PLUS:
self._plus_hold = max(self._plus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD)
elif b.type in CRUISE_BUTTONS_MINUS:
self._minus_hold = max(self._minus_hold, now + CRUISE_BUTTON_CONFIRM_HOLD)
def _get_button_release(self, req_plus: bool, req_minus: bool) -> bool:
now = time.monotonic()
if req_plus and now <= self._plus_hold:
self._plus_hold = 0.
return True
elif req_minus and now <= self._minus_hold:
self._minus_hold = 0.
return True
# expired
if now > self._plus_hold:
self._plus_hold = 0.
if now > self._minus_hold:
self._minus_hold = 0.
return False
def update_calculations(self, v_cruise_cluster: float) -> None:
speed_conv = CV.MS_TO_KPH if self.is_metric else CV.MS_TO_MPH
self.v_cruise_cluster = v_cruise_cluster
# Update current velocity offset (error)
self.v_offset = self._speed_limit_final_last - self.v_ego
self.speed_limit_final_last_conv = round(self._speed_limit_final_last * speed_conv)
self.v_cruise_cluster_conv = round(self.v_cruise_cluster * speed_conv)
cst_low, cst_high = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.is_metric]
pcm_long_required_max = cst_low if self._has_speed_limit and self.speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric] else \
cst_high
pcm_long_required_max_set_speed_conv = round(pcm_long_required_max * speed_conv)
self.target_set_speed_conv = pcm_long_required_max_set_speed_conv if self.pcm_op_long else self.speed_limit_final_last_conv
@property
def apply_confirm_speed_threshold(self) -> bool:
# below CST: always require user confirmation
if self.v_cruise_cluster_below_confirm_speed_threshold:
return True
# at/above CST:
# - new speed limit >= CST: auto change
# - new speed limit < CST: user confirmation required
return bool(self.speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[self.is_metric])
def get_current_acceleration_as_target(self) -> float:
return self.a_ego
def get_adapting_state_target_acceleration(self) -> float:
if self._distance > 0:
return (self._speed_limit_final_last ** 2 - self.v_ego ** 2) / (2. * self._distance)
return self.v_offset / float(ModelConstants.T_IDXS[CONTROL_N])
def get_active_state_target_acceleration(self) -> float:
return self.v_offset / float(ModelConstants.T_IDXS[CONTROL_N])
def _update_confirmed_state(self):
if self._has_speed_limit:
if self.v_offset < LIMIT_SPEED_OFFSET_TH:
self.state = SpeedLimitAssistState.adapting
else:
self.state = SpeedLimitAssistState.active
else:
self.state = SpeedLimitAssistState.pending
def _update_non_pcm_long_confirmed_state(self) -> bool:
if self.target_set_speed_confirmed:
return True
if self.state != SpeedLimitAssistState.preActive:
return False
req_plus, req_minus = compare_cluster_target(self.v_cruise_cluster, self._speed_limit_final_last, self.is_metric)
return self._get_button_release(req_plus, req_minus)
def update_state_machine_pcm_op_long(self):
self.long_engaged_timer = max(0, self.long_engaged_timer - 1)
self.pre_active_timer = max(0, self.pre_active_timer - 1)
# ACTIVE, ADAPTING, PENDING, PRE_ACTIVE, INACTIVE
if self.state != SpeedLimitAssistState.disabled:
if not self.long_enabled or not self.enabled:
self.state = SpeedLimitAssistState.disabled
else:
# ACTIVE
if self.state == SpeedLimitAssistState.active:
if self.v_cruise_cluster_changed:
self.state = SpeedLimitAssistState.inactive
elif self.speed_limit_changed and self.apply_confirm_speed_threshold:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
elif self._has_speed_limit and self.v_offset < LIMIT_SPEED_OFFSET_TH:
self.state = SpeedLimitAssistState.adapting
# ADAPTING
elif self.state == SpeedLimitAssistState.adapting:
if self.v_cruise_cluster_changed:
self.state = SpeedLimitAssistState.inactive
elif self.speed_limit_changed and self.apply_confirm_speed_threshold:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
elif self.v_offset >= LIMIT_SPEED_OFFSET_TH:
self.state = SpeedLimitAssistState.active
# PENDING
elif self.state == SpeedLimitAssistState.pending:
if self.target_set_speed_confirmed:
self._update_confirmed_state()
elif self.speed_limit_changed:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
# PRE_ACTIVE
elif self.state == SpeedLimitAssistState.preActive:
if self.target_set_speed_confirmed:
self._update_confirmed_state()
elif self.pre_active_timer <= 0:
# Timeout - session ended
self.state = SpeedLimitAssistState.inactive
# INACTIVE
elif self.state == SpeedLimitAssistState.inactive:
pass
# DISABLED
elif self.state == SpeedLimitAssistState.disabled:
if self.long_enabled and self.enabled:
# start or reset preActive timer if initially enabled or manual set speed change detected
if not self.long_enabled_prev or self.v_cruise_cluster_changed:
self.long_engaged_timer = int(DISABLED_GUARD_PERIOD / DT_MDL)
elif self.long_engaged_timer <= 0:
if self.target_set_speed_confirmed:
self._update_confirmed_state()
elif self._has_speed_limit:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
else:
self.state = SpeedLimitAssistState.pending
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
return enabled, active
def update_state_machine_non_pcm_long(self):
self.long_engaged_timer = max(0, self.long_engaged_timer - 1)
self.pre_active_timer = max(0, self.pre_active_timer - 1)
# ACTIVE, ADAPTING, PENDING, PRE_ACTIVE, INACTIVE
if self.state != SpeedLimitAssistState.disabled:
if not self.long_enabled or not self.enabled:
self.state = SpeedLimitAssistState.disabled
else:
# ACTIVE
if self.state == SpeedLimitAssistState.active:
if self.v_cruise_cluster_changed:
self.state = SpeedLimitAssistState.inactive
elif self.speed_limit_changed and self.apply_confirm_speed_threshold:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
# PRE_ACTIVE
elif self.state == SpeedLimitAssistState.preActive:
if self._update_non_pcm_long_confirmed_state():
self.state = SpeedLimitAssistState.active
elif self.pre_active_timer <= 0:
# Timeout - session ended
self.state = SpeedLimitAssistState.inactive
# INACTIVE
elif self.state == SpeedLimitAssistState.inactive:
if self.speed_limit_changed:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
elif self._update_non_pcm_long_confirmed_state():
self.state = SpeedLimitAssistState.active
# DISABLED
elif self.state == SpeedLimitAssistState.disabled:
if self.long_enabled and self.enabled:
# start or reset preActive timer if initially enabled or manual set speed change detected
if not self.long_enabled_prev or self.v_cruise_cluster_changed:
self.long_engaged_timer = int(DISABLED_GUARD_PERIOD / DT_MDL)
elif self.long_engaged_timer <= 0:
if self._update_non_pcm_long_confirmed_state():
self.state = SpeedLimitAssistState.active
elif self._has_speed_limit:
self.state = SpeedLimitAssistState.preActive
self.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.pcm_op_long] / DT_MDL)
else:
self.state = SpeedLimitAssistState.inactive
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
return enabled, active
def update_events(self, events_sp: EventsSP) -> None:
if self.state == SpeedLimitAssistState.preActive:
events_sp.add(EventNameSP.speedLimitPreActive)
if self.state == SpeedLimitAssistState.pending and self._state_prev != SpeedLimitAssistState.pending:
events_sp.add(EventNameSP.speedLimitPending)
if self.is_active:
if self._state_prev not in ACTIVE_STATES:
self.update_active_event(events_sp)
# only notify if we acquire a valid speed limit
# do not check has_speed_limit here
elif self._speed_limit != self.speed_limit_prev:
if self.speed_limit_prev <= 0:
self.update_active_event(events_sp)
elif self.speed_limit_prev > 0 and self._speed_limit > 0:
self.update_active_event(events_sp)
def update(self, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float, v_cruise_cluster: float, speed_limit: float,
speed_limit_final_last: float, has_speed_limit: bool, distance: float, events_sp: EventsSP) -> None:
self.long_enabled = long_enabled
self.v_ego = v_ego
self.a_ego = a_ego
self._has_speed_limit = has_speed_limit
self._speed_limit = speed_limit
self._speed_limit_final_last = speed_limit_final_last
self._distance = distance
self.update_params()
self.update_calculations(v_cruise_cluster)
self._state_prev = self.state
if self.pcm_op_long:
self.is_enabled, self.is_active = self.update_state_machine_pcm_op_long()
else:
self.is_enabled, self.is_active = self.update_state_machine_non_pcm_long()
self.update_events(events_sp)
# Update change tracking variables
self.speed_limit_prev = self._speed_limit
self.v_cruise_cluster_prev = self.v_cruise_cluster
self.long_enabled_prev = self.long_enabled
self.prev_target_set_speed_conv = self.target_set_speed_conv
self.prev_v_cruise_cluster_conv = self.v_cruise_cluster_conv
self.prev_speed_limit_final_last_conv = self.speed_limit_final_last_conv
self.output_v_target = self.get_v_target_from_control()
self.output_a_target = self.get_a_target_from_control()
self.frame += 1

View File

@@ -0,0 +1,190 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
import cereal.messaging as messaging
from cereal import custom
from openpilot.common.constants import CV
from openpilot.common.gps import get_gps_location_service
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType
SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source
ALL_SOURCES = tuple(SpeedLimitSource.schema.enumerants.values())
class SpeedLimitResolver:
limit_solutions: dict[custom.LongitudinalPlanSP.SpeedLimit.Source, float]
distance_solutions: dict[custom.LongitudinalPlanSP.SpeedLimit.Source, float]
v_ego: float
speed_limit: float
speed_limit_last: float
speed_limit_final: float
speed_limit_final_last: float
distance: float
source: custom.LongitudinalPlanSP.SpeedLimit.Source
speed_limit_offset: float
def __init__(self):
self.params = Params()
self.frame = -1
self._gps_location_service = get_gps_location_service(self.params)
self.limit_solutions = {} # Store for speed limit solutions from different sources
self.distance_solutions = {} # Store for distance to current speed limit start for different sources
self.policy = self.params.get("SpeedLimitPolicy", return_default=True)
self.policy = get_sanitize_int_param(
"SpeedLimitPolicy",
Policy.min().value,
Policy.max().value,
self.params
)
self._policy_to_sources_map = {
Policy.car_state_only: [SpeedLimitSource.car],
Policy.map_data_only: [SpeedLimitSource.map],
Policy.car_state_priority: [SpeedLimitSource.car, SpeedLimitSource.map],
Policy.map_data_priority: [SpeedLimitSource.map, SpeedLimitSource.car],
Policy.combined: [SpeedLimitSource.car, SpeedLimitSource.map],
}
self.source = SpeedLimitSource.none
for source in ALL_SOURCES:
self._reset_limit_sources(source)
self.is_metric = self.params.get_bool("IsMetric")
self.offset_type = get_sanitize_int_param(
"SpeedLimitOffsetType",
OffsetType.min().value,
OffsetType.max().value,
self.params
)
self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True)
self.speed_limit = 0.
self.speed_limit_last = 0.
self.speed_limit_final = 0.
self.speed_limit_final_last = 0.
self.speed_limit_offset = 0.
def update_speed_limit_states(self) -> None:
self.speed_limit_final = self.speed_limit + self.speed_limit_offset
if self.speed_limit > 0.:
self.speed_limit_last = self.speed_limit
self.speed_limit_final_last = self.speed_limit_final
@property
def speed_limit_valid(self) -> bool:
return self.speed_limit > 0.
@property
def speed_limit_last_valid(self) -> bool:
return self.speed_limit_last > 0.
def update_params(self):
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.policy = self.params.get("SpeedLimitPolicy", return_default=True)
self.is_metric = self.params.get_bool("IsMetric")
self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True)
self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True)
def _get_speed_limit_offset(self) -> float:
if self.offset_type == OffsetType.off:
return 0
elif self.offset_type == OffsetType.fixed:
return float(self.offset_value * (CV.KPH_TO_MS if self.is_metric else CV.MPH_TO_MS))
elif self.offset_type == OffsetType.percentage:
return float(self.offset_value * 0.01 * self.speed_limit)
else:
raise NotImplementedError("Offset not supported")
def _reset_limit_sources(self, source: custom.LongitudinalPlanSP.SpeedLimit.Source) -> None:
self.limit_solutions[source] = 0.
self.distance_solutions[source] = 0.
def _get_from_car_state(self, sm: messaging.SubMaster) -> None:
self._reset_limit_sources(SpeedLimitSource.car)
self.limit_solutions[SpeedLimitSource.car] = sm['carStateSP'].speedLimit
self.distance_solutions[SpeedLimitSource.car] = 0.
def _get_from_map_data(self, sm: messaging.SubMaster) -> None:
self._reset_limit_sources(SpeedLimitSource.map)
self._process_map_data(sm)
def _process_map_data(self, sm: messaging.SubMaster) -> None:
gps_data = sm[self._gps_location_service]
map_data = sm['liveMapDataSP']
gps_fix_age = time.monotonic() - gps_data.unixTimestampMillis * 1e-3
if gps_fix_age > LIMIT_MAX_MAP_DATA_AGE:
return
speed_limit = map_data.speedLimit if map_data.speedLimitValid else 0.
next_speed_limit = map_data.speedLimitAhead if map_data.speedLimitAheadValid else 0.
self._calculate_map_data_limits(sm, speed_limit, next_speed_limit)
def _calculate_map_data_limits(self, sm: messaging.SubMaster, speed_limit: float, next_speed_limit: float) -> None:
gps_data = sm[self._gps_location_service]
map_data = sm['liveMapDataSP']
distance_since_fix = self.v_ego * (time.monotonic() - gps_data.unixTimestampMillis * 1e-3)
distance_to_speed_limit_ahead = max(0., map_data.speedLimitAheadDistance - distance_since_fix)
self.limit_solutions[SpeedLimitSource.map] = speed_limit
self.distance_solutions[SpeedLimitSource.map] = 0.
# FIXME-SP: this is not working as expected
if 0. < next_speed_limit < self.v_ego:
adapt_time = (next_speed_limit - self.v_ego) / LIMIT_ADAPT_ACC
adapt_distance = self.v_ego * adapt_time + 0.5 * LIMIT_ADAPT_ACC * adapt_time ** 2
if distance_to_speed_limit_ahead <= adapt_distance:
self.limit_solutions[SpeedLimitSource.map] = next_speed_limit
self.distance_solutions[SpeedLimitSource.map] = distance_to_speed_limit_ahead
def _get_source_solution_according_to_policy(self) -> custom.LongitudinalPlanSP.SpeedLimit.Source:
sources_for_policy = self._policy_to_sources_map[self.policy]
if self.policy != Policy.combined:
# They are ordered in the order of preference, so we pick the first that's non-zero
for source in sources_for_policy:
if self.limit_solutions[source] > 0.:
return source
return SpeedLimitSource.none
sources_with_limits = [(s, limit) for s, limit in [(s, self.limit_solutions[s]) for s in sources_for_policy] if limit > 0.]
if sources_with_limits:
return min(sources_with_limits, key=lambda x: x[1])[0]
return SpeedLimitSource.none
def _resolve_limit_sources(self, sm: messaging.SubMaster) -> tuple[float, float, custom.LongitudinalPlanSP.SpeedLimit.Source]:
"""Get limit solutions from each data source"""
self._get_from_car_state(sm)
self._get_from_map_data(sm)
source = self._get_source_solution_according_to_policy()
speed_limit = self.limit_solutions[source] if source else 0.
distance = self.distance_solutions[source] if source else 0.
return speed_limit, distance, source
def update(self, v_ego: float, sm: messaging.SubMaster) -> None:
self.v_ego = v_ego
self.update_params()
self.speed_limit, self.distance, self.source = self._resolve_limit_sources(sm)
self.speed_limit_offset = self._get_speed_limit_offset()
self.update_speed_limit_states()
self.frame += 1

View File

@@ -0,0 +1,278 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import pytest
from cereal import custom
from opendbc.car.car_helpers import interfaces
from opendbc.car.rivian.values import CAR as RIVIAN
from opendbc.car.tesla.values import CAR as TESLA
from opendbc.car.toyota.values import CAR as TOYOTA
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist, \
PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState
ALL_STATES = tuple(SpeedLimitAssistState.schema.enumerants.values())
SPEED_LIMITS = {
'residential': 25 * CV.MPH_TO_MS, # 25 mph
'city': 35 * CV.MPH_TO_MS, # 35 mph
'highway': 65 * CV.MPH_TO_MS, # 65 mph
'freeway': 80 * CV.MPH_TO_MS, # 80 mph
}
DEFAULT_CAR = TOYOTA.TOYOTA_RAV4_TSS2
@pytest.fixture
def car_name(request):
return getattr(request, "param", DEFAULT_CAR)
@pytest.fixture(autouse=True)
def set_car_name_on_instance(request, car_name):
instance = getattr(request, "instance", None)
if instance:
instance.car_name = car_name
class TestSpeedLimitAssist:
def setup_method(self, method):
self.params = Params()
self.reset_custom_params()
self.events_sp = EventsSP()
CI = self._setup_platform(self.car_name)
self.sla = SpeedLimitAssist(CI.CP, CI.CP_SP)
self.sla.pre_active_timer = int(PRE_ACTIVE_GUARD_PERIOD[self.sla.pcm_op_long] / DT_MDL)
self.pcm_long_max_set_speed = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.sla.is_metric][1] # use 80 MPH for now
self.speed_conv = CV.MS_TO_KPH if self.sla.is_metric else CV.MS_TO_MPH
def teardown_method(self, method):
self.reset_state()
def _setup_platform(self, car_name):
CarInterface = interfaces[car_name]
CP = CarInterface.get_non_essential_params(car_name)
CP_SP = CarInterface.get_non_essential_params_sp(CP, car_name)
CI = CarInterface(CP, CP_SP)
CI.CP.openpilotLongitudinalControl = True # always assume it's openpilot longitudinal
sunnypilot_interfaces.setup_interfaces(CI, self.params)
return CI
def reset_custom_params(self):
self.params.put("IsReleaseSpBranch", True)
self.params.put("SpeedLimitMode", int(Mode.assist))
self.params.put_bool("IsMetric", False)
self.params.put("SpeedLimitOffsetType", 0)
self.params.put("SpeedLimitValueOffset", 0)
def reset_state(self):
self.sla.state = SpeedLimitAssistState.disabled
self.sla.frame = -1
self.sla.last_op_engaged_frame = 0
self.sla.op_engaged = False
self.sla.op_engaged_prev = False
self.sla._speed_limit = 0.
self.sla.speed_limit_prev = 0.
self.sla.last_valid_speed_limit_offsetted = 0.
self.sla._distance = 0.
self.events_sp.clear()
def initialize_active_state(self, initialize_v_cruise):
self.sla.state = SpeedLimitAssistState.active
self.sla.v_cruise_cluster = initialize_v_cruise
self.sla.v_cruise_cluster_prev = initialize_v_cruise
self.sla.prev_v_cruise_cluster_conv = round(initialize_v_cruise * self.speed_conv)
def test_initial_state(self):
assert self.sla.state == SpeedLimitAssistState.disabled
assert not self.sla.is_enabled
assert not self.sla.is_active
assert V_CRUISE_UNSET == self.sla.get_v_target_from_control()
@pytest.mark.parametrize("car_name", [RIVIAN.RIVIAN_R1_GEN1, TESLA.TESLA_MODEL_Y], indirect=True)
def test_disallowed_brands(self, car_name):
"""
Speed Limit Assist is disabled for the following brands and conditions:
- All Tesla and is a release branch;
- All Rivian
"""
assert not self.sla.enabled
# stay disallowed even when the param may have changed from somewhere else
self.params.put("SpeedLimitMode", int(Mode.assist))
for _ in range(int(PARAMS_UPDATE_PERIOD / DT_MDL)):
self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'],
SPEED_LIMITS['city'], True, 0, self.events_sp)
assert not self.sla.enabled
def test_disabled(self):
self.params.put("SpeedLimitMode", int(Mode.off))
for _ in range(int(10. / DT_MDL)):
self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.disabled
def test_transition_disabled_to_preactive(self):
for _ in range(int(3. / DT_MDL)):
self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.preActive
assert self.sla.is_enabled and not self.sla.is_active
def test_transition_disabled_to_pending_no_speed_limit_not_max_initial_set_speed(self):
for _ in range(int(3. / DT_MDL)):
self.sla.update(True, False, SPEED_LIMITS['highway'], 0, SPEED_LIMITS['city'], 0, 0, False, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.pending
assert self.sla.is_enabled and not self.sla.is_active
def test_preactive_to_active_with_max_speed_confirmation(self):
self.sla.state = SpeedLimitAssistState.preActive
self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'],
SPEED_LIMITS['highway'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.active
assert self.sla.is_enabled and self.sla.is_active
assert self.sla.output_v_target == SPEED_LIMITS['highway']
def test_preactive_timeout_to_inactive(self):
self.sla.state = SpeedLimitAssistState.preActive
self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
for _ in range(int(PRE_ACTIVE_GUARD_PERIOD[self.sla.pcm_op_long] / DT_MDL)):
self.sla.update(True, False, SPEED_LIMITS['city'], 0, SPEED_LIMITS['highway'], SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.inactive
def test_preactive_to_pending_no_speed_limit(self):
self.sla.state = SpeedLimitAssistState.preActive
self.sla.update(True, False, SPEED_LIMITS['highway'], 0, self.pcm_long_max_set_speed, 0, 0, False, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.pending
assert self.sla.is_enabled and not self.sla.is_active
def test_pending_to_active_when_speed_limit_available(self):
self.sla.state = SpeedLimitAssistState.pending
self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed
self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv)
self.sla.update(True, False, SPEED_LIMITS['highway'], 0, self.pcm_long_max_set_speed,
SPEED_LIMITS['highway'], SPEED_LIMITS['highway'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.active
def test_pending_to_adapting_when_below_speed_limit(self):
self.sla.state = SpeedLimitAssistState.pending
self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed
self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv)
self.sla.update(True, False, SPEED_LIMITS['highway'] + 5, 0, self.pcm_long_max_set_speed,
SPEED_LIMITS['highway'], SPEED_LIMITS['highway'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.adapting
assert self.sla.is_enabled and self.sla.is_active
def test_active_to_adapting_transition(self):
self.initialize_active_state(self.pcm_long_max_set_speed)
self.sla.update(True, False, SPEED_LIMITS['highway'] + 2, 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'],
SPEED_LIMITS['highway'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.adapting
def test_adapting_to_active_transition(self):
self.sla.state = SpeedLimitAssistState.adapting
self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed
self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv)
self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['highway'],
SPEED_LIMITS['highway'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.active
def test_manual_cruise_change_detection(self):
self.sla.state = SpeedLimitAssistState.active
expected_cruise = SPEED_LIMITS['highway']
self.sla.v_cruise_cluster_prev = expected_cruise
different_cruise = SPEED_LIMITS['highway'] + 5
self.sla.update(True, False, SPEED_LIMITS['city'], 0, different_cruise, SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.inactive
# TODO-SP: test lower CST cases
def test_rapid_speed_limit_changes(self):
self.initialize_active_state(self.pcm_long_max_set_speed)
speed_limits = [SPEED_LIMITS['highway'], SPEED_LIMITS['freeway']]
for _, speed_limit in enumerate(speed_limits):
self.sla.update(True, False, speed_limit, 0, self.pcm_long_max_set_speed, speed_limit, speed_limit, True, 0, self.events_sp)
assert self.sla.state in ACTIVE_STATES
def test_invalid_speed_limits_handling(self):
self.initialize_active_state(self.pcm_long_max_set_speed)
invalid_limits = [-10, 0, 200 * CV.MPH_TO_MS]
for invalid_limit in invalid_limits:
self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, invalid_limit, SPEED_LIMITS['city'], True, 0, self.events_sp)
assert isinstance(self.sla.output_v_target, (int, float))
assert self.sla.output_v_target == V_CRUISE_UNSET or self.sla.output_v_target > 0
def test_stale_data_handling(self):
self.initialize_active_state(self.pcm_long_max_set_speed)
old_speed_limit = SPEED_LIMITS['city']
self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, 0, old_speed_limit, True, 0, self.events_sp)
assert self.sla.state in ACTIVE_STATES
assert self.sla.output_v_target == old_speed_limit
def test_distance_based_adapting(self):
self.sla.state = SpeedLimitAssistState.adapting
self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed
self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv)
distance = 100.0
current_speed = SPEED_LIMITS['freeway']
target_speed = SPEED_LIMITS['highway']
self.sla.update(True, False, current_speed, 0, self.pcm_long_max_set_speed, target_speed, target_speed, True, distance, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.adapting
assert self.sla.output_v_target == target_speed # TODO-SP: assert expected accel, need to enable self.acceleration_solutions
def test_long_disengaged_to_disabled(self):
self.initialize_active_state(self.pcm_long_max_set_speed)
self.sla.update(False, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['city'],
SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state == SpeedLimitAssistState.disabled
assert self.sla.output_v_target == V_CRUISE_UNSET
def test_maintain_states_with_no_changes(self):
"""Test that states are maintained when no significant changes occur"""
test_states = [
SpeedLimitAssistState.preActive,
SpeedLimitAssistState.pending,
SpeedLimitAssistState.active,
SpeedLimitAssistState.adapting
]
for state in test_states:
self.sla.state = state
self.sla.op_engaged = True
initial_state = state
self.sla.update(True, False, SPEED_LIMITS['city'], 0, self.pcm_long_max_set_speed, SPEED_LIMITS['city'], SPEED_LIMITS['city'], True, 0, self.events_sp)
assert self.sla.state in ALL_STATES # Sanity check
if initial_state == SpeedLimitAssistState.preActive:
assert self.sla.state in [SpeedLimitAssistState.preActive, SpeedLimitAssistState.active]
elif initial_state in ACTIVE_STATES:
assert self.sla.state in ACTIVE_STATES

View File

@@ -0,0 +1,144 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import random
import time
import pytest
from pytest_mock import MockerFixture
from cereal import custom
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver, ALL_SOURCES
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy
SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source
def create_mock(properties, mocker: MockerFixture):
mock = mocker.MagicMock()
for _property, value in properties.items():
setattr(mock, _property, value)
return mock
def setup_sm_mock(mocker: MockerFixture):
cruise_speed_limit = random.uniform(0, 120)
live_map_data_limit = random.uniform(0, 120)
car_state = create_mock({
'gasPressed': False,
'brakePressed': False,
'standstill': False,
}, mocker)
car_state_sp = create_mock({
'speedLimit': cruise_speed_limit,
}, mocker)
live_map_data = create_mock({
'speedLimit': live_map_data_limit,
'speedLimitValid': True,
'speedLimitAhead': 0.,
'speedLimitAheadValid': 0.,
'speedLimitAheadDistance': 0.,
}, mocker)
gps_data = create_mock({
'unixTimestampMillis': time.monotonic() * 1e3,
}, mocker)
sm_mock = mocker.MagicMock()
sm_mock.__getitem__.side_effect = lambda key: {
'carState': car_state,
'liveMapDataSP': live_map_data,
'carStateSP': car_state_sp,
'gpsLocation': gps_data,
}[key]
return sm_mock
parametrized_policies = pytest.mark.parametrize(
"policy, sm_key, function_key", [
(Policy.car_state_only, 'carStateSP', SpeedLimitSource.car),
(Policy.car_state_priority, 'carStateSP', SpeedLimitSource.car),
(Policy.map_data_only, 'liveMapDataSP', SpeedLimitSource.map),
(Policy.map_data_priority, 'liveMapDataSP', SpeedLimitSource.map),
],
ids=lambda val: val.name if hasattr(val, 'name') else str(val)
)
@pytest.mark.parametrize("resolver_class", [SpeedLimitResolver])
class TestSpeedLimitResolverValidation:
@pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name)
def test_initial_state(self, resolver_class, policy):
resolver = resolver_class()
resolver.policy = policy
for source in ALL_SOURCES:
if source in resolver.limit_solutions:
assert resolver.limit_solutions[source] == 0.
assert resolver.distance_solutions[source] == 0.
@parametrized_policies
def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture):
resolver = resolver_class()
resolver.policy = policy
sm_mock = setup_sm_mock(mocker)
source_speed_limit = sm_mock[sm_key].speedLimit
# Assert the resolver
resolver.update(source_speed_limit, sm_mock)
assert resolver.speed_limit == source_speed_limit
assert resolver.source == ALL_SOURCES[function_key]
def test_resolver_combined(self, resolver_class, mocker: MockerFixture):
resolver = resolver_class()
resolver.policy = Policy.combined
sm_mock = setup_sm_mock(mocker)
socket_to_source = {'carStateSP': SpeedLimitSource.car, 'liveMapDataSP': SpeedLimitSource.map}
minimum_key, minimum_speed_limit = min(
((key, sm_mock[key].speedLimit) for key in
socket_to_source.keys()), key=lambda x: x[1])
# Assert the resolver
resolver.update(minimum_speed_limit, sm_mock)
assert resolver.speed_limit == minimum_speed_limit
assert resolver.source == socket_to_source[minimum_key]
@parametrized_policies
def test_parser(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture):
resolver = resolver_class()
resolver.policy = policy
sm_mock = setup_sm_mock(mocker)
source_speed_limit = sm_mock[sm_key].speedLimit
# Assert the parsing
resolver.update(source_speed_limit, sm_mock)
assert resolver.limit_solutions[ALL_SOURCES[function_key]] == source_speed_limit
assert resolver.distance_solutions[ALL_SOURCES[function_key]] == 0.
@pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name)
def test_resolve_interaction_in_update(self, resolver_class, policy, mocker: MockerFixture):
v_ego = 50
resolver = resolver_class()
resolver.policy = policy
sm_mock = setup_sm_mock(mocker)
resolver.update(v_ego, sm_mock)
# After resolution
assert resolver.speed_limit is not None
assert resolver.distance is not None
assert resolver.source is not None
@pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name)
def test_old_map_data_ignored(self, resolver_class, policy, mocker: MockerFixture):
resolver = resolver_class()
resolver.policy = policy
sm_mock = mocker.MagicMock()
sm_mock['gpsLocation'].unixTimestampMillis = (time.monotonic() - 2 * LIMIT_MAX_MAP_DATA_AGE) * 1e3
resolver._get_from_map_data(sm_mock)
assert resolver.limit_solutions[SpeedLimitSource.map] == 0.
assert resolver.distance_solutions[SpeedLimitSource.map] == 0.

View File

@@ -1,7 +1,9 @@
import cereal.messaging as messaging
from cereal import log, car, custom
from openpilot.common.constants import CV
from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \
NoEntryAlert, ImmediateDisableAlert, EngagementAlert, NormalPermanentAlert, AlertCallbackType, wrong_car_mode_alert
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED, CONFIRM_SPEED_THRESHOLD
AlertSize = log.SelfdriveState.AlertSize
@@ -16,6 +18,43 @@ EventNameSP = custom.OnroadEventSP.EventName
EVENT_NAME_SP = {v: k for k, v in EventNameSP.schema.enumerants.items()}
def speed_limit_adjust_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
speedLimit = sm['longitudinalPlanSP'].speedLimit.resolver.speedLimit
speed = round(speedLimit * (CV.MS_TO_KPH if metric else CV.MS_TO_MPH))
message = f'Adjusting to {speed} {"km/h" if metric else "mph"} speed limit'
return Alert(
message,
"",
AlertStatus.normal, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.none, 4.)
def speed_limit_pre_active_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
speed_conv = CV.MS_TO_KPH if metric else CV.MS_TO_MPH
speed_limit_final_last = sm['longitudinalPlanSP'].speedLimit.resolver.speedLimitFinalLast
speed_limit_final_last_conv = round(speed_limit_final_last * speed_conv)
alert_1_str = ""
alert_2_str = ""
alert_size = AlertSize.none
if CP.openpilotLongitudinalControl and CP.pcmCruise:
# PCM long
cst_low, cst_high = PCM_LONG_REQUIRED_MAX_SET_SPEED[metric]
pcm_long_required_max = cst_low if speed_limit_final_last_conv < CONFIRM_SPEED_THRESHOLD[metric] else cst_high
pcm_long_required_max_set_speed_conv = round(pcm_long_required_max * speed_conv)
speed_unit = "km/h" if metric else "mph"
alert_1_str = "Speed Limit Assist: Activation Required"
alert_2_str = f"Manually change set speed to {pcm_long_required_max_set_speed_conv} {speed_unit} to activate"
alert_size = AlertSize.mid
return Alert(
alert_1_str,
alert_2_str,
AlertStatus.normal, alert_size,
Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleLow, .1)
class EventsSP(EventsBase):
def __init__(self):
super().__init__()
@@ -152,6 +191,34 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = {
Priority.LOW, VisualAlert.none, AudibleAlert.none, 1.),
},
EventNameSP.speedLimitActive: {
ET.WARNING: Alert(
"Automatically adjusting to the posted speed limit",
"",
AlertStatus.normal, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.),
},
EventNameSP.speedLimitChanged: {
ET.WARNING: Alert(
"Set speed changed",
"",
AlertStatus.normal, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.),
},
EventNameSP.speedLimitPreActive: {
ET.WARNING: speed_limit_pre_active_alert,
},
EventNameSP.speedLimitPending: {
ET.WARNING: Alert(
"Automatically adjusting to the last speed limit",
"",
AlertStatus.normal, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlertSP.promptSingleHigh, 5.),
},
EventNameSP.e2eChime: {
ET.PERMANENT: Alert(
"",

View File

@@ -126,13 +126,11 @@
"description": "Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)",
"min": -0.35,
"max": 0.35,
"step": 0.01,
"unit": "meters"
"step": 0.01
},
"CarBatteryCapacity": {
"title": "Car Battery Capacity",
"description": "Battery Size",
"unit": "kWh"
"description": ""
},
"CarList": {
"title": "Supported Car List",
@@ -398,8 +396,7 @@
},
"InteractivityTimeout": {
"title": "Interactivity Timeout",
"description": "",
"unit": "seconds"
"description": ""
},
"IsDevelopmentBranch": {
"title": "Is Development Branch",
@@ -633,8 +630,7 @@
},
"MaxTimeOffroad": {
"title": "Max Time Offroad",
"description": "",
"unit": "seconds"
"description": ""
},
"ModelManager_ActiveBundle": {
"title": "Model Manager Active Bundle",
@@ -788,56 +784,9 @@
"OnroadScreenOffTimer": {
"title": "Onroad Brightness Delay",
"description": "",
"options": [
{
"value": 15,
"label": "15s"
},
{
"value": 30,
"label": "30s"
},
{
"value": 60,
"label": "1m"
},
{
"value": 120,
"label": "2m"
},
{
"value": 180,
"label": "3m"
},
{
"value": 240,
"label": "4m"
},
{
"value": 300,
"label": "5m"
},
{
"value": 360,
"label": "6m"
},
{
"value": 420,
"label": "7m"
},
{
"value": 480,
"label": "8m"
},
{
"value": 540,
"label": "9m"
},
{
"value": 600,
"label": "10m"
}
]
"min": 0,
"max": 60,
"step": 1
},
"OnroadUploads": {
"title": "Onroad Uploads",
@@ -1111,8 +1060,7 @@
"description": "",
"min": 0.1,
"max": 5.0,
"step": 0.1,
"unit": "m/s²"
"step": 0.1
},
"ToyotaEnforceStockLongitudinal": {
"title": "Toyota: Enforce Factory Longitudinal Control",

View File

@@ -95,3 +95,10 @@ class Paths:
return str(Path(Paths.comma_home()) / "media" / "0" / "osm")
else:
return "/data/media/0/osm"
@staticmethod
def params_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "params" / "d")
else:
return "/data/params/d"

View File

@@ -19,6 +19,7 @@ from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAc
from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
from openpilot.selfdrive.ui.ui_state import ui_state
class Spacer(Widget):
@@ -321,6 +322,9 @@ def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callab
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
param = ui_state.params.last_accessed_param
ui_state.params.last_accessed_param = None
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
@@ -328,6 +332,9 @@ def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable = None,
icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP:
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
param = ui_state.params.last_accessed_param
ui_state.params.last_accessed_param = None
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)

2
third_party/mapd_pfeiferj/README.md vendored Normal file
View File

@@ -0,0 +1,2 @@
# MAPD implementation by pfeiferj
https://github.com/pfeiferj/openpilot-mapd/releases/

BIN
third_party/mapd_pfeiferj/mapd vendored Executable file

Binary file not shown.