Merge pull request #278 from dirwin31:Dom-UI-Maps-Fixes

UI/Maps: lane guidance, map polish. Hotspot and Wi-Fi fixes
This commit is contained in:
prabhaavp
2026-11-05 13:16:20 -05:00
committed by GitHub
39 changed files with 1616 additions and 97 deletions
+2
View File
@@ -14,6 +14,8 @@ openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl -text
openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text
*.apk -text
openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text
openpilot/starpilot/galaxy/bin/hostapd_linux_arm64 -text
openpilot/starpilot/galaxy/bin/hostapd_cli_linux_arm64 -text
openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text
openpilot/starpilot/soloist/pulseaudio/bin/pactl -text
openpilot/starpilot/soloist/bluealsa/bin/bluealsa-aplay -text
+6
View File
@@ -87,6 +87,12 @@ struct StarPilotCarControl @0x81c2f05a394cf4af {
distanceMeters @3 :Float32;
remainingDistanceMeters @4 :Float32;
remainingDurationSeconds @5 :Float32;
lanes @6 :List(Lane);
}
struct Lane {
active @0 :Bool;
directions @1 :List(Text);
activeDirection @2 :Text;
}
version @12 :UInt16;
}
+26 -1
View File
@@ -1,12 +1,15 @@
"""Independent curve diagnostics in the existing single-owner planner event."""
from dataclasses import dataclass
from dataclasses import dataclass, replace
import math
import secrets
from openpilot.cereal import messaging
from openpilot.starpilot.curve_speed.learning import finite_number
STATUS_LIFETIME_NS = 100_000_000
CURVATURE_DISPLAY_TAU_S = 0.6
CURVATURE_DISPLAY_HOLD_NS = 1_000_000_000
@dataclass(frozen=True)
@@ -90,3 +93,25 @@ def observation(state, now_ns: int) -> CurveObservation | None:
str(value.persistenceStatus), bool(value.glow), bool(value.curveOnly), curvature)
except (AttributeError, TypeError, ValueError, OverflowError):
return None
class CurvatureDisplay:
"""Smooth published curvature; short dropouts hold the last value."""
def __init__(self):
self._value: float | None = None
self._stamp_ns = 0
def update(self, curve: CurveObservation | None, now_ns: int) -> CurveObservation | None:
if curve is None:
return None
raw, gap = curve.road_curvature, now_ns - self._stamp_ns
if self._value is None or not 0 <= gap <= CURVATURE_DISPLAY_HOLD_NS:
self._value = raw
self._stamp_ns = now_ns
return curve
if raw is None:
return replace(curve, road_curvature=self._value)
self._value += (raw - self._value) * (1.0 - math.exp(-gap / 1e9 / CURVATURE_DISPLAY_TAU_S))
self._stamp_ns = now_ns
return replace(curve, road_curvature=self._value)
+23 -1
View File
@@ -53,6 +53,21 @@ def delete_records(self, path, payload, sentry_events_lock, crash_reports_lock,
self.json(401, {'error': 'Sign in to Galaxy'})
def widget_art_header(image):
"""Encode bounded PNG sprite metadata for the preview response header."""
import base64
import struct
signature = b'starpilot-widget-art\0'
offset = 8
while offset + 12 <= len(image):
size, kind = struct.unpack('!I4s', image[offset:offset + 8])
if kind == b'tEXt' and image[offset + 8:offset + 8 + len(signature)] == signature:
text = image[offset + 8 + len(signature):offset + 8 + size]
return base64.b64encode(text).decode() if len(text) <= 8192 else ''
offset += size + 12
return ''
def preview_layout(self, payload, projection_layout_owner, layout_owner, layout_preview_socket):
identity = self.settings_session()
if identity is None:
@@ -91,7 +106,14 @@ def preview_layout(self, payload, projection_layout_owner, layout_owner, layout_
elif not layout_owner().parked():
self.json(403, {'error': 'Turn off the vehicle to preview this layout'})
else:
self.respond(200, image, 'image/png')
headers, cache = {}, None
if payload.get('art') is True:
# Proxies that re-encode PNGs drop the tEXt chunk, so the sprite sheet also rides in a header.
header = widget_art_header(image)
if header:
headers['X-Widget-Art'] = header
cache = 'no-store, no-transform'
self.respond(200, image, 'image/png', asset_headers=headers or None, cache_control=cache)
except PreviewDenied:
self.json(403, {'error': 'Turn off the vehicle to preview this layout'})
except PreviewBusy:
@@ -0,0 +1,422 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: hostapd
Upstream-Contact: Jouni Malinen <j@w1.fi>
Source: git://w1.fi/srv/git/hostap.git
Files-Excluded:
doc/*
eap_example/*
mac80211_hwsim/*
radius_example/*
tests/*
wlantest/*
wpadebug/*
wpaspy/*
Android.mk
build_release
Files: *
Copyright: 2002-2021, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: hostapd/logwatch/*
Copyright: 2005, Henrik Brix Andersen <brix@gentoo.org>
License: BSD-3-clause or GPL-2
Files: hostapd/Android.mk
Copyright: 2008, The Android Open Source Project
License: BSD-3-clause
Files: hostapd/hostapd.8
hostapd/hostapd_cli.1
Copyright: 2005, Faidon Liambotis <faidon@cube.gr>
License: BSD-3-clause
Files: hs20/*
Copyright: 2012-2014, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/ap/acs.*
Copyright: 2011, Atheros Communications
2013, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/ap/ap_list.*
src/ap/ap_mlme.*
src/ap/beacon.*
src/ap/hw_features.*
src/ap/vlan_init.*
src/ap/wmm.*
Copyright: 2002-2009, Jouni Malinen <j@w1.fi>
2002-2004, Instant802 Networks, Inc.
2005-2006, Devicescape Software, Inc.
License: BSD-3-clause
Files: src/ap/dfs.*
Copyright: 2002-2013, Jouni Malinen <j@w1.fi>
2013, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/ap/gas_serv.*
src/ap/wnm_ap.*
src/common/ieee802_1x_defs.h
src/common/qca-vendor*
Copyright: 2011-2014, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/ap/hs20.*
wpa_supplicant/hs20_supplicant.*
Copyright: 2009, Atheros Communications, Inc.
2011-2013, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/ap/ieee802_11_ht.c
Copyright: 2002-2009, Jouni Malinen <j@w1.fi>
2007-2008, Intel Corporation
License: BSD-3-clause
Files: src/ap/p2p_hostapd.*
Copyright: 2009-2010, Atheros Communications
License: BSD-3-clause
Files: src/ap/vlan_util.*
Copyright: 2012, Michael Braun <michael-dev@fami-braun.de>
License: BSD-3-clause
Files: src/common/gas.*
Copyright: 2009, Atheros Communications
2011-2012, Qualcomm Atheros
License: BSD-3-clause
Files: src/common/ieee802_11_defs.h
Copyright: 2002-2009, Jouni Malinen <j@w1.fi>
2007-2008, Intel Corporation
License: BSD-3-clause
Files: src/common/wpa_helpers.*
Copyright: 2010-2011, Atheros Communications, Inc.
2011-2012, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/crypto/aes-internal*
Copyright: 2000, Vincent Rijmen <vincent.rijmen@esat.kuleuven.ac.be>
2000, Antoon Bosselaers <antoon.bosselaers@esat.kuleuven.ac.be>
2000, Paulo Barreto <paulo.barreto@terra.com.br>
2003-2012, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/crypto/des-internal.c
Copyright: 2005, Tom St Denis <tomstdenis@gmail.com>
2006-2009, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/crypto/md4-internal.c
Copyright: 1993, Colin Plumb
2004, Todd C. Miller
2006, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/crypto/md5-internal.c
Copyright: 1993, Colin Plumb
2003-2005, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/crypto/sha1-internal.c
Copyright: 1998, Steve Reid <sreid@sea-to-sky.net>
1998, James H. Brown <jbrown@burgoyne.com>
2001, Saul Kravitz <Saul.Kravitz@celera.com>
2001-2005, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/drivers/driver_atheros.c
Copyright: 2004, Sam Leffler <sam@errno.com>
2004, Video54 Technologies
2005-2007, Jouni Malinen <j@w1.fi>
2009, Atheros Communications
License: BSD-3-clause
Files: src/drivers/driver_bsd.c
Copyright: 2004, Sam Leffler <sam@errno.com>
2004, 2Wire, Inc
License: BSD-3-clause
Files: src/drivers/driver_macsec_qca.c
Copyright: 2004, Gunter Burchardt <tira@isx.de>
2005-2009, Jouni Malinen <j@w1.fi>
2013-2014, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/drivers/driver_nl80211.c
Copyright: 2002-2014, Jouni Malinen <j@w1.fi>
2003-2004, Instant802 Networks, Inc.
2005-2006, Devicescape Software, Inc.
2007, Johannes Berg <johannes@sipsolutions.net>
2009-2010, Atheros Communications
License: BSD-3-clause
Files: src/drivers/driver_none.c
Copyright: 2008, Atheros Communications
License: BSD-3-clause
Files: src/drivers/driver_openbsd.c
Copyright: 2013, Mark Kettenis <mark.kettenis@xs4all.nl>
License: BSD-3-clause
Files: src/drivers/driver_roboswitch.c
Copyright: 2008-2009, Jouke Witteveen
License: BSD-3-clause
Files: src/drivers/driver_wired.c
Copyright: 2005-2009, Jouni Malinen <j@w1.fi>
2004, Gunter Burchardt <tira@isx.de>
License: BSD-3-clause
Files: src/drivers/nl80211_copy.h
Copyright: 2006-2010, Johannes Berg <johannes@sipsolutions.net>
2008, Michael Wu <flamingice@sourmilk.net>
2008, Luis Carlos Cobo <luisca@cozybit.com>
2008, Michael Buesch <m@bues.ch>
2008-2009, Luis R. Rodriguez <lrodriguez@atheros.com>
2008, Jouni Malinen <jouni.malinen@atheros.com>
2008, Colin McCabe <colin@cozybit.com>
License: ISC
Files: src/eap_common/eap_pwd_common.*
src/eap_peer/eap_pwd.c
src/eap_server/eap_server_pwd.c
Copyright: 2010, Dan Harkins <dharkins@lounge.org>
License: BSD-3-clause
Files: src/eap_peer/eap_proxy*
Copyright: 2011-2013 Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/l2_packet/l2_packet_freebsd.c
Copyright: 2003-2005, Jouni Malinen <j@w1.fi>
2005, Sam Leffler <sam@errno.com>
License: BSD-3-clause
Files: src/p2p/*
Copyright: 2009-2010, Atheros Communications
License: BSD-3-clause
Files: src/pae/*
Copyright: 2013-2014, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/rsn_supp/tdls.c
Copyright: 2010-2011, Atheros Communications
License: BSD-3-clause
Files: src/tls/libtommath.c
Copyright: 2005-2007, Tom St Denis <tomstdenis@gmail.com>
License: public-domain
Files: src/utils/browser*
src/utils/http*
src/utils/xml*
Copyright: 2012-2014, Qualcomm Atheros, Inc.
License: BSD-3-clause
Files: src/utils/radiotap.c
Copyright: 2007, Andy Green <andy@warmcat.com>
2009, Johannes Berg <johannes@sipsolutions.net>
License: BSD-3-clause
Files: src/utils/radiotap.h
Copyright: 2003-2004, David Young
License: BSD-3-clause
Files: src/wps/http.h
src/wps/upnp_xml.*
src/wps/wps_upnp.*
src/wps/wps_upnp_event.c
src/wps/wps_upnp_i.h
src/wps/wps_upnp_ssdp.c
src/wps/wps_upnp_web.c
Copyright: 2000-2003, Intel Corporation
2006-2007, Sony Corporation
2008-2009, Atheros Communications
2009, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: src/wps/httpread.*
Copyright: 2008, Ted Merrill, Atheros Communications
License: BSD-3-clause
Files: src/wps/ndef.c
Copyright: 2009-2012, Masashi Honma <honma@ictec.co.jp>
License: BSD-3-clause
Files: src/wps/wps_validate.c
Copyright: 2010, Atheros Communications, Inc.
License: BSD-3-clause
Files: wpa_supplicant/dbus/dbus_common.*
wpa_supplicant/dbus/dbus_common_i.h
wpa_supplicant/dbus/dbus_new.*
wpa_supplicant/dbus/dbus_new_handlers.*
wpa_supplicant/dbus/dbus_new_handlers_wps.c
wpa_supplicant/dbus/dbus_new_helpers.*
wpa_supplicant/dbus/dbus_new_introspect.c
Copyright: 2006, Dan Williams <dcbw@redhat.com> and Red Hat, Inc.
2009-2010, Witold Sowa <witold.sowa@gmail.com>
2009-2010, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: wpa_supplicant/dbus/dbus_dict_helpers.*
Copyright: 2006, Dan Williams <dcbw@redhat.com> and Red Hat, Inc.
License: BSD-3-clause
Files: wpa_supplicant/dbus/dbus_new_handlers_p2p.*
wpa_supplicant/examples/p2p/*
wpa_supplicant/examples/dbus-listen-preq.py
Copyright: 2011-2012, Intel Corporation
License: BSD-3-clause
Files: wpa_supplicant/utils/log2pcap.py
Copyright: Johannes Berg <johannes@sipsolutions.net>, Intel Corporation
License: BSD-3-clause
Files: wpa_supplicant/wpa_gui-qt4/icons/ap.svg
Copyright: 2008, mystica
License: public-domain
Files: wpa_supplicant/wpa_gui-qt4/icons/group.svg
Copyright: 2009, Andrew Fitzsimon / Anonymous
License: public-domain
Files: wpa_supplicant/wpa_gui-qt4/icons/invitation.svg
Copyright: 2009, Jean Victor Balin
License: public-domain
Files: wpa_supplicant/wpa_gui-qt4/icons/laptop.svg
Copyright: 2008, metalmarious
License: public-domain
Files: wpa_supplicant/wpa_gui-qt4/icons/wpa_gui.svg
Copyright: 2008, Bernard Gray <bernard.gray@gmail.com>
License: BSD-3-clause or GPL-2
Files: wpa_supplicant/wpa_gui-qt4/peers.*
wpa_supplicant/wpa_gui-qt4/stringquery.*
Copyright: 2009-2010, Atheros Communications
License: BSD-3-clause
Files: wpa_supplicant/wpa_gui-qt4/signalbar.*
Copyright: 2011, Kel Modderman <kel@otaku42.de>
License: BSD-3-clause
Files: wpa_supplicant/Android.mk
wpa_supplicant/wpa_supplicant_conf.*
Copyright: 2008-2010, The Android Open Source Project
License: BSD-3-clause
Files: wpa_supplicant/ap.*
Copyright: 2003-2009, Jouni Malinen <j@w1.fi>
2009, Atheros Communications
License: BSD-3-clause
Files: wpa_supplicant/autoscan*
Copyright: 2012, Intel Corporation
License: BSD-3-clause
Files: wpa_supplicant/gas_query.*
wpa_supplicant/offchannel.*
wpa_supplicant/p2p_supplicant.*
wpa_supplicant/wifi_display.*
Copyright: 2009-2011, Atheros Communications
2011-2014, Qualcomm Atheros
2011-2014, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: wpa_supplicant/interworking.*
wpa_supplicant/wnm_sta.*
wpa_supplicant/wpas_kay.*
Copyright: 2011-2014, Qualcomm Atheros
2011-2014, Jouni Malinen <j@w1.fi>
License: BSD-3-clause
Files: debian/*
Copyright: 2004-2006, Kyle McMartin <kyle@debian.org>
2005-2009, Faidon Liambotis <paravoid@debian.org>
2006-2008, Reinhard Tartler <siretart@tauware.de>
2006-2012, Kel Modderman <kel@otaku42.de>
2010, Jan Dittberner <jandd@debian.org>
2010-2014, Stefan Lippers-Hollmann <s.l-h@gmx.de>
2016-2021, Andrej Shadura <andrewsh@debian.org>
License: BSD-3-clause
License: BSD-3-clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
.
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
.
3. Neither the name(s) of the above-listed copyright holder(s) nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
License: GPL-2
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2 as
published by the Free Software Foundation.
.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
.
On Debian GNU/Linux systems, the complete text of the GNU General Public
License version 2 can be found in `/usr/share/common-licenses/GPL-2'.
.
Note that this distribution of hostapd comes with configuration options that
link it to the OpenSSL library. The OpenSSL license is GPL-incompatible,
therefore in this distribution only the BSD license applies.
License: ISC
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
License: public-domain
Minimal code for RSA support from LibTomMath 0.41
http://libtom.org/
http://libtom.org/files/ltm-0.41.tar.bz2
This library was released in public domain by Tom St Denis.
.
The combination in this file may not use all of the optimized algorithms
from LibTomMath and may be considerable slower than the LibTomMath with its
default settings. The main purpose of having this version here is to make it
easier to build bignum.c wrapper without having to install and build an
external library.
@@ -0,0 +1,2 @@
a435b110265dc32cd646c9ea7a001c41eed7586d7312aadf8eff4464bd60695a hostapd_linux_arm64
1aa95e8d7831e32fb64a427056540f0963e1d33fcf14f45fd44c87adb1de753a hostapd_cli_linux_arm64
Binary file not shown.
Binary file not shown.
+51 -5
View File
@@ -2,6 +2,7 @@
The unprivileged manager child sends validated settings/heartbeats on stdin.
EOF, ten seconds without a heartbeat, or SIGTERM tears down only our AP.
hostapd ships in bin/ (SHA256 in hostapd.sha256).
"""
from __future__ import annotations
@@ -21,6 +22,16 @@ from openpilot.starpilot.system.pipe_worker import claim, owned, stop_event
ALIAS = 'starpilot-galaxy-hotspot'
RUNTIME = Path('/run/starpilot-galaxy-hotspot')
HOSTAPD = Path(__file__).parent / 'bin/hostapd_linux_arm64'
HOSTAPD_CLI = Path(__file__).parent / 'bin/hostapd_cli_linux_arm64'
RETRY_S = 15.0
# Failed starts churn galaxy0, so retries back off.
MAX_RETRY_S = 300.0
# A runtime udev rule keeps NetworkManager off galaxy0 from creation.
UDEV_RULE = Path('/run/udev/rules.d/85-starpilot-galaxy-hotspot.rules')
UDEV_TEXT = f'SUBSYSTEM=="net", ENV{{INTERFACE}}=="{INTERFACE}", ENV{{NM_UNMANAGED}}="1"\n'
SUPPLICANT_DIR = Path('/run/wpa_supplicant')
SUPPLICANT_RELEASE_S = 3.0
STANDALONE_CHANNEL = ('g', 6)
HOLD = 'hold'
# wlan0 first; aa0 is Android Auto's own car link when it runs on a dedicated interface.
@@ -97,8 +108,10 @@ def hostapd_config(config, channel):
class HotspotBackend:
def __init__(self, root=RUNTIME, run=subprocess.run, interfaces=WirelessInterfaces, popen=subprocess.Popen):
def __init__(self, root=RUNTIME, run=subprocess.run, interfaces=WirelessInterfaces, popen=subprocess.Popen,
udev_rule=UDEV_RULE, supplicant_dir=SUPPLICANT_DIR, clock=time.monotonic, sleep=time.sleep):
self.root, self.run, self.interfaces, self.popen = root, run, interfaces, popen
self.udev_rule, self.supplicant_dir, self.clock, self.sleep = udev_rule, supplicant_dir, clock, sleep
self.children = []
self.owned = False
self.firewall = shutil.which('iptables-legacy') or shutil.which('iptables')
@@ -140,7 +153,7 @@ class HotspotBackend:
def ap_status(self):
"""Whether the AP is up, and its current channel: the driver moves it by itself to follow a station."""
reply = self.command(['/usr/sbin/hostapd_cli', '-p', str(self.root / 'control'), '-i', INTERFACE, 'status'], check=False)
reply = self.command([str(HOSTAPD_CLI), '-p', str(self.root / 'control'), '-i', INTERFACE, 'status'], check=False)
lines = reply.stdout.splitlines() if reply.returncode == 0 else []
channel = next((line.split('=', 1)[1] for line in lines if line.startswith('channel=')), '')
return {'ready': 'state=ENABLED' in lines, 'channel': int(channel) if channel.isdigit() else None}
@@ -152,6 +165,25 @@ class HotspotBackend:
finally:
client.close()
def keep_unmanaged(self):
"""Before galaxy0 exists: NetworkManager must never manage it or hand it to wpa_supplicant."""
try:
if self.udev_rule.read_text() == UDEV_TEXT:
return
except OSError:
pass
self.udev_rule.parent.mkdir(parents=True, exist_ok=True)
self.udev_rule.write_text(UDEV_TEXT)
self.command(['udevadm', 'control', '--reload'], check=False)
def wait_supplicant_released(self):
"""wpa_supplicant releasing galaxy0 stops any AP on it, so hostapd starts only once it has let go."""
deadline = self.clock() + SUPPLICANT_RELEASE_S
while (self.supplicant_dir / INTERFACE).exists():
if self.clock() >= deadline:
raise RuntimeError('wpa_supplicant still holds the hotspot interface')
self.sleep(0.1)
def stale_owned(self):
return owned(INTERFACE, ALIAS)
@@ -183,8 +215,11 @@ class HotspotBackend:
def start(self, config, channel):
if not self.firewall:
raise RuntimeError('Firewall helper unavailable')
if not os.access(HOSTAPD, os.X_OK):
raise RuntimeError('hostapd is missing')
if overlapping_routes(json.loads(self.command(['ip', '-j', '-4', 'route', 'show', 'table', 'all']).stdout)):
raise RuntimeError('Hotspot subnet conflicts with an existing network')
self.keep_unmanaged()
self._wireless('create')
self.owned = True
try:
@@ -196,6 +231,7 @@ class HotspotBackend:
self.command(self.firewall_rule('-I', direction))
self.command(['ip', 'address', 'add', f'{ADDRESS}/24', 'dev', INTERFACE])
self.command(['ip', 'link', 'set', 'dev', INTERFACE, 'up'])
self.wait_supplicant_released()
ap = self.root / 'hostapd.conf'
ap.write_text(hostapd_config(config, channel) + f'ctrl_interface={self.root / "control"}\n')
ap.chmod(0o600)
@@ -203,7 +239,7 @@ class HotspotBackend:
dhcp.write_text(f'interface={INTERFACE}\nbind-interfaces\nport=0\nno-resolv\nno-hosts\n' +
'dhcp-range=172.31.254.10,172.31.254.30,255.255.255.0,10m\n' +
'dhcp-option=3\ndhcp-option=6\nleasefile-ro\n')
for command in (['/usr/sbin/hostapd', str(ap)],
for command in ([str(HOSTAPD), str(ap)],
['/usr/sbin/dnsmasq', '--keep-in-foreground', '--conf-file=' + str(dhcp)]):
self.children.append(self.popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True))
@@ -219,6 +255,10 @@ class HotspotController:
self.station_seen = float('-inf')
self.attempted = None
self.refused = set() # DFS channels hostapd would not start on; use the standalone channel instead
self.failures = 0
def backoff(self):
return min(RETRY_S * 2 ** min(self.failures, 5), MAX_RETRY_S)
def tick(self, config):
if not config['enabled']:
@@ -257,11 +297,14 @@ class HotspotController:
desired = self.applied
channel = desired[1]
if desired != self.applied or not running:
if self.applied is not None and not running:
self.failures += 1
self.retry_at = max(self.retry_at, self.clock() + self.backoff())
self.backend.stop()
self.applied = None
if self.clock() < self.retry_at:
return {'active': False, 'state': 'error', 'reason': 'Hotspot unavailable; retrying shortly.'}
self.retry_at = self.clock() + 15
self.retry_at = self.clock() + self.backoff()
self.attempted = channel
self.backend.start(config, channel)
self.applied = desired
@@ -272,14 +315,17 @@ class HotspotController:
raise RuntimeError('Hotspot did not become ready')
return {'active': False, 'state': 'starting', 'reason': 'Waiting for the Wi-Fi access point.', 'channel': channel[1]}
self.attempted = None
self.failures = 0
return {'active': True, 'state': 'active', 'reason': '', 'channel': channel[1]}
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError):
if self.attempted is not None and dfs(self.attempted):
self.refused.add(self.attempted[1])
else:
self.failures += 1
self.attempted = None
self.backend.stop()
self.applied = None
self.retry_at = self.clock() + 15
self.retry_at = self.clock() + self.backoff()
return {'active': False, 'state': 'error', 'reason': 'Hotspot could not start on this channel. Projection Wi-Fi was not reconfigured.'}
@@ -1,4 +1,6 @@
import hashlib
import json
import os
import stat
import struct
from pathlib import Path
@@ -7,6 +9,7 @@ from unittest.mock import Mock
import pytest
from openpilot.starpilot.galaxy.hotspot import DEFAULT, HotspotSettings, atomic_json, hotspot_name, validate_config
from openpilot.starpilot.galaxy import hotspot_worker
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces, attribute, attributes
from openpilot.starpilot.galaxy.hotspot_worker import (HOLD, STANDALONE_SETTLE_S, HotspotBackend, HotspotController, choose_channel,
hostapd_config, nm_activating, overlapping_routes, radio_channel)
@@ -300,7 +303,8 @@ def test_controller_readiness_and_failed_child():
def test_backend_failure_rolls_back_only_owned_interface(tmp_path):
run = Mock(return_value=Mock(stdout='[]'))
interfaces = Mock()
backend = HotspotBackend(tmp_path, run=run, interfaces=interfaces)
backend = HotspotBackend(tmp_path, run=run, interfaces=interfaces, udev_rule=tmp_path / 'udev/85.rules',
supplicant_dir=tmp_path / 'wpa')
backend.firewall = '/sbin/iptables-legacy'
def command(args, **kwargs):
if args[:3] == ['ip', 'address', 'add']:
@@ -317,6 +321,106 @@ def test_backend_failure_rolls_back_only_owned_interface(tmp_path):
assert not backend.owned
def test_missing_hostapd_never_creates_the_interface(tmp_path, monkeypatch):
monkeypatch.setattr(hotspot_worker, 'HOSTAPD', tmp_path / 'missing')
interfaces = Mock()
backend = HotspotBackend(tmp_path, run=Mock(return_value=Mock(stdout='[]')), interfaces=interfaces)
backend.firewall = '/sbin/iptables-legacy'
with pytest.raises(RuntimeError, match='hostapd'):
backend.start(CONFIG, ('a', 149))
interfaces.assert_not_called()
def test_bundled_hostapd_matches_its_checksum():
bundled = Path(hotspot_worker.__file__).parent / 'bin'
for line in (bundled / 'hostapd.sha256').read_text().splitlines():
digest, name = line.split()
path = bundled / name
assert hashlib.sha256(path.read_bytes()).hexdigest() == digest and os.access(path, os.X_OK)
assert {hotspot_worker.HOSTAPD.name, hotspot_worker.HOSTAPD_CLI.name} <= {line.split()[1] for line in
(bundled / 'hostapd.sha256').read_text().splitlines()}
def test_repeated_failures_back_off_and_success_resets():
backend = backend_mock()
backend.channel.return_value = ('a', 149)
backend.start.side_effect = RuntimeError('hostapd is missing')
now = [0.]
controller = HotspotController(backend, clock=lambda: now[0])
starts = []
for _ in range(400):
calls = backend.start.call_count
controller.tick(CONFIG)
if backend.start.call_count > calls:
starts.append(now[0])
now[0] += 2
gaps = [later - earlier for earlier, later in zip(starts, starts[1:], strict=False)]
assert gaps[:5] == [30, 60, 120, 240, 300] and max(gaps) == 300
backend.start.side_effect = None
now[0] += 300
assert controller.tick(CONFIG)['state'] == 'starting'
assert controller.tick(CONFIG)['active'] and controller.failures == 0 and controller.backoff() == 15
controller.failures = 10000
assert controller.backoff() == hotspot_worker.MAX_RETRY_S
def test_ap_that_keeps_exiting_backs_off():
backend = backend_mock()
backend.channel.return_value = ('a', 149)
backend.alive.return_value = False # hostapd exits right after each start
now = [0.]
controller = HotspotController(backend, clock=lambda: now[0])
starts = []
for _ in range(200):
calls = backend.start.call_count
controller.tick(CONFIG)
if backend.start.call_count > calls:
starts.append(now[0])
now[0] += 2
gaps = [later - earlier for earlier, later in zip(starts, starts[1:], strict=False)]
assert gaps[0] >= 30 and gaps[0] < gaps[1] < gaps[2] <= 300 and controller.failures >= 3
def test_networkmanager_is_kept_off_galaxy0_before_it_exists(tmp_path):
rule = tmp_path / 'udev/85-starpilot-galaxy-hotspot.rules'
run = Mock(return_value=Mock(stdout='[]', returncode=0))
interfaces = Mock()
order = []
interfaces.return_value.create.side_effect = lambda name: order.append(('create', rule.exists()))
backend = HotspotBackend(tmp_path, run=run, interfaces=interfaces, popen=Mock(), udev_rule=rule,
supplicant_dir=tmp_path / 'wpa')
backend.firewall = '/sbin/iptables-legacy'
backend.start(CONFIG, ('a', 149))
assert order == [('create', True)]
assert rule.read_text() == 'SUBSYSTEM=="net", ENV{INTERFACE}=="galaxy0", ENV{NM_UNMANAGED}="1"\n'
assert ['udevadm', 'control', '--reload'] in [call.args[0] for call in run.call_args_list]
run.reset_mock()
backend.keep_unmanaged()
run.assert_not_called() # unchanged rule: no reload
def test_hostapd_waits_for_wpa_supplicant_to_let_go(tmp_path):
supplicant = tmp_path / 'wpa'
supplicant.mkdir()
(supplicant / 'galaxy0').touch()
now = [0.]
def sleep(seconds):
now[0] += seconds
if now[0] >= 1:
(supplicant / 'galaxy0').unlink(missing_ok=True)
backend = HotspotBackend(tmp_path, run=Mock(), interfaces=Mock(), supplicant_dir=supplicant, clock=lambda: now[0], sleep=sleep)
backend.wait_supplicant_released()
assert 1 <= now[0] < 1.2
(supplicant / 'galaxy0').touch()
def sleep_forever(seconds):
now[0] += seconds
backend = HotspotBackend(tmp_path, run=Mock(), interfaces=Mock(), supplicant_dir=supplicant, clock=lambda: now[0],
sleep=sleep_forever)
with pytest.raises(RuntimeError, match='wpa_supplicant'):
backend.wait_supplicant_released()
def test_backend_conflict_does_not_create(tmp_path):
interfaces = Mock()
backend = HotspotBackend(tmp_path, run=Mock(return_value=Mock(stdout='[{"dst":"172.31.0.0/16"}]')), interfaces=interfaces)
@@ -10,13 +10,19 @@ function chunk(kind, bytes) {
return Buffer.concat([size,body,sum])
}
const meta={version:1,width:2,height:1,sprites:{conditional_mode:{x:0,y:0,width:2,height:1}}}
function image(metadata=meta){
function image(metadata=meta,text=true){
const header=Buffer.alloc(13);header.writeUInt32BE(2);header.writeUInt32BE(1,4);header[8]=8;header[9]=6
return new Blob([Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),
chunk('tEXt',Buffer.from('starpilot-widget-art\0'+JSON.stringify(metadata))),
...(text?[chunk('tEXt',Buffer.from('starpilot-widget-art\0'+JSON.stringify(metadata)))]:[]),
chunk('IDAT',deflateSync(Buffer.from([0,255,255,255,255,0,0,0,0]))),chunk('IEND',Buffer.alloc(0))])],{type:'image/png'})
}
assert.deepEqual(await widgetArtMetadata(image()),meta)
// A proxy that re-encodes the PNG drops the tEXt chunk; the header still carries the sheet.
const stripped=image(meta,false),header=Buffer.from(JSON.stringify(meta)).toString('base64')
assert.deepEqual(await widgetArtMetadata(stripped,header),meta,'Sprite sheet survives a proxy that strips PNG text')
await assert.rejects(widgetArtMetadata(stripped,null),/metadata/)
assert.deepEqual(await widgetArtMetadata(image(),'image/png'),meta,'A malformed header falls back to the PNG chunk')
await assert.rejects(widgetArtMetadata(stripped,Buffer.from(JSON.stringify({...meta,width:3})).toString('base64')))
const withHeader={...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-32.5}}}
assert.deepEqual(await widgetArtMetadata(image(withHeader)),withHeader,'Native action sprites include the heading above the buttons')
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
@@ -125,6 +131,7 @@ stalled.feed.stop()
run()
await flush();assert.equal(requests.length,1,'The second feed waits instead of being turned away as busy')
assert.equal(JSON.parse(requests[0].options.body).art,'backdrop')
assert.equal([...timers.values()].filter(t=>t.delay>=7000).length,1,'Only the feed holding the renderer runs a response clock; waiting its turn costs no budget')
backdrop.update(document,'compact','aol');run()
await flush();assert.equal(requests.length,1,'Cancelling a request does not release the renderer before it answers')
requests[0].resolve({status:200,ok:true,headers:{get:()=>'image/png'},blob:async()=>image()});await flush()
@@ -0,0 +1,29 @@
import base64
import json
import struct
import unittest
import zlib
from openpilot.starpilot.galaxy.batch_routes import widget_art_header
def chunk(kind, body):
return struct.pack('!I', len(body)) + kind + body + struct.pack('!I', zlib.crc32(kind + body))
class WidgetArtHeaderTest(unittest.TestCase):
def png(self, text):
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('!IIBBBBB', 1, 1, 8, 6, 0, 0, 0)) + text + chunk(b'IEND', b'')
def test_sheet_is_copied_into_header(self):
meta = {'version': 1, 'width': 1, 'height': 1, 'sprites': {}}
image = self.png(chunk(b'tEXt', b'starpilot-widget-art\0' + json.dumps(meta).encode()))
self.assertEqual(json.loads(base64.b64decode(widget_art_header(image))), meta)
def test_missing_or_oversized_sheet_gives_no_header(self):
self.assertEqual(widget_art_header(self.png(b'')), '')
self.assertEqual(widget_art_header(self.png(chunk(b'tEXt', b'starpilot-widget-art\0' + b'x' * 9000))), '')
if __name__ == '__main__':
unittest.main()
@@ -59,6 +59,9 @@ export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {})
// The device renders one preview at a time and answers a second request with 429.
// Feeds sharing a queue take turns instead, widget art before the backdrop.
// Allow for Galaxy tunnel latency.
const PREVIEW_TIMEOUT_MS = 20000
export class PreviewQueue {
constructor() { this.running = false; this.waiting = [] }
@@ -181,7 +184,8 @@ export class LayoutPreviewFeed {
this.last = item
const request = new AbortController()
this.request = request
const timeout = this.later(() => request.abort(), 7000)
// Queue time does not consume the response timeout.
let timeout = null
let rejectAborted
const aborted = new Promise((_, reject) => { rejectAborted = () => reject(new Error("Preview request cancelled")) })
request.signal.addEventListener("abort", rejectAborted, { once: true })
@@ -190,6 +194,7 @@ export class LayoutPreviewFeed {
const work = (async () => {
const held = !!this.queue && await this.queue.acquire(this.art ? 0 : 1, request.signal).then(() => true)
try {
timeout = this.later(() => request.abort(), PREVIEW_TIMEOUT_MS)
const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin",
cache: "no-store", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : this.backdrop ? {art: "backdrop"} : {}) }), signal: request.signal })
@@ -202,7 +207,7 @@ export class LayoutPreviewFeed {
const blob = await response.blob()
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Reconnecting automatically…")
const sprites = this.art ? await widgetArtMetadata(blob) : null
const sprites = this.art ? await widgetArtMetadata(blob, response.headers.get("X-Widget-Art")) : null
if (!this.active || item.version !== this.version || request.signal.aborted) return
const cacheKey = item.profile + "\n" + item.scene
const previousUrl = this.art ? this.artCache.get(cacheKey)?.url : this.currentUrl
@@ -227,13 +232,13 @@ export class LayoutPreviewFeed {
this.inputKey = null
this.failures = denied ? 2 : this.failures + 1
if (this.art && denied) this.clearImage()
// Art retries promptly, as does any feed turned away by a busy renderer; real outages back off.
const recovering = (this.art || busy) && this.failures < 2
// Art and backdrop retry promptly, as does any feed turned away by a busy renderer; real outages back off.
const recovering = (this.art || this.backdrop || busy) && this.failures < 2
this.publish({ status: recovering ? "updating" : "unavailable", error: recovering ? "" : failure })
this.timer = this.later(() => { this.timer = null; this.retry() }, recovering ? 600 : 5000)
}
} finally {
this.cancelTimer(timeout)
if (timeout !== null) this.cancelTimer(timeout)
request.signal.removeEventListener("abort", rejectAborted)
if (this.request === request) this.request = null
if (this.active && this.pending && this.timer === null) this.dispatch()
@@ -151,6 +151,9 @@ export class OnroadLayoutFeed {
export const LayoutWidgetPreview = {
props: ["widget", "palette", "profile", "scene", "clock24Hour", "native", "art"],
computed: {
roadNameSize() { return Math.max(18, Math.min(this.widget.width, this.widget.height) * .075) },
},
methods: {
withAlpha,
modeLines(scene, profile) {
@@ -264,13 +267,18 @@ export const LayoutWidgetPreview = {
</g>
<g v-else-if="widget.kind === 'nav_card'">
<rect x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" rx="39" fill="#0f0d17f5" stroke="#a07edcbe" stroke-width="2" />
<g transform="translate(24 47) scale(1.7)" fill="none" stroke="white" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 56 V30 Q16 18 28 18 H46" /><path d="M38 6 L50 18 L38 30" />
<text x="24" y="66" font-size="54" font-weight="700" fill="white">500 ft</text>
<text x="24" y="100" font-size="30" font-weight="600" fill="#ded6f4">Turn right onto Las Vegas Blvd</text>
<g v-for="(lane, i) in [['S', 0], ['S', 0], ['S', 0], ['R', 1], ['R', 1]]" :key="i" :transform="'translate(' + ((widget.width - 320) / 2 + i * 66) + ' 116)'">
<rect width="56" height="44" rx="13" :fill="lane[1] ? '#4e3e80' : '#222030'" />
<g transform="translate(28 22)" fill="none" :stroke="lane[1] ? '#ffffff' : '#70688a'" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
<path v-if="lane[0] === 'S'" d="M0 13 V-12 M-8 -4 L0 -12 L8 -4" />
<path v-else d="M-8 13 V0 Q-8 -7 -1 -7 H10 M4 -13 L10 -7 L4 -1" />
</g>
</g>
<text x="148" y="78" font-size="40" font-weight="600" fill="white">Turn right onto</text>
<text x="148" y="121" font-size="40" font-weight="600" fill="white">Las Vegas Blvd</text>
<text x="148" y="170" font-size="30" font-weight="700" fill="#c7aef7">500 ft</text>
<text :x="widget.width - 24" y="170" text-anchor="end" font-size="22" fill="#bebbc5">4.2 mi - 9 min</text>
<rect x="24" y="171" :width="widget.width - 48 - 190" height="6" rx="3" fill="#a07edc3c" />
<rect x="24" y="171" :width="(widget.width - 48 - 190) * .35" height="6" rx="3" fill="#b296ff" />
<text :x="widget.width - 24" y="182" text-anchor="end" font-size="22" font-weight="600" fill="#ebe1ff">ETA 3:51 PM</text>
</g>
<g v-else-if="widget.kind === 'nav_map'">
<defs>
@@ -293,6 +301,10 @@ export const LayoutWidgetPreview = {
<g :transform="'translate(' + widget.width / 2 + ' ' + widget.height * .7 + ')'">
<circle r="22" fill="#8f6bff40" /><path d="M0 -17 L13 13 L0 6 L-13 13 Z" fill="white" stroke="#6e4fe0" stroke-width="3" stroke-linejoin="round" />
</g>
<g :transform="'translate(' + widget.width / 2 + ' ' + (widget.height * .68 + 38) + ')'">
<rect :x="-roadNameSize * 4.4" y="0" :width="roadNameSize * 8.8" :height="roadNameSize * 1.55" :rx="roadNameSize * .78" fill="#080c14be" />
<text x="0" :y="roadNameSize * 1.12" text-anchor="middle" :font-size="roadNameSize" fill="#eceef6">Desert Inn Road</text>
</g>
</g>
<template v-else>
<rect :width="widget.width" :height="widget.height" :fill="palette.cardFill" :stroke="palette.cardBorder" />
@@ -1,17 +1,27 @@
// Pixels and sprite locations come from the actual onroad renderer, not browser widget copies.
export async function widgetArtMetadata(blob) {
// X-Widget-Art preserves metadata when proxies strip PNG text chunks.
function headerMetadata(header) {
try {
const bytes = Uint8Array.from(atob(header), c => c.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
} catch { return null }
}
export async function widgetArtMetadata(blob, header = null) {
const bytes = new Uint8Array(await blob.arrayBuffer())
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (bytes.length < 45 || bytes.length > 4 * 1024 * 1024 ||
![137,80,78,71,13,10,26,10].every((value, index) => bytes[index] === value)) throw new Error("Invalid widget art")
const text = new TextDecoder(), signature = "starpilot-widget-art\0"
let metadata = null
let metadata = typeof header === "string" && header.length <= 12288 ? headerMetadata(header) : null
const fromHeader = metadata !== null
for (let offset = 8; offset + 12 <= bytes.length;) {
const size = view.getUint32(offset), type = text.decode(bytes.subarray(offset + 4, offset + 8))
if (size > bytes.length - offset - 12) throw new Error("Invalid widget art chunk")
if (type === "tEXt") {
const value = text.decode(bytes.subarray(offset + 8, offset + 8 + size))
if (value.startsWith(signature)) {
if (fromHeader) { offset += size + 12; continue }
if (metadata || size > 8192) throw new Error("Invalid widget art metadata")
metadata = JSON.parse(value.slice(signature.length))
}
@@ -22,7 +32,7 @@ export async function widgetArtMetadata(blob) {
metadata.width < 1 || metadata.width > 1024 || metadata.height < 1 || metadata.height > 1440 ||
metadata.width !== view.getUint32(16) || metadata.height !== view.getUint32(20) ||
!metadata.sprites || typeof metadata.sprites !== "object" || Array.isArray(metadata.sprites) ||
Object.keys(metadata.sprites).length > 7) throw new Error("Invalid widget art metadata")
Object.keys(metadata.sprites).length > 10) throw new Error("Invalid widget art metadata")
for (const [id, rect] of Object.entries(metadata.sprites)) {
if (!/^[a-z_]+$/.test(id) || !rect || ![rect.x,rect.y,rect.width,rect.height].every(Number.isInteger) ||
rect.x < 0 || rect.y < 0 || rect.width < 1 || rect.height < 1 ||
+25 -2
View File
@@ -52,6 +52,27 @@ class Step:
text: str
maneuver_type: str
modifier: str
lanes: tuple = ()
MAX_LANES = 8
def lane_guidance(banners) -> tuple:
"""The first banner's lane arrows as ((active, directions, active_direction), ...); empty unless a lane is usable."""
for banner in banners:
sub = banner.get('sub') if isinstance(banner, dict) else None
components = sub.get('components') if isinstance(sub, dict) else None
lanes = []
for component in components if isinstance(components, list) else ():
if not isinstance(component, dict) or component.get('type') != 'lane':
continue
directions = component.get('directions')
directions = tuple(str(row)[:24] for row in directions[:4]) if isinstance(directions, list) else ()
lanes.append((component.get('active') is True, directions, str(component.get('active_direction') or '')[:24]))
if 0 < len(lanes) <= MAX_LANES and any(lane[0] for lane in lanes):
return tuple(lanes)
return ()
@dataclass(frozen=True)
@@ -85,7 +106,8 @@ class NavigationRoute:
text = str(primary.get('text') or maneuver.get('instruction', ''))[:256]
self.steps.append(Step(offset, end, number(step['duration']), text,
str(primary.get('type') or maneuver['type'])[:40],
modifier(primary.get('modifier') or maneuver.get('modifier', 'none'))[:40]))
modifier(primary.get('modifier') or maneuver.get('modifier', 'none'))[:40],
lane_guidance(banners)))
offset = end
self.total_distance = number(data['distance'])
self.total_duration = number(data['duration'])
@@ -119,7 +141,8 @@ class NavigationRoute:
duration += sum(row.duration for row in self.steps[index + 1:])
instruction = {'text': step.text, 'maneuverType': step.maneuver_type, 'maneuverModifier': step.modifier,
'distanceMeters': remaining, 'remainingDistanceMeters': max(0., self.steps[-1].end - along),
'remainingDurationSeconds': duration}
'remainingDurationSeconds': duration,
'lanes': [{'active': a, 'directions': list(d), 'activeDirection': ad} for a, d, ad in step.lanes]}
upcoming = self.steps[min(index + 1, len(self.steps) - 1)]
next_maneuver = {'maneuverType': upcoming.maneuver_type, 'maneuverModifier': upcoming.modifier,
'distanceMeters': max(0., upcoming.end - along)}
@@ -377,3 +377,27 @@ def test_held_phone_position_cannot_authorize_stale_navigation_control(tmp_path,
runtime.route = route
result = runtime.update(14_000_000_000, position, 1)
assert result['status'] == 'guiding' and not result['controlValid']
def lane_route(sub):
return NavigationRoute({'distance': 1112., 'duration': 100., 'geometry': {'coordinates': [[0., 0.], [.01, 0.]]}, 'legs': [{'steps': [
{'distance': 1112., 'duration': 100., 'maneuver': {'type': 'depart', 'instruction': 'Head east'}, 'bannerInstructions': [
{'primary': {'type': 'off ramp', 'modifier': 'right', 'text': 'Exit 40'}, 'sub': sub}]},
{'distance': 0., 'duration': 0., 'maneuver': {'type': 'arrive', 'instruction': 'You have arrived'}}]}]})
def test_lane_guidance_comes_from_the_banner_and_reaches_the_instruction():
sub = {'text': '', 'components': [
{'type': 'lane', 'active': False, 'directions': ['straight']},
{'type': 'lane', 'active': True, 'directions': ['straight', 'right'], 'active_direction': 'right'},
{'type': 'text', 'text': 'ignored'}]}
lanes = lane_route(sub).progress((.005, 0.), 5., 90.).instruction['lanes']
assert lanes == [{'active': False, 'directions': ['straight'], 'activeDirection': ''},
{'active': True, 'directions': ['straight', 'right'], 'activeDirection': 'right'}]
@pytest.mark.parametrize('sub', [None, 'text', {'components': 'x'}, {'components': [None, {'type': 'lane'}]},
{'components': [{'type': 'lane', 'active': False, 'directions': ['left']}]},
{'components': [{'type': 'lane', 'active': True, 'directions': ['left']}] * 9}])
def test_missing_unusable_or_malformed_lane_data_is_ignored(sub):
assert lane_route(sub).progress((.005, 0.), 5., 90.).instruction['lanes'] == []
+24
View File
@@ -34,6 +34,8 @@ MAX_FAST_FAILURES = 5
UNRESPONSIVE_SECONDS = 120.0
PLAYING_GRACE_SECONDS = 600.0
LOG_LINES = 200
LOGIN_RETRY_MAX_SECONDS = 300.0
LOGIN_REJECTED_AFTER = 3
def network_ready() -> bool:
@@ -58,6 +60,8 @@ def network_ready() -> bool:
EXPIRES_IN = re.compile(r'client expires in (\d+) days?')
# Soloist logs this when restoring the saved account fails.
LOGIN_FAILED = re.compile(r'\blogin failed\b')
def _kill_group(process: subprocess.Popen | None) -> None:
@@ -166,6 +170,8 @@ class Supervisor:
target=fn, name='spotify_output_check', daemon=True).start()
self.unresponsive_since: float | None = None
self.authenticated_once = False
self.login_failed = False
self.login_failures = 0
self.answered = False
self.last_playing = False
@@ -209,6 +215,8 @@ class Supervisor:
match = EXPIRES_IN.search(line)
if match:
self.days_left = int(match.group(1))
if label == 'soloist' and LOGIN_FAILED.search(line):
self.login_failed = True
def _start(self, launch: backend.Launch, label: str) -> subprocess.Popen:
process = self.spawn(launch.argv, env=launch.env, cwd=str(self.layout.runtime), stdin=subprocess.DEVNULL,
@@ -218,6 +226,7 @@ class Supervisor:
def stop_children(self, *, release_phone: bool = True) -> None:
self.unresponsive_since = None
self.login_failed = False
self.output_generation += 1
self.output_seen, self.output_flips, self.waiting_for = None, 0, ''
if self.client is not None:
@@ -432,7 +441,12 @@ class Supervisor:
self.answered = self.answered or connected
if snapshot.get('loggedIn'):
self.authenticated_once = True
self.login_failed = False
self.login_failures = 0
self.last_playing = bool(snapshot.get('active') and snapshot.get('status') in ('playing', 'buffering'))
elif self.login_failed:
self._retry_login(now)
return
lost_login = connected and self.authenticated_once and not snapshot.get('loggedIn')
# Logout clears the playback state, so cached music from before a coverage gap gets longer.
limit = PLAYING_GRACE_SECONDS if lost_login and self.last_playing else UNRESPONSIVE_SECONDS
@@ -460,6 +474,15 @@ class Supervisor:
detail = 'audio output closed' if server_dead and code is None else f'player exited ({code})'
self._fail(now, f'Spotify stopped: {detail}. Retrying…')
def _retry_login(self, now: float) -> None:
self.stop_children()
self.login_failures += 1
delay = min(LOGIN_RETRY_MAX_SECONDS, 5.0 * 2 ** min(self.login_failures - 1, 6)) * random.uniform(0.8, 1.2)
self.retry_at = now + delay
rejected = self.login_failures >= LOGIN_REJECTED_AFTER
self._set('retrying', 'Spotify keeps refusing the sign-in. Check your API key or network connection. Trying again…' if rejected
else 'Couldn’t sign in to Spotify yet. Trying again…')
def _fail(self, now: float, message: str) -> None:
self.fast_failures += 1
if self.fast_failures >= MAX_FAST_FAILURES and package.rollback(self.layout):
@@ -523,6 +546,7 @@ class Supervisor:
with self.lock:
self.stop_children()
self.authenticated_once = False
self.login_failures = 0
shutil.rmtree(self.layout.state, ignore_errors=True)
shutil.rmtree(self.layout.cache, ignore_errors=True)
return {'ok': True}
@@ -1,3 +1,4 @@
import io
import json
import pytest
@@ -86,6 +87,67 @@ def test_lost_login_recovers_but_first_pairing_waits_without_restart(tmp_path, m
assert not sup.authenticated_once
LOGIN_FAILED_LINE = b'2026-07-28 08:06:23.046: login failed: make sure --api-key is valid\n'
def test_failed_saved_login_at_boot_retries_with_backoff(tmp_path, monkeypatch):
layout = Layout(tmp_path / 'soloist')
ready_layout(layout, monkeypatch)
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
monkeypatch.setattr(supervisor.random, 'uniform', lambda _a, _b: 1.0)
spawned = []
sup, now = make_supervisor(layout, spawned, logged_in=False)
sup.tick()
assert len(spawned) == 2
for attempt, delay in enumerate((5.0, 10.0, 20.0), start=1):
sup._record(io.BytesIO(LOGIN_FAILED_LINE), 'soloist')
sup.tick()
assert sup.player is None and sup.status()['state'] == 'retrying'
assert ('keeps refusing' in sup.status()['message']) == (attempt >= supervisor.LOGIN_REJECTED_AFTER)
now[0] += delay - 0.1
sup.tick()
assert len(spawned) == 2 * attempt # still waiting out the backoff
now[0] += 0.2
sup.tick()
assert len(spawned) == 2 * (attempt + 1)
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': True, 'status': 'idle', 'active': False}
sup.tick()
assert sup.login_failures == 0 and sup.status()['state'] == 'ready'
def test_first_pairing_and_audio_lines_never_trigger_a_login_retry(tmp_path, monkeypatch):
layout = Layout(tmp_path / 'soloist')
ready_layout(layout, monkeypatch)
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
spawned = []
sup, now = make_supervisor(layout, spawned, logged_in=False)
sup.tick()
sup._record(io.BytesIO(b'W: [pulseaudio] login failed for unrelated reasons\n'), 'audio')
for _ in range(5):
now[0] += 200
sup.tick()
assert len(spawned) == 2 and sup.status()['state'] == 'waiting'
def test_successful_login_clears_prior_failure_and_preserves_logout_grace(tmp_path, monkeypatch):
layout = Layout(tmp_path / 'soloist')
ready_layout(layout, monkeypatch)
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
spawned = []
sup, now = make_supervisor(layout, spawned, logged_in=False)
sup.tick()
sup._record(io.BytesIO(LOGIN_FAILED_LINE), 'soloist')
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': True}
sup.tick()
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': False}
sup.tick()
assert not sup.login_failed and sup.player is not None
monkeypatch.setattr(supervisor.random, 'uniform', lambda _a, _b: 1.0)
sup.login_failures = 10000
sup._retry_login(now[0])
assert sup.retry_at == now[0] + supervisor.LOGIN_RETRY_MAX_SECONDS
def test_lost_login_while_playing_gets_a_longer_grace(tmp_path, monkeypatch):
layout = Layout(tmp_path / 'soloist')
ready_layout(layout, monkeypatch)
@@ -100,11 +100,18 @@ class DedicatedLease:
self._opening: threading.Thread | None = None
self._open_error: Exception | None = None
self._cancel = threading.Event()
self.home = None
self._home_lock = threading.Lock()
@property
def local_ip(self):
return self.lease.local_ip if self.lease is not None else ""
@property
def dedicated(self) -> bool:
"""Whether the car uses aa0 on the shared radio."""
return not self.legacy
@property
def interface(self) -> str:
return getattr(self.lease, "interface", self.fallback_interface if self.legacy else INTERFACE)
@@ -149,17 +156,57 @@ class DedicatedLease:
self.legacy = True
self.lease = self.lease_factory(self.log, self.fallback_interface)
def hold_home_wifi(self) -> None:
"""Keep wlan0 from searching on the shared radio until the session ends."""
with self._home_lock:
if self.legacy or self._cancel.is_set() or self.home is not None:
return
home = self.lease_factory(self.log, self.fallback_interface)
try:
held = home.hold_autoconnect()
except NetworkError as error:
self.log("wifi_home_hold_failed", error=str(error))
held = False
if not held:
home.release(restore=True)
return
self.home = home
self.log("wifi_home_held", interface=self.fallback_interface)
def _release_home(self) -> None:
with self._home_lock:
home, self.home = self.home, None
if home is not None:
try:
home.release(restore=True)
self.log("wifi_home_released", interface=self.fallback_interface)
except NetworkError as error:
self.log("wifi_home_release_failed", error=str(error))
def _fall_back(self, error: Exception) -> None:
self.legacy = True
self._release_home() # the car's join moves onto wlan0, which keeps its own autoconnect pause
self.log("wifi_dedicated_unavailable", error=str(error) or type(error).__name__)
if self.lease is not None:
try:
self.lease.release(restore=True)
except Exception:
except Exception: # the failed aa0 lease may be broken in any way; falling back must still finish
pass
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self.legacy = True
def _reopen(self) -> None:
"""Drop aa0 and its lease, then create both again; a failure to recreate falls back to wlan0."""
if self.lease is not None:
try:
self.lease.release(restore=True)
except NetworkError as error:
self.log("wifi_dedicated_release_failed", error=str(error))
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
self._ensure()
def acquire(self, credentials, cancelled: Callable[[], bool] = lambda: False, **kwargs):
self._ensure()
@@ -174,6 +221,21 @@ class DedicatedLease:
if self.timeouts >= DEDICATED_TIMEOUTS:
self._fall_back(error)
raise
if not isinstance(error, DedicatedUnavailable):
# A NetworkManager restart leaves stale device paths; retry once on a fresh aa0.
self.log("wifi_dedicated_reopen", error=str(error) or type(error).__name__)
self._reopen()
try:
local_ip = self.lease.acquire(credentials, cancelled=cancelled, **kwargs)
except NetworkError as retry_error:
if self.legacy or cancelled() or isinstance(retry_error, JoinTimeout):
raise
error = retry_error
else:
if not self.legacy:
self.aa0_joined = True
self.timeouts = 0
return local_ip
# aa0 itself failed; finish this same join on wlan0 rather than report a failure to the car.
self._fall_back(error)
self._ensure()
@@ -195,6 +257,7 @@ class DedicatedLease:
if self.lease is not None:
self.lease.release(restore=restore)
if restore:
self._release_home()
if self.iface is not None:
self.iface.close()
self.lease = self.iface = None
@@ -39,6 +39,8 @@ NM_WIRELESS_IFACE = "org.freedesktop.NetworkManager.Device.Wireless"
NM_AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint"
DEVICE_TYPE_WIFI = 2
DEVICE_STATE_DISCONNECTED = 30
DEVICE_STATE_PREPARE = 40
DEVICE_STATE_ACTIVATED = 100
DEVICE_STATES_NOT_MANAGED = (10, 20) # NM_DEVICE_STATE_UNMANAGED, NM_DEVICE_STATE_UNAVAILABLE
DEVICE_READY_TIMEOUT_S = 5.0
# A shared interface can be briefly unmanaged (for example while the Wi-Fi mode is being switched). Bugsink showed 19 joins
@@ -354,6 +356,23 @@ class NetworkLease:
except NetworkError as error:
self.log("wifi_scan_request_failed", error=str(error))
def hold_autoconnect(self) -> bool:
"""Stop wlan0 hunting for a network until release(restore=True). True when held."""
with self._lock:
self.device_path = next((path for path, name in self._wifi_devices() if name == self.interface), "")
if not self.device_path:
return False
state = int(self._get(self.device_path, NM_DEVICE_IFACE, "State"))
if state == DEVICE_STATE_ACTIVATED:
return False # connected again; nothing to hold
self._pause_autoconnect()
if DEVICE_STATE_PREPARE <= state < DEVICE_STATE_ACTIVATED:
try:
self._call(self.device_path, NM_DEVICE_IFACE, "Disconnect") # a join already under way
except NetworkError as error:
self.log("wifi_hold_disconnect_failed", error=str(error))
return self.autoconnect_paused
def _pause_autoconnect(self) -> None:
"""Keep NetworkManager from rejoining home Wi-Fi between attempts; release(restore=True) turns it back on."""
if self.autoconnect_paused:
@@ -385,9 +385,9 @@ def projection_customization(document, base_customization):
shifts['torque_bar'] = (tx - native['torque_bar']['default']['x'], ty - native['torque_bar']['default']['y'])
result = copy.deepcopy(base_customization)
result['clock24Hour'] = document.get('clock24Hour', False)
result.setdefault('widgetOrder', {}).pop('large', None)
if 'widgetOrder' in document:
result['widgetOrder']['large'] = list(document['widgetOrder'])
# Match the editor's default layers, keeping PiP above the map.
result.setdefault('widgetOrder', {})['large'] = list(document['widgetOrder'] if 'widgetOrder' in document else
layout_metadata_for_viewport((width, height))['widgetOrder'])
result['layouts']['large'] = {}
for key, placement in document['widgets'].items():
dx, dy = shifts.get(key, (0, 0))
@@ -262,9 +262,11 @@ class SharedRadioWatch:
"""
def __init__(self, log: Callable[..., None], interface: str = "wlan0", interval: float = RADIO_CHECK_INTERVAL,
counter: Path | None = None):
counter: Path | None = None, carrier: Path | None = None, on_down: Callable[[], None] | None = None):
self.log, self.interface, self.interval = log, interface, interval
self.counter = counter or Path(f"/sys/class/net/{interface}/carrier_changes")
self.carrier = carrier or Path(f"/sys/class/net/{interface}/carrier")
self.on_down = on_down # the other network was lost, not just roamed
self.changed_at: float | None = None
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name="aa_radio_watch", daemon=True)
@@ -297,6 +299,17 @@ class SharedRadioWatch:
last = current
self.changed_at = time.monotonic()
self.log("other_wifi_changed", interface=self.interface)
if self.on_down is not None and not self._carrier():
try:
self.on_down()
except Exception as error: # the watch must keep running for the ACK grace window
self.log("other_wifi_hold_failed", error=str(error) or type(error).__name__)
def _carrier(self) -> bool:
try:
return self.carrier.read_text().strip() == "1"
except OSError:
return False # reading carrier of a down interface fails with EINVAL
class EventLog:
@@ -1794,7 +1807,7 @@ class Supervisor:
watch = LinkWatch(lease, self.log).start()
radio = None
if getattr(lease, "dedicated", False) and getattr(lease, "interface", "wlan0") != "wlan0":
radio = SharedRadioWatch(self.log).start()
radio = SharedRadioWatch(self.log, on_down=getattr(lease, "hold_home_wifi", None)).start()
session.radio_change = radio.recent
try:
from openpilot.starpilot.system.starport.media_audio import MediaAudio, music_control
@@ -48,6 +48,7 @@ class FakeLease:
self.local_ip = ""
self.errors: list = []
self.releases: list = []
self.held = False
def prepare_device(self, cancelled):
self.prepared = True
@@ -64,23 +65,30 @@ class FakeLease:
def release(self, restore=True):
self.releases.append(restore)
def hold_autoconnect(self):
self.held = True
return True
def credentials():
return WifiCredentials(ssid="Car-4E91", key="secret-key", bssid="C6:B7:57:F7:4E:91", security=8, ap_type=0)
def make(fail=False, errors=()):
def make(fail=False, errors=(), fail_reopen=False):
leases = []
def factory(log, interface, dedicated=False):
lease = FakeLease(log, interface, dedicated)
if dedicated:
lease.errors = list(errors)
lease.errors = list(errors) if not any(item.dedicated for item in leases) else []
leases.append(lease)
return lease
FakeInterface.instances = []
log = Log()
return DedicatedLease(log, "wlan0", factory, interface_factory=lambda log: FakeInterface(log, fail)), leases, log
def interface(log):
return FakeInterface(log, fail or (fail_reopen and len(FakeInterface.instances) > 0))
return DedicatedLease(log, "wlan0", factory, interface_factory=interface), leases, log
def test_dedicated_session_keeps_aa0_between_attempts():
@@ -116,10 +124,75 @@ def test_overlapping_car_subnet_finishes_join_on_wlan0():
def test_failed_aa0_join_finishes_the_same_join_on_wlan0():
lease, leases, log = make(errors=[NetworkError("NetworkManager could not join the projection network")])
error = NetworkError("NetworkManager could not join the projection network")
lease, leases, log = make(errors=[error])
leases_errors = [error]
def factory(log, interface, dedicated=False):
item = FakeLease(log, interface, dedicated)
if dedicated:
item.errors = list(leases_errors) # the fresh aa0 fails the same way
leases.append(item)
return item
lease.lease_factory = factory
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("aa0", True), ("aa0", True), ("wlan0", False)]
assert lease.aa0_failed and not lease.aa0_joined and all(item.closed for item in FakeInterface.instances)
assert log.names().count("wifi_dedicated_reopen") == 1
def test_networkmanager_restart_rebuilds_aa0_instead_of_falling_back():
stale = NetworkError("Cannot read ActiveConnection: Object does not exist at path /org/freedesktop/NetworkManager/Devices/116")
lease, leases, log = make(errors=[stale, stale])
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("aa0", True), ("aa0", True)]
assert FakeInterface.instances[0].closed and not FakeInterface.instances[1].closed
assert lease.interface == "aa0" and lease.dedicated and lease.aa0_joined and not lease.legacy
assert "wifi_dedicated_reopen" in log.names() and "wifi_dedicated_unavailable" not in log.names()
def test_aa0_that_cannot_be_rebuilt_finishes_on_wlan0():
lease, leases, log = make(errors=[NetworkError("stale")], fail_reopen=True)
assert lease.acquire(credentials()) == "192.168.180.183"
assert [(item.interface, item.dedicated) for item in leases] == [("aa0", True), ("wlan0", False)]
assert lease.aa0_failed and not lease.aa0_joined and FakeInterface.instances[0].closed
assert lease.legacy and not lease.dedicated and not lease.aa0_failed
def test_lost_home_wifi_is_held_until_the_session_ends():
lease, leases, log = make()
lease.acquire(credentials())
lease.hold_home_wifi()
lease.hold_home_wifi() # a second carrier change keeps the one hold
home = [item for item in leases if item.interface == "wlan0"]
assert len(home) == 1 and home[0].held and not home[0].dedicated
lease.release(restore=False)
assert home[0].releases == [] # between attempts the hold stays
lease.release(restore=True)
assert home[0].releases == [True] and lease.home is None
assert log.names().count("wifi_home_held") == 1 and "wifi_home_released" in log.names()
def test_home_wifi_is_never_held_while_the_car_uses_it():
lease, leases, log = make(fail=True)
lease.acquire(credentials())
lease.hold_home_wifi()
assert lease.home is None and not any(item.held for item in leases)
def test_late_radio_callback_cannot_hold_home_wifi_after_release():
lease, leases, log = make()
lease.acquire(credentials())
lease.release(restore=True)
lease.hold_home_wifi()
assert lease.home is None and not any(item.held for item in leases)
def test_home_wifi_that_came_back_is_not_held(monkeypatch):
lease, leases, log = make()
lease.acquire(credentials())
monkeypatch.setattr(FakeLease, "hold_autoconnect", lambda self: False)
lease.hold_home_wifi()
assert lease.home is None and leases[-1].releases == [True]
def test_repeated_aa0_timeouts_fall_back_for_the_session():
@@ -176,8 +249,9 @@ def test_session_stop_cancels_a_slow_aa0_setup():
class FakeNM:
def __init__(self, devices=None, autoconnect=True, overlap=False, appears_after=0):
def __init__(self, devices=None, autoconnect=True, overlap=False, appears_after=0, state=30):
self.devices = devices if devices is not None else {"/dev/wlan0": "wlan0", "/dev/aa0": "aa0"}
self.state = state
self.autoconnect = autoconnect
self.overlap = overlap
self.appears_after = appears_after # GetDevices calls before NetworkManager lists aa0
@@ -208,7 +282,7 @@ class FakeNM:
return [{"address": ("s", "192.168.180.183"), "prefix": ("u", 24)}]
if name == "Frequency":
return 5745 if path == "/ap/aa0" else 2437
return {"WirelessEnabled": True, "DeviceType": 2, "ActiveConnection": "/active/home", "State": 30 if path.startswith("/dev") else 2,
return {"WirelessEnabled": True, "DeviceType": 2, "ActiveConnection": "/active/home", "State": self.state if path.startswith("/dev") else 2,
"Autoconnect": self.autoconnect, "Connection": "/settings/home", "ActiveAccessPoint": "/ap/" + path.rsplit("/", 1)[-1]}[name]
def set_property(self, path, interface, name, signature, value):
@@ -273,6 +347,20 @@ def test_dedicated_network_lease_rejects_overlapping_subnet_without_a_second_joi
assert nm.calls.count("AddAndActivateConnection2") == 1
@pytest.mark.parametrize("state, held, disconnected", [(30, True, False), (120, True, False), (50, True, True), (100, False, False)])
def test_hold_autoconnect_on_lost_home_wifi(tmp_path, state, held, disconnected):
nm = FakeNM(state=state)
lease: Any = NetworkLease(Log(), "wlan0")
lease._call, lease._get, lease._set = nm.call, nm.get, nm.set_property
lease.router = type("R", (), {"close": lambda self: None})()
lease.marker = tmp_path / "paused.json"
assert lease.hold_autoconnect() is held
assert ("Disconnect" in nm.calls) is disconnected
assert nm.set == ([("/dev/wlan0", "Autoconnect", False)] if held else [])
lease.release(restore=True)
assert nm.autoconnect and not lease.marker.exists()
def test_legacy_lease_still_falls_back():
lease = nm_lease(FakeNM(devices={"/dev/wlan0": "wlan0"}), dedicated=False)
lease.acquire(credentials(), timeout=2.0)
@@ -34,6 +34,15 @@ class TestProjectionLayout(unittest.TestCase):
self.assertGreaterEqual(placed[axis], metadata['bounds'][axis])
self.assertLessEqual(placed[axis] + widget[extent], metadata['bounds'][axis] + metadata['bounds'][extent])
def test_unsaved_layer_order_draws_the_editor_default(self):
document = default_layout(SCREEN)
self.assertNotIn('widgetOrder', document)
order = projection_customization(document, default_document())['widgetOrder']['large']
self.assertEqual(order, layout_metadata(SCREEN)['widgetOrder'])
self.assertLess(order.index(NAV_MAP), order.index('pip_right'))
saved = [NAV_MAP, *(key for key in order if key != NAV_MAP)][::-1]
self.assertEqual(projection_customization({**document, 'widgetOrder': saved}, default_document())['widgetOrder']['large'], saved)
def test_sources_save_actual_attached_actions_inside_each_projection_screen(self):
from openpilot.starpilot.ui.onroad_customization import default_document, placement
for viewport in ((1860, 1080), (2880, 1080), (1920, 1440)):
@@ -103,6 +103,28 @@ def test_watch_reports_a_carrier_change_without_naming_the_network(tmp_path):
watch.stop()
@pytest.mark.parametrize("carrier, held", [("0\n", True), ("1\n", False), (None, True)])
def test_watch_holds_home_wifi_only_when_it_was_lost(tmp_path, carrier, held):
counter, carrier_path = tmp_path / "carrier_changes", tmp_path / "carrier"
counter.write_text("4\n")
if carrier is not None:
carrier_path.write_text(carrier) # missing: reading carrier of a down interface fails
holds = []
watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=counter, carrier=carrier_path,
on_down=lambda: holds.append(True)).start()
try:
time.sleep(0.05)
counter.write_text("5\n")
deadline = time.monotonic() + 2
while not watch.recent():
assert time.monotonic() < deadline
time.sleep(0.01)
time.sleep(0.05)
assert holds == ([True] if held else [])
finally:
watch.stop()
def test_watch_without_the_counter_never_reports(tmp_path):
watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=tmp_path / "missing").start()
try:
@@ -492,7 +492,9 @@ class LayoutPreviewRenderer:
if profile == Profile.LARGE:
from openpilot.starpilot.ui.navigation_state import NavigationDisplay
state = replace(state, navigation=NavigationDisplay(("preview", 1), "Turn left onto Desert Inn Road", "turn", "left",
152.0, 6800.0, 540.0))
152.0, 6800.0, 540.0, False, "turn", "right", 300.0,
((True, ("left",), "left"), (True, ("left",), "left"), (False, ("straight",), ""),
(False, ("straight",), ""), (False, ("right",), ""))))
if not rl.is_window_ready():
raise RuntimeError("Layout preview requires the UI graphics context")
if self._canvas is None or self._canvas.profile != profile or self._canvas.viewport != viewport:
+11 -1
View File
@@ -12,6 +12,10 @@ class NavigationDisplay:
remaining_distance_m: float
remaining_seconds: float
arrived: bool = False
then_type: str = ''
then_modifier: str = ''
then_distance_m: float | None = None
lanes: tuple = ()
def navigation_display(message, *, now_ns: int, drive_id: int) -> NavigationDisplay | None:
@@ -30,8 +34,14 @@ def navigation_display(message, *, now_ns: int, drive_id: int) -> NavigationDisp
text = 'You have arrived' if message.status == 'arrived' else ' '.join(instruction.text.split())[:240]
if not text:
return None
lanes = tuple((lane.active, tuple(lane.directions), lane.activeDirection) for lane in list(instruction.lanes)[:8])
then = message.nextManeuver
then_type = '' if message.status == 'arrived' else then.maneuverType
then_distance = then.distanceMeters if then_type and math.isfinite(then.distanceMeters) else None
return NavigationDisplay((message.sessionId, drive_id), text, instruction.maneuverType,
instruction.maneuverModifier, *distances, message.status == 'arrived')
instruction.maneuverModifier, *distances, message.status == 'arrived',
then_type, then.maneuverModifier if then_type else '', then_distance,
() if message.status == 'arrived' else lanes)
except (AttributeError, TypeError, ValueError, OverflowError):
return None
+4 -2
View File
@@ -25,7 +25,7 @@ from openpilot.starpilot.ui.onroad_alerts import AlertRenderer
from openpilot.starpilot.ui.onroad_border import render_compact_half_borders, render_large_half_borders
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer, MiciSidebarWidgets
from openpilot.starpilot.ui.onroad_personality import PersonalityWidget
from openpilot.starpilot.ui.onroad_curve import controlling as curve_controlling, glow_color, intensity, render_glow, status_label
from openpilot.starpilot.ui.onroad_curve import GlowPhase, controlling as curve_controlling, glow_color, intensity, render_glow, status_label
from openpilot.starpilot.ui.onroad_conditional import stop_active
from openpilot.starpilot.ui.onroad_corner import CornerHintCache, render_corner_hint
from openpilot.starpilot.ui.onroad_large_widgets import CurrentSpeedHud, UnifiedSpeedWidget, SteeringWheelWidget
@@ -126,6 +126,7 @@ class OnroadView:
self.wall_clock = wall_clock or (lambda: datetime.now().astimezone())
self._corner_cache = CornerHintCache()
self.quick_select_layer = None
self._curve_glow_phase = GlowPhase()
gui_app.add_render_prepare(self._corner_cache.prepare, self._corner_cache.close)
self.fonts = fonts
self.camera_layer = camera_layer
@@ -457,7 +458,8 @@ class OnroadView:
self.fonts.draw(label, FontRole.SEMI_BOLD, 29, 1390 + right_shift, 56,
curve_color)
self._traffic_badge(state, 56 + 45 * int(label is not None))
render_glow(content, state, border_width=30, time_s=state.observed_ns / 1e9 if state.observed_ns else rl.get_time())
render_glow(content, state, border_width=30, time_s=state.observed_ns / 1e9 if state.observed_ns else rl.get_time(),
phase=getattr(self, "_curve_glow_phase", None))
rl.draw_rectangle_lines_ex(frame, 30, rl.BLACK)
rl.draw_rectangle_rounded_lines_ex(content, 0.12, 10, 30, edge)
render_large_half_borders(state, state.observed_ns / 1e9 if state.observed_ns else rl.get_time(), content, int(height))
+18 -2
View File
@@ -91,7 +91,22 @@ def status_label(state: OnroadState) -> str | None:
return None
def render_glow(rect: rl.Rectangle, state: OnroadState, *, border_width: float, time_s: float) -> None:
class GlowPhase:
"""Advance the breath by dt/period; (t % period) / period jumps whenever the period changes."""
def __init__(self):
self._phase = 0.0
self._time_s: float | None = None
def advance(self, time_s: float, cycle: float) -> float:
if self._time_s is not None and 0.0 <= time_s - self._time_s <= 1.0:
self._phase = (self._phase + (time_s - self._time_s) / cycle) % 1.0
self._time_s = time_s
return self._phase
def render_glow(rect: rl.Rectangle, state: OnroadState, *, border_width: float, time_s: float,
phase: GlowPhase | None = None) -> None:
"""Draw behind the axis-status border; absent/stale/off draws nothing."""
preview = state.visual_preview
preview_curve = preview.curve_curvature if preview is not None and state.alert.size != AlertSize.FULL else None
@@ -109,7 +124,8 @@ def render_glow(rect: rl.Rectangle, state: OnroadState, *, border_width: float,
cycle = period(strength)
fraction = max(0.0, min(1.0, (strength - BASE_INTENSITY) / (1.0 - BASE_INTENSITY)))
amplitude = 0.3 + 0.7 * fraction
breath = max(0.0, min(1.0, 0.5 + amplitude * math.sin((time_s % cycle) / cycle * 2 * math.pi)))
position = phase.advance(time_s, cycle) if phase is not None else (time_s % cycle) / cycle
breath = max(0.0, min(1.0, 0.5 + amplitude * math.sin(position * 2 * math.pi)))
alpha = int(200 * strength * (0.6 + 0.4 * breath))
span = max(1, int(border_width * SPAN))
edge = rl.Color(color.r, color.g, color.b, alpha)
+95 -6
View File
@@ -49,6 +49,17 @@ CAR_ANCHOR = 0.68 # the car sits below center so more road ahead is v
ZOOM_SLOW_M_PER_PX = 0.9 # parking-lot and city detail
ZOOM_FAST_M_PER_PX = 3.4 # highway overview
MIN_HEADING_SPEED = 1.5 # m/s; GPS heading is noise below this
ROTATE_MIN_SPEED = 3.0 # m/s; below this the map holds its rotation
HEADING_DEADBAND_DEG = 3.0 # heading changes smaller than this leave the map still
HEADING_RATE = 1.5 # 1/s: how quickly the map eases toward the heading
MAX_TURN_DEG_S = 45.0 # the map never spins faster than this
JUNCTION_LOOKAHEAD_S = 14.0 # zoom in on the next maneuver this far ahead
JUNCTION_MIN_M, JUNCTION_MAX_M = 80.0, 300.0
STOPPED_SPEED = 0.5 # m/s
OVERVIEW_AFTER_S = 30.0 # stopped this long: zoom out to an overview
OVERVIEW_MIN_MANEUVER_M = 50.0 # ...unless a maneuver is about to happen
NAME_HOLD_S = 1.5 # a new road name must hold this long to replace the old one
ROAD_NAME_MAX_AGE_S = 3.0
GUIDANCE_SLACK_PX = 4.0 # screen pixels of driven route left under the puck before guidance is trimmed again
@@ -90,6 +101,19 @@ def zoom_for_speed(speed_mps: float) -> float:
return ZOOM_SLOW_M_PER_PX + (ZOOM_FAST_M_PER_PX - ZOOM_SLOW_M_PER_PX) * t
def zoom_target(speed_mps: float, maneuver_m: float | None = None, stopped_s: float = 0.0) -> float:
"""Meters per screen pixel: speed-based, closer ahead of a maneuver, wider after sitting still."""
base = zoom_for_speed(speed_mps)
near = maneuver_m is not None and maneuver_m <= OVERVIEW_MIN_MANEUVER_M
if stopped_s >= OVERVIEW_AFTER_S and not near:
return ZOOM_FAST_M_PER_PX
if maneuver_m is not None:
reach = min(JUNCTION_MAX_M, max(JUNCTION_MIN_M, speed_mps * JUNCTION_LOOKAHEAD_S))
t = min(1.0, max(0.0, (reach - maneuver_m) / (reach * 0.5)))
base -= (base - ZOOM_SLOW_M_PER_PX) * t * t * (3 - 2 * t)
return base
# ---------------------------------------------------------------- geometry
def line_strip(points: np.ndarray, offsets: np.ndarray, half_width: float, bounds: tuple[float, float, float, float],
@@ -282,6 +306,8 @@ class MapInput:
gps_wait_seconds: float = 0.0
destination: str = '' # where navigation is going: "Home", "Work" or the place name
source: str = '' # where the fix comes from: comma, car or phone
maneuver_distance_m: float | None = None # meters to the next maneuver while guiding
road_name: str = '' # the road the car is on, from the map data
class TileReader:
@@ -437,6 +463,11 @@ class MapOverlay:
self._latitude = 0.0
self._bearing = 0.0
self._has_heading = False
self._stopped_since: float | None = None
self._name = ''
self._name_candidate = ''
self._name_since = 0.0
self._name_layout: tuple | None = None
self._m_per_px = ZOOM_SLOW_M_PER_PX
self._last_prepare: float | None = None
self.status = "waiting"
@@ -448,6 +479,7 @@ class MapOverlay:
self._input = data
self.status = "live" if data.fix is not None and 0 <= now - data.fix.monotonic <= fix_hold_seconds(data.source) else "waiting"
fix = data.fix
self._track_name(data.road_name if self.status == "live" else '', now)
dt = 0.0 if self._last_prepare is None else min(0.2, max(0.0, now - self._last_prepare))
self._last_prepare = now
if fix is None:
@@ -459,8 +491,11 @@ class MapOverlay:
heading = math.radians(fix.bearing)
target_world = (target_world[0] + math.sin(heading) * units, target_world[1] - math.cos(heading) * units)
delta = (fix.bearing - self._bearing + 540.0) % 360.0 - 180.0
self._bearing = (self._bearing + delta * (min(1.0, dt * 6.0) if self._has_heading else 1.0)) % 360.0
self._has_heading = True
if not self._has_heading:
self._bearing, self._has_heading = fix.bearing % 360.0, True
elif fix.speed > ROTATE_MIN_SPEED and abs(delta) > HEADING_DEADBAND_DEG:
limit = MAX_TURN_DEG_S * dt
self._bearing = (self._bearing + max(-limit, min(limit, delta * min(1.0, dt * HEADING_RATE)))) % 360.0
if self._world is None or math.hypot(target_world[0] - self._world[0], target_world[1] - self._world[1]) > 0.5:
self._world = target_world # first fix or a jump: no slow glide across town
else:
@@ -468,9 +503,23 @@ class MapOverlay:
self._world = (self._world[0] + (target_world[0] - self._world[0]) * k,
self._world[1] + (target_world[1] - self._world[1]) * k)
self._latitude = fix.latitude
target_zoom = zoom_for_speed(fix.speed) if math.isfinite(fix.speed) else self._m_per_px
stopped = math.isfinite(fix.speed) and fix.speed < STOPPED_SPEED
if not stopped:
self._stopped_since = None
elif self._stopped_since is None:
self._stopped_since = now
stopped_s = 0.0 if self._stopped_since is None else now - self._stopped_since
maneuver = data.maneuver_distance_m if data.navigation_active else None
target_zoom = zoom_target(fix.speed, maneuver, stopped_s) if math.isfinite(fix.speed) else self._m_per_px
self._m_per_px += (target_zoom - self._m_per_px) * min(1.0, dt * 0.8)
def _track_name(self, name: str, now: float) -> None:
name = ' '.join(name.split())[:64]
if name != self._name_candidate:
self._name_candidate, self._name_since = name, now
if not name or now - self._name_since >= NAME_HOLD_S:
self._name = name
def _route(self, route: tuple[tuple[float, float], ...]) -> None:
if route is self._route_key:
return # MapFeed hands back the same tuple until the route changes
@@ -731,12 +780,33 @@ class MapOverlay:
self._draw_starting_navigation(rect)
return
text = "" if self.has_roads or self._canvas_request is None else "No map data here yet"
if not text and self._name and self.fonts is not None:
self._draw_road_name(rect)
if text and self.fonts is not None:
from openpilot.starpilot.ui.presentation import FontRole
measured = self.fonts.measure(text, FontRole.MEDIUM, 26)
self.fonts.draw(text, FontRole.MEDIUM, 26, rect.x + (rect.width - measured.width) / 2,
rect.y + rect.height * CAR_ANCHOR + 40, rl.Color(225, 228, 238, round(200 * opacity)))
def _draw_road_name(self, rect: rl.Rectangle) -> None:
"""Draw the current road below the car, independent of map opacity."""
from openpilot.starpilot.ui.presentation import FontRole
size = max(18, round(min(rect.width, rect.height) * 0.075))
limit = rect.width * 0.8 - size
key = (self._name, size, round(limit))
if self._name_layout is None or self._name_layout[0] != key:
text = self._name
while len(text) > 3 and self.fonts.measure(text, FontRole.MEDIUM, size).width > limit:
text = text[:-4] + "..." if text.endswith("...") else text[:-1] + "..."
self._name_layout = key, text, self.fonts.measure(text, FontRole.MEDIUM, size).width
_, text, text_width = self._name_layout
pad = size * 0.55
pill = rl.Rectangle(rect.x + (rect.width - text_width) / 2 - pad, rect.y + rect.height * CAR_ANCHOR + 38,
text_width + pad * 2, size * 1.55)
rl.draw_rectangle_rounded(pill, 0.5, 8, rl.Color(15, 13, 23, 255))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 8, 1.5, rl.Color(150, 140, 200, 255))
self.fonts.draw(text, FontRole.MEDIUM, size, pill.x + pad, pill.y + size * 0.27, rl.Color(240, 242, 250, 255))
def _draw_source_chip(self, rect: rl.Rectangle) -> None:
"""Where the fix comes from, left of the compass; solid whatever the map's opacity so it always reads."""
label = SOURCE_LABELS.get(self._input.source)
@@ -840,7 +910,7 @@ class MapFeed:
from openpilot.starpilot.gps.source import GPS_SERVICES
if sm is None:
from openpilot.cereal import messaging
sm = messaging.SubMaster(list(GPS_SERVICES))
sm = messaging.SubMaster([*GPS_SERVICES, "mapdOut"])
self.sm = sm
self._fix: MapFix | None = None
self._source = ''
@@ -849,6 +919,9 @@ class MapFeed:
self._route: tuple[tuple[float, float], ...] = ()
self._navigation_active = False
self._destination = ''
self._maneuver_m: float | None = None
self._road = ''
self._road_at = -math.inf
self._acquisition = acquisition if acquisition is not None else GpsAcquisition()
self._acquisition_polled = -math.inf
@@ -892,10 +965,12 @@ class MapFeed:
active = bool(nav is not None and nav.enabled and nav.destinationName)
self._destination = nav.destinationName if active else ''
self._route_valid_until = 0
self._maneuver_m = None
if (nav is not None and nav.enabled and nav.status in ("guiding", "arrived") and
0 < nav.frameMonoTime <= now_ns <= nav.frameMonoTime + 3_000_000_000):
self._route_valid_until = nav.frameMonoTime + 3_000_000_000
route = tuple((point.latitude, point.longitude) for point in nav.route)
self._maneuver_m = self._maneuver(nav)
except Exception:
route = ()
if route != self._route:
@@ -903,6 +978,7 @@ class MapFeed:
self._navigation_active = active
if now_ns > self._route_valid_until:
self._route = ()
self._maneuver_m = None
route, active = self._route, self._navigation_active
acquisition_elapsed = 0.0
if not fresh:
@@ -912,7 +988,20 @@ class MapFeed:
return MapInput(self._fix, route, active if navigation_requested is None else navigation_requested,
None if fresh else self._acquisition.satellites(now),
acquisition_elapsed, self._destination if destination is None else destination,
self._source if self._fix is not None else '')
self._source if self._fix is not None else '', self._maneuver_m if route else None, self._road_name(now))
@staticmethod
def _maneuver(nav) -> float | None:
meters = float(nav.instruction.distanceMeters)
return meters if nav.status == "guiding" and math.isfinite(meters) and meters >= 0 else None
def _road_name(self, now: float) -> str:
if self.sm.updated.get("mapdOut", False):
out = self.sm["mapdOut"]
self._road = (out.roadName or out.wayName) if self.sm.valid["mapdOut"] else ''
self._road_at = now
return self._road if 0 <= now - self._road_at <= ROAD_NAME_MAX_AGE_S else ''
# ---------------------------------------------------------------- parked preview
@@ -967,4 +1056,4 @@ def sample_input() -> MapInput:
return lat_lon(SAMPLE_TILE.x + x / EXTENT, SAMPLE_TILE.y + y / EXTENT)
latitude, longitude = world(*SAMPLE_CAR)
route = tuple(world(x, y) for x, y in ((1450, 4200), (1450, 2620), (1200, 2620), (-1800, 2460)))
return MapInput(MapFix(latitude, longitude, SAMPLE_BEARING, 8.0, time.monotonic()), route)
return MapInput(MapFix(latitude, longitude, SAMPLE_BEARING, 8.0, time.monotonic()), route, road_name="Desert Inn Road")
+93 -26
View File
@@ -10,6 +10,11 @@ from openpilot.starpilot.ui.navigation_state import distance_text
from openpilot.starpilot.ui.presentation import FontRole, Profile
FAR_TURN_M = 1500.
THEN_MAX_GAP_M = 400.
LANES_SHOW_M = 800.
def _overlaps_slc_actions(rect, profile, state) -> bool:
from openpilot.starpilot.ui.onroad_state import slc_action_occludes
return slc_action_occludes(profile, state, (rect.x, rect.y, rect.x + rect.width, rect.y + rect.height))
@@ -29,13 +34,14 @@ class NavigationCard:
self._press = None
self._textures = OrderedDict()
self._text_layout = None
self._total = 0.
def bounds(self, state):
if (state.navigation is None or state.alert.size != 'none' or state.reverse_driver_camera or
state.appearance.camera_view in (CameraViewChoice.DRIVER, CameraViewChoice.NONE)):
return None
if state.navigation.key != self._key:
self._key, self.collapsed = state.navigation.key, False
self._key, self.collapsed, self._total = state.navigation.key, False, 0.
rect = self._placed(state)
# A live SLC decision keeps its touch priority only where its buttons actually sit on the card.
if rect is not None and _overlaps_slc_actions(rect, self.fonts.profile, state):
@@ -87,13 +93,13 @@ class NavigationCard:
raise RuntimeError('Unable to load navigation artwork')
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._textures[name] = texture
if len(self._textures) > 8:
if len(self._textures) > 16:
rl.unload_texture(self._textures.popitem(last=False)[1])
self._textures.move_to_end(name)
return self._textures[name]
def _lines(self, text, width, size):
key = (text, width, size)
def _lines(self, text, width, size, max_lines=2):
key = (text, width, size, max_lines)
if self._text_layout is not None and self._text_layout[0] == key:
return self._text_layout[1]
words, lines, line = text.split(), [], ''
@@ -105,8 +111,8 @@ class NavigationCard:
else:
line = candidate
lines.append(line)
if len(lines) > 2:
lines = lines[:2]
if len(lines) > max_lines:
lines = lines[:max_lines]
lines[-1] += '...'
for i, line in enumerate(lines):
while len(line) > 3 and self.fonts.measure(line, FontRole.SEMI_BOLD, size).width > width:
@@ -124,33 +130,94 @@ class NavigationCard:
compact = self.fonts.profile == Profile.COMPACT
rl.draw_rectangle_rounded(rect, .2, 8, rl.Color(15, 13, 23, 255 if compact else 245))
rl.draw_rectangle_rounded_lines_ex(rect, .2, 8, 2, rl.Color(160, 126, 220, 190))
size = (52 if compact else 80) if self.collapsed else (68 if compact else 100)
padding = 10 if self.collapsed else (18 if compact else 24)
icon = self._icon(icon_name('arrive' if nav.arrived else nav.maneuver_type, nav.modifier))
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
rl.Rectangle(rect.x + padding, rect.y + (rect.height - size) / 2, size, size), rl.Vector2(0, 0), 0, rl.WHITE)
if self.collapsed:
size = 64 if compact else 100
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
rl.Rectangle(rect.x + (rect.width - size) / 2, rect.y + (rect.height - size) / 2, size, size),
rl.Vector2(0, 0), 0, rl.WHITE)
return
x, y = rect.x + padding * 2 + size, rect.y + (14 if compact else 22)
self._total = max(self._total, nav.remaining_distance_m)
padding = 18 if compact else 24
lanes = () if nav.arrived or nav.distance_m > LANES_SHOW_M else nav.lanes
header = rect.height * ((.52 if lanes else .64) if compact else (.55 if lanes else .66))
size = min(84 if compact else 122, header - 6)
if not lanes:
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
rl.Rectangle(rect.x + padding, rect.y + (header - size) / 2, size, size), rl.Vector2(0, 0), 0, rl.WHITE)
x = rect.x + padding * (1 if lanes else 2) + (0 if lanes else size)
width = rect.x + rect.width - padding - x
primary_size, secondary_size = (28, 22) if compact else (40, 30)
for i, text in enumerate(self._lines(nav.text, width, primary_size)):
self.fonts.draw(text, FontRole.SEMI_BOLD, primary_size, x, y + i * (primary_size + 3))
distance_size, text_size = (32, 20) if compact else (54, 30 if lanes else 28)
distance = 'Arrived' if nav.arrived else distance_text(nav.distance_m, state.metric)
self.fonts.draw(distance, FontRole.BOLD, secondary_size, x, rect.y + rect.height - (58 if compact else 85),
rl.Color(199, 174, 247, 255))
if not nav.arrived:
now = self.clock()
font_size = 22 if compact else 32
y = rect.y + rect.height - (30 if compact else 44)
if now is not None:
remaining = f'ETA {arrival_time(nav.remaining_seconds, now)}'
measured = self.fonts.measure(remaining, FontRole.SEMI_BOLD, font_size)
self.fonts.draw(remaining, FontRole.SEMI_BOLD, font_size, rect.x + rect.width - padding - measured.width,
y, rl.Color(235, 225, 255, 255))
far = not nav.arrived and nav.distance_m > FAR_TURN_M
y = rect.y + (8 if compact else 12)
self.fonts.draw(distance, FontRole.BOLD, distance_size, x, y, rl.WHITE)
y += distance_size + (2 if compact else 4)
lines = self._lines(nav.text, width, text_size, max_lines=1 if far or lanes else 2)
for i, text in enumerate(lines):
self.fonts.draw(text, FontRole.SEMI_BOLD, text_size, x, y + i * (text_size + 2), rl.Color(222, 214, 244, 255))
if nav.arrived:
return
top = rect.y + header
now = self.clock()
eta = f'ETA {arrival_time(nav.remaining_seconds, now)}' if now is not None else ''
right = rect.x + rect.width - padding
bar_center = rect.y + rect.height - (11 if compact else 21)
eta_size = 19 if compact else 28
bar_right = right
if eta:
eta_width = self.fonts.measure(eta, FontRole.SEMI_BOLD, eta_size).width
self.fonts.draw(eta, FontRole.SEMI_BOLD, eta_size, right - eta_width, bar_center - eta_size * (.55 if compact else .6), rl.Color(235, 225, 255, 255))
bar_right -= eta_width + (12 if compact else 18)
if lanes:
lane_top = top + (6 if compact else 9)
self._lane_strip(lanes, rect.x + padding, lane_top, right, bar_center - (8 if compact else 12) - 6 - lane_top)
else:
chip = 20 if compact else 24
self._then(nav, rect.x + padding, top + 7, chip)
if not compact:
trip = f'{distance_text(nav.remaining_distance_m, state.metric)} | {max(1, round(nav.remaining_seconds / 60))} min'
self.fonts.draw(trip, FontRole.NORMAL, 20, rect.x + padding, y + 8, rl.Color(190, 187, 197, 255))
trip_size = 27
trip_width = self.fonts.measure(trip, FontRole.MEDIUM, trip_size).width
self.fonts.draw(trip, FontRole.MEDIUM, trip_size, right - trip_width, top, rl.Color(214, 210, 226, 255))
if self._total > 0:
thick = 5 if compact else 6
bar = rl.Rectangle(rect.x + padding, bar_center - thick / 2, bar_right - (rect.x + padding), thick)
rl.draw_rectangle_rounded(bar, 1., 4, rl.Color(160, 126, 220, 60))
done = min(1., max(0., 1 - nav.remaining_distance_m / self._total))
if done > 0:
rl.draw_rectangle_rounded(rl.Rectangle(bar.x, bar.y, max(thick, bar.width * done), thick), 1., 4, rl.Color(178, 150, 255, 255))
def _lane_strip(self, lanes, x, y, right, height):
"""One cell per lane with the maneuver artwork for its arrow: usable lanes lit, the rest dim."""
gap = max(4., height * .12)
cell = min(height * 1.2, (right - x + gap) / len(lanes) - gap)
x += (right - x - (cell + gap) * len(lanes) + gap) / 2
size = height * .8
for i, (active, directions, chosen) in enumerate(lanes):
box = rl.Rectangle(x + i * (cell + gap), y, cell, height)
rl.draw_rectangle_rounded(box, .3, 6, rl.Color(78, 62, 128, 255) if active else rl.Color(34, 30, 46, 255))
direction = chosen if active and chosen else (directions[0] if directions else 'straight')
icon = self._icon(icon_name('turn', direction))
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
rl.Rectangle(box.x + (box.width - size) / 2, box.y + (box.height - size) / 2, size, size),
rl.Vector2(0, 0), 0, rl.WHITE if active else rl.Color(112, 104, 134, 255))
def _then(self, nav, x, y, size):
"""A "Then <arrow>" chip when the turn after this one comes soon after it."""
if not nav.then_type or nav.then_distance_m is None:
return
gap = nav.then_distance_m - nav.distance_m
if not 1 < gap <= THEN_MAX_GAP_M:
return
label = 'Then'
label_width = self.fonts.measure(label, FontRole.SEMI_BOLD, size - 4).width
chip = rl.Rectangle(x, y - 4, label_width + size + 28, size + 8)
rl.draw_rectangle_rounded(chip, .5, 8, rl.Color(44, 37, 70, 255))
self.fonts.draw(label, FontRole.SEMI_BOLD, size - 4, x + 12, y + 1, rl.Color(199, 174, 247, 255))
icon = self._icon(icon_name(nav.then_type, nav.then_modifier))
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
rl.Rectangle(x + label_width + 18, y - 1, size, size), rl.Vector2(0, 0), 0, rl.WHITE)
def close(self):
for texture in self._textures.values():
+9 -6
View File
@@ -17,11 +17,10 @@ from typing import Any
from openpilot.starpilot.galaxy.access import AccessStatus, GalaxyAccessOwner
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance, onroad_appearance
from openpilot.starpilot.ui.brand import DISPLAY_VERSION, home_description
from openpilot.starpilot.curve_speed.status import observation as curve_observation
from openpilot.starpilot.curve_speed.status import CurvatureDisplay, observation as curve_observation
from openpilot.starpilot.conditional_mode.policy import ModeChoice
from openpilot.starpilot.conditional_mode.manual import TRAFFIC_MODE_ACTION, read_button_map
from openpilot.starpilot.feature_capabilities import lateral_supported, longitudinal_supported
from openpilot.starpilot.aol.runtime import current_axis
from openpilot.starpilot.aol.wire import INTENT_SERVICE, decode_intent
from openpilot.starpilot.conditional_mode.projection import paired_clocks_ns
from openpilot.starpilot.conditional_mode.runtime_settings import ConditionalSettingsOwner
@@ -58,6 +57,7 @@ from openpilot.starpilot.ui.toggles_state import Personality, ToggleKey, Toggles
SLC_SIGN_HOLD_NS = 150_000_000
EVENTS_DISPLAY_MAX_AGE_NS = 1_500_000_000 # selfdrived sends onroadEvents on every change and at least once a second
def _boot_time_ns() -> int:
@@ -325,6 +325,7 @@ class RuntimeSnapshotAdapter:
self._conditional_read_ns: int | None = None
self._traffic = TrafficDisplayProjector()
self._switchback = SwitchbackStatusOwner()
self._curve_curvature = CurvatureDisplay()
self._traffic_settings = ConditionalSettingsOwner(ui_state.params)
self._traffic_context_ns: int | None = None
self._traffic_map_fingerprint: str | None = None
@@ -453,7 +454,7 @@ class RuntimeSnapshotAdapter:
display_model = display_message(sm, "modelV2", now_ns, after_frame=after) if started else None
display_radar = display_message(sm, "radarState", now_ns, after_frame=after) if started else None
display_plan = display_message(sm, 'longitudinalPlan', now_ns, after_frame=after) if started else None
display_events = display_message(sm, 'onroadEvents', now_ns, after_frame=after) if started else None
display_events = _message_at_age(sm, 'onroadEvents', now_ns, after, EVENTS_DISPLAY_MAX_AGE_NS) if started else None
display_monitor = display_message(sm, 'driverMonitoringState', now_ns, after_frame=after) if started else None
if display_monitor is not None:
self._display_rhd = bool(display_monitor.isRHD)
@@ -465,7 +466,7 @@ class RuntimeSnapshotAdapter:
now_ns=now_ns, native_alert=native_alert, hide=self._appearance_value.hide_personality_alerts)
slc = display_message(sm, "slcState", now_ns, after_frame=after) if started else None
curve_envelope = current_curve_message(sm, now_ns, after_frame=after) if started else None
curve = curve_observation(curve_envelope, now_ns) if curve_envelope is not None else None
curve = self._curve_curvature.update(curve_observation(curve_envelope, now_ns) if curve_envelope is not None else None, now_ns)
conditional_envelope = display_message(sm, 'starpilotSelfdriveState', now_ns, after_frame=after) if started else None
from openpilot.starpilot.navigation.wire import navigation_state
@@ -662,8 +663,10 @@ class RuntimeSnapshotAdapter:
display_long_active = bool(display_control.longActive) if display_control is not None else False
long_engaged = bool(display_selfdrive is not None and getattr(display_selfdrive, 'enabled', False))
if getattr(ui, 'aol_runtime', False):
axis = current_axis(sm, now_ns=now_ns) if started and sm.recv_frame.get('aolAxisState', 0) > after else None
long_engaged = long_engaged and bool(axis is not None and axis.nativeAcknowledged and axis.desiredLongitudinal)
# Display only: the control-side 30 ms proof window would blink the override edge whenever a frame runs late.
axis = display_message(sm, 'aolAxisState', now_ns, after_frame=after) if started else None
long_engaged = long_engaged and bool(axis is not None and axis.qualified and axis.sessionId and
axis.nativeAcknowledged and axis.desiredLongitudinal)
longitudinal_overridden = bool(
display_car_valid and getattr(display_car, 'gasPressed', False) and long_engaged and
str(getattr(display_selfdrive, 'state', '')) == 'overriding' and
@@ -0,0 +1,82 @@
"""Noisy model curvature must not strobe the curve glow between green and red."""
import random
import unittest
from unittest.mock import patch
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.curve_speed.status import CurvatureDisplay, CurveObservation
from openpilot.starpilot.ui.developer_preview import OnroadVisualPreview
from openpilot.starpilot.ui.onroad_curve import GlowPhase, render_glow
from openpilot.starpilot.ui.onroad_state import OnroadAlert, OnroadState, SpeedLimitObservation
FRAME_NS = 50_000_000
START_NS = 5_000 * 1_000_000_000 # observed time is monotonic uptime, not seconds since the glow appeared
def curve(curvature: float | None) -> CurveObservation:
return CurveObservation(True, 20.0, 20.0, True, True, False, 100.0, 2.0, 50.0, 'curve', 'selected', 'ok', True, False,
curvature)
def jitter(frames: int) -> list[float | None]:
rng = random.Random(7)
return [None if rng.random() < .1 else rng.uniform(.004, .016) for _ in range(frames)]
def edges(curvatures, phase):
rect = rl.Rectangle(30, 30, 1800, 1020)
colors = []
with patch.object(rl, 'draw_rectangle_gradient_v') as vertical, patch.object(rl, 'draw_rectangle_gradient_h'):
for frame, curvature in enumerate(curvatures):
state = OnroadState(engaged=False, camera_available=False, speed_mps=0.0, cruise_kph=None,
speed_limit=SpeedLimitObservation(), alert=OnroadAlert(),
visual_preview=OnroadVisualPreview(None, curvature))
vertical.reset_mock()
render_glow(rect, state, border_width=30, time_s=(START_NS + frame * FRAME_NS) / 1e9, phase=phase)
colors.append(vertical.call_args_list[0].args[4])
return colors
def worst_step(colors) -> int:
return max(max(abs(a.r - b.r), abs(a.g - b.g), abs(a.b - b.b), abs(a.a - b.a)) for a, b in zip(colors, colors[1:], strict=False))
class CurveGlowFlickerTests(unittest.TestCase):
def test_smoothed_curvature_holds_dropouts_and_resets_after_gap(self):
display = CurvatureDisplay()
self.assertEqual(display.update(curve(.01), START_NS).road_curvature, .01)
held = display.update(curve(None), START_NS + FRAME_NS)
self.assertEqual(held.road_curvature, .01)
stepped = display.update(curve(.02), START_NS + 2 * FRAME_NS).road_curvature
self.assertIsNotNone(stepped)
self.assertGreater(stepped or 0., .01)
self.assertLess(stepped or 0., .013)
self.assertIsNone(display.update(None, START_NS + 3 * FRAME_NS))
self.assertEqual(display.update(curve(.003), START_NS + 3 * FRAME_NS + 2_000_000_000).road_curvature, .003)
def test_glow_changes_gradually_under_jittery_curvature(self):
display = CurvatureDisplay()
smoothed = [display.update(curve(value), START_NS + frame * FRAME_NS).road_curvature
for frame, value in enumerate(jitter(200))]
self.assertLessEqual(worst_step(edges(smoothed, GlowPhase())), 40)
def test_raw_jitter_without_phase_state_strobes(self):
raw = [value if value is not None else .0001 for value in jitter(200)]
self.assertGreater(worst_step(edges(raw, None)), 150)
def test_tiny_period_changes_do_not_jump_the_breath(self):
# 1% curvature noise barely moves the color, but re-deriving the phase from uptime strobed the alpha.
curvatures = [.01 if frame % 2 else .0101 for frame in range(200)]
self.assertGreater(worst_step(edges(curvatures, None)), 30)
self.assertLessEqual(worst_step(edges(curvatures, GlowPhase())), 8)
def test_constant_period_matches_stateless_breath(self):
curvatures = [.01] * 40
stateless, phased = edges(curvatures, None), edges(curvatures, GlowPhase())
self.assertLessEqual(worst_step(phased), worst_step(stateless) + 1)
if __name__ == '__main__':
unittest.main()
@@ -219,22 +219,24 @@ class TestOnroadAxes(unittest.TestCase):
nativeAcknowledged=True, desiredLongitudinal=True)
self.ui.sm.put('aolAxisState', axis)
self.assertTrue(self.state(True, False).longitudinal_overridden)
for field, value in (('qualified', False), ('sessionId', ''), ('observedMonoTime', NOW + 1),
('validUntilMonoTime', NOW - 1), ('nativeAcknowledged', False)):
for field, value in (('qualified', False), ('sessionId', ''), ('nativeAcknowledged', False), ('desiredLongitudinal', False)):
with self.subTest(field=field):
original = getattr(axis, field)
setattr(axis, field, value)
state = self.state(True, False)
self.assertFalse(state.longitudinal_overridden or state.driver_override_active or state.cruise_active)
setattr(axis, field, original)
for stamp in (NOW + 1, NOW - 31_000_000):
self.ui.sm.logMonoTime['aolAxisState'] = NOW - 60_000_000
self.assertTrue(self.state(True, False).longitudinal_overridden,
'a late UI frame past the 30 ms control window does not blink the override edge')
for stamp in (NOW + 1, NOW - 201_000_000):
self.ui.sm.logMonoTime['aolAxisState'] = stamp
self.assertFalse(self.state(True, False).longitudinal_overridden)
self.ui.sm.logMonoTime['aolAxisState'] = NOW
self.ui.sm.recv_frame['aolAxisState'] = self.ui.started_frame
self.assertFalse(self.state(True, False).longitudinal_overridden)
self.ui.sm.recv_frame['aolAxisState'] = self.ui.started_frame + 1
for flags in (self.ui.sm.valid, self.ui.sm.alive, self.ui.sm.seen):
for flags in (self.ui.sm.valid, self.ui.sm.alive):
flags['aolAxisState'] = False
self.assertFalse(self.state(True, False).longitudinal_overridden)
flags['aolAxisState'] = True
+111 -4
View File
@@ -3,6 +3,7 @@
import math
import os
import time
from collections import defaultdict
import threading
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
@@ -15,8 +16,8 @@ from openpilot.starpilot.navigation.road_tiles import (
DATA_ZOOM, EXTENT, MAJOR, MINOR, MOTORWAY, STREET, TileKey, decode_road_tile, encode_road_tile, lat_lon, world_xy,
)
from openpilot.starpilot.ui.onroad_map import (
CAR_ANCHOR, ZOOM_FAST_M_PER_PX, ZOOM_SLOW_M_PER_PX, CanvasRequest, MapFix, MapInput, MapOverlay, TileReader,
build_canvas, line_strip, route_guidance, split_route, zoom_for_speed,
CAR_ANCHOR, HEADING_DEADBAND_DEG, MAX_TURN_DEG_S, ZOOM_FAST_M_PER_PX, ZOOM_SLOW_M_PER_PX, CanvasRequest, MapFix, MapInput, MapOverlay, TileReader,
build_canvas, line_strip, route_guidance, split_route, zoom_for_speed, zoom_target,
)
from openpilot.starpilot.system.starport.tests import test_gpu_nv12
@@ -309,6 +310,7 @@ class FakeGpsMaster:
from types import SimpleNamespace
self.ns = SimpleNamespace
self.messages, self.valid, self.logMonoTime = {}, {}, {}
self.updated = defaultdict(bool)
def update(self, _timeout):
pass
@@ -347,7 +349,7 @@ def test_map_feed_uses_navigation_gps_choice_with_car_fallback(monkeypatch):
fix = feed.read().fix
assert fix.latitude == 36.3 and fix.bearing is None, "a receiver wins; an unusable heading is dropped"
nav = sm.ns(version=2, enabled=True, status="guiding", frameMonoTime=now, destinationName="Home",
route=[sm.ns(latitude=36.1, longitude=-115.1)])
instruction=sm.ns(distanceMeters=120.0), route=[sm.ns(latitude=36.1, longitude=-115.1)])
published = {"starpilotNavigation": sm.ns(navigation=nav)}
class NavigationMaster(dict):
valid = {"starpilotNavigation": True}
@@ -550,7 +552,8 @@ class FakeNavigationMaster(FakeGpsMaster):
from openpilot.starpilot.navigation.wire import VERSION
route = [ns(latitude=lat, longitude=lon) for lat, lon in points]
self.messages["starpilotNavigation"] = ns(navigation=ns(version=VERSION, status=status, route=route,
enabled=True, destinationName='Home', frameMonoTime=time.monotonic_ns()))
enabled=True, destinationName='Home', frameMonoTime=time.monotonic_ns(),
instruction=ns(distanceMeters=120.0)))
self.valid["starpilotNavigation"], self.logMonoTime["starpilotNavigation"] = True, stamp
@@ -718,3 +721,107 @@ def test_map_feed_subscribes_to_registered_gps_carriers(monkeypatch):
feed = MapFeed(acquisition=NS())
assert feed.sm is not None
assert subscriptions
def test_zoom_closes_in_before_a_maneuver_and_never_widens():
assert zoom_target(25., None) == zoom_for_speed(25.)
assert zoom_target(25., 1000.) == zoom_for_speed(25.)
assert zoom_target(25., 250.) < zoom_for_speed(25.)
assert zoom_target(25., 20.) == pytest.approx(ZOOM_SLOW_M_PER_PX)
assert zoom_target(2., 60.) <= zoom_for_speed(2.)
def test_stopped_overview_waits_then_yields_to_a_close_maneuver():
assert zoom_target(0., None, 29.) == ZOOM_SLOW_M_PER_PX
assert zoom_target(0., None, 31.) == ZOOM_FAST_M_PER_PX
assert zoom_target(0., 30., 60.) == ZOOM_SLOW_M_PER_PX
def test_overview_starts_after_standing_still_and_drops_when_moving(tmp_path):
overlay = MapOverlay(tmp_path)
try:
still = MapInput(MapFix(*CENTER, 0., 0., 100.))
overlay._advance(still, 100.)
overlay._advance(still, 131.)
assert overlay._stopped_since == 100.
overlay._advance(MapInput(MapFix(*CENTER, 0., 8., 132.)), 132.)
assert overlay._stopped_since is None
finally:
overlay.close()
def test_map_rotation_ignores_heading_wobble_and_turns_gently(tmp_path):
overlay = MapOverlay(tmp_path)
try:
clock = 100.
def step(bearing, speed=15.):
nonlocal clock
clock += .05
overlay._advance(MapInput(MapFix(*CENTER, bearing, speed, clock)), clock)
step(90.)
assert overlay._bearing == 90.
for i in range(40):
step(90. + (2.5 if i % 2 else -2.5))
assert overlay._bearing == 90., "GPS heading wobble leaves the map still"
for _ in range(20):
step(180., speed=2.)
assert overlay._bearing == 90., "creeping along, the map holds its rotation"
step(180.)
assert 0 < overlay._bearing - 90. <= MAX_TURN_DEG_S * .05 + 1e-9, "a real turn rotates the map, but gently"
for _ in range(80):
step(180.)
assert abs(overlay._bearing - 180.) < HEADING_DEADBAND_DEG + 1e-9
finally:
overlay.close()
def test_road_name_waits_before_replacing_and_clears_when_empty(tmp_path):
overlay = MapOverlay(tmp_path)
try:
overlay._track_name('Main Street', 10.)
overlay._track_name('Main Street', 11.6)
assert overlay._name == 'Main Street'
overlay._track_name('Side Road', 12.)
overlay._track_name('Side Road', 13.)
assert overlay._name == 'Main Street', 'a brief side road does not flicker in'
overlay._track_name('Main Street', 13.2)
overlay._track_name('Side Road', 14.)
overlay._track_name('Side Road', 15.6)
assert overlay._name == 'Side Road'
overlay._track_name('', 16.)
assert overlay._name == ''
finally:
overlay.close()
def test_map_feed_reports_maneuver_distance_and_road_name(monkeypatch):
from openpilot.starpilot.ui.onroad_map import MapFeed
clock = [100.0]
monkeypatch.setattr(time, 'monotonic', lambda: clock[0])
monkeypatch.setattr(time, 'monotonic_ns', lambda: int(clock[0] * 1e9))
sm = FakeNavigationMaster()
acquisition = Mock(since=clock[0])
acquisition.satellites.return_value = None
feed = MapFeed(sm, acquisition=acquisition)
sm.put_route(1, [(36.0, -115.0), (36.1, -115.1)])
sm.messages["starpilotNavigation"].navigation.instruction = NS(distanceMeters=240.)
sm.updated["mapdOut"] = True
sm.valid["mapdOut"] = True
sm.messages["mapdOut"] = NS(roadName="", wayName="Main Street")
data = feed.read(sm)
assert (data.maneuver_distance_m, data.road_name) == (240., "Main Street")
clock[0] += 2.9
sm.updated["mapdOut"] = False
data = feed.read(sm)
assert data.road_name == "Main Street", "a brief gap in mapd does not blink the name away"
clock[0] += .2
data = feed.read(sm)
assert data.maneuver_distance_m is None, "no stale distance once the route lease lapses"
assert data.road_name == "", "road names expire when mapd stops publishing"
sm.updated["mapdOut"] = True
sm.messages["mapdOut"] = NS(roadName="", wayName="")
assert feed.read(sm).road_name == "", "unnamed roads do not inherit the preceding road's name"
sm.messages["mapdOut"] = NS(roadName="Desert Inn Road", wayName="")
assert feed.read(sm).road_name == "Desert Inn Road"
sm.valid["mapdOut"] = False
assert feed.read(sm).road_name == "", "an invalid road match clears its name"
@@ -10,7 +10,7 @@ from openpilot.cereal import messaging
from openpilot.starpilot.ui.navigation_state import NavigationDisplay, distance_text, navigation_display
from openpilot.starpilot.ui.onroad_navigation import ASSETS, MANIFEST, NavigationCard, arrival_time, icon_name
from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert, OnroadState, SpeedLimitObservation
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.presentation import FontRole, Profile
NOW = 10_000_000_000
DRIVE = 1_000_000_000
@@ -136,7 +136,8 @@ def test_renderer_keeps_actual_instruction_and_distance(profile):
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts, clock=lambda: datetime(2026, 10, 7, 15, 42))
with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \
patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'):
patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'), \
patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rec'):
card.render(state())
text = ' '.join(call.args[0] for call in fonts.draw.call_args_list)
assert 'Turn left onto Main Street' in text
@@ -201,6 +202,32 @@ def test_unchanged_instruction_reuses_measured_layout():
assert fonts.measure.call_count > measured
def test_single_line_instruction_marks_truncated_text_and_has_its_own_cache():
fonts = Mock(profile=Profile.COMPACT)
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts)
text = 'Turn left onto West Charleston Boulevard'
assert len(card._lines(text, 220, 20)) == 2
line, = card._lines(text, 220, 20, max_lines=1)
assert line.endswith('...') and fonts.measure(line, FontRole.SEMI_BOLD, 20).width <= 220
def test_compact_two_line_instruction_clears_then_chip():
fonts = Mock(profile=Profile.COMPACT)
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts)
nav = replace(state().navigation, text='Turn left onto West Charleston Boulevard',
then_type='turn', then_modifier='right', then_distance_m=300.)
with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \
patch.object(rl, 'draw_rectangle_rounded') as rounded, patch.object(rl, 'draw_rectangle_rounded_lines_ex'), \
patch.object(rl, 'draw_texture_pro'):
card.render(replace(state(), navigation=nav))
instruction = [call.args for call in fonts.draw.call_args_list if call.args[1] == FontRole.SEMI_BOLD and call.args[2] == 20]
assert len(instruction) == 2
then_chip = rounded.call_args_list[1].args[0]
assert max(args[4] + args[2] for args in instruction) < then_chip.y
def test_saved_favorite_revision_does_not_expand_navigation_card():
card = NavigationCard(Mock(profile=Profile.COMPACT))
observed = state()
@@ -219,7 +246,8 @@ def test_replay_without_wall_anchor_omits_eta_but_keeps_recorded_navigation():
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts, clock=lambda: None)
with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \
patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'):
patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'), \
patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rec'):
card.render(state())
text = ' '.join(call.args[0] for call in fonts.draw.call_args_list)
assert 'Turn left onto Main Street' in text
@@ -241,3 +269,73 @@ def test_replay_without_wall_anchor_omits_optional_hud_clock():
view.wall_clock = lambda: None
view._clock(observed)
view.fonts.draw.assert_not_called()
def test_then_maneuver_comes_from_the_next_instruction_and_card_shows_it_when_close():
result = message()
result.nextManeuver = {'maneuverType': 'turn', 'maneuverModifier': 'right', 'distanceMeters': 300}
nav = navigation_display(result, now_ns=NOW, drive_id=DRIVE)
assert nav is not None
assert (nav.then_type, nav.then_modifier, nav.then_distance_m) == ('turn', 'right', 300.)
assert navigation_display(message(), now_ns=NOW, drive_id=DRIVE).then_type == ''
fonts = Mock(profile=Profile.LARGE)
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts, clock=lambda: datetime(2026, 10, 7, 15, 42))
with patch.object(card, '_icon', return_value=NS(width=100, height=100)) as icon, \
patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), \
patch.object(rl, 'draw_texture_pro'), patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rec'):
card.render(replace(state(), navigation=nav))
assert 'Then' in [call.args[0] for call in fonts.draw.call_args_list]
assert icon.call_count == 2
fonts.draw.reset_mock()
card.render(replace(state(), navigation=replace(nav, then_distance_m=900.)))
assert 'Then' not in [call.args[0] for call in fonts.draw.call_args_list]
def test_progress_bar_tracks_the_longest_remaining_distance_per_trip():
fonts = Mock(profile=Profile.LARGE)
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts, clock=lambda: datetime(2026, 10, 7, 15, 42))
observed = state()
with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \
patch.object(rl, 'draw_rectangle_rounded') as rounded, patch.object(rl, 'draw_rectangle_rounded_lines_ex'), \
patch.object(rl, 'draw_texture_pro'), patch.object(rl, 'draw_line_ex'):
card.render(observed)
card.render(replace(observed, navigation=replace(observed.navigation, remaining_distance_m=2150.)))
track, fill = (call.args[0] for call in rounded.call_args_list[-2:])
eta_width = len('ETA 3:50 PM') * 28 * .48
assert track.width == pytest.approx(560 - 48 - eta_width - 18), 'the bar stops short of the ETA beside it'
assert fill.width == pytest.approx(track.width * .5)
card.bounds(replace(observed, navigation=replace(observed.navigation, key=('other', 1))))
assert card._total == 0
LANES = ((False, ('straight',), ''), (True, ('straight', 'right'), 'right'), (True, ('right',), 'right'))
def test_lanes_are_read_from_the_instruction():
result = message()
result.instruction = {**result.instruction.to_dict(), 'lanes': [
{'active': False, 'directions': ['straight']}, {'active': True, 'directions': ['straight', 'right'], 'activeDirection': 'right'}]}
assert navigation_display(result, now_ns=NOW, drive_id=DRIVE).lanes == (
(False, ('straight',), ''), (True, ('straight', 'right'), 'right'))
assert navigation_display(message(), now_ns=NOW, drive_id=DRIVE).lanes == ()
def test_lane_strip_shows_near_the_turn_and_replaces_the_then_chip():
fonts = Mock(profile=Profile.LARGE)
fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48)
card = NavigationCard(fonts, clock=lambda: datetime(2026, 10, 7, 15, 42))
nav = replace(state().navigation, lanes=LANES, then_type='turn', then_modifier='right', then_distance_m=300.)
with patch.object(card, '_icon', return_value=NS(width=100, height=100)) as icon, \
patch.object(rl, 'draw_rectangle_rounded') as rounded, patch.object(rl, 'draw_rectangle_rounded_lines_ex'), \
patch.object(rl, 'draw_texture_pro') as textures, patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rec'):
card.render(replace(state(), navigation=nav))
assert rounded.call_count == 1 + len(LANES) + 1, 'the card, one cell per lane and the progress track; no Then chip'
assert 'Then' not in [call.args[0] for call in fonts.draw.call_args_list]
assert textures.call_count == len(LANES), 'the lanes carry the maneuver; no separate icon'
assert [call.args[0] for call in icon.call_args_list[1:]] == ['direction_turn_straight.png', 'direction_turn_right.png', 'direction_turn_right.png']
rounded.reset_mock()
card.render(replace(state(), navigation=replace(nav, distance_m=900., then_distance_m=1200.)))
assert rounded.call_count == 1 + 1 + 1, 'too far for lanes: the card, the Then chip and the progress track'
@@ -175,6 +175,8 @@ class TestRuntimeSnapshot(unittest.TestCase):
adapter = RuntimeSnapshotAdapter(ui)
state = adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad
self.assertTrue(state.lead_detected and state.gas_pressed_override and state.right_hand_drive)
state = adapter.build(ShellMode.ONROAD, now_ns=NOW + 1_000_000_000).onroad
self.assertTrue(state.gas_pressed_override, 'events last until the next once-a-second send, so the blink stays steady')
ui.sm.recv_frame['longitudinalPlan'] = ui.sm.recv_frame['onroadEvents'] = 0
state = adapter.build(ShellMode.ONROAD, now_ns=NOW + 1).onroad
self.assertFalse(state.lead_detected or state.gas_pressed_override)
@@ -43,9 +43,9 @@ def test_projection_order_is_independent_from_native_and_survives_decode():
native['widgetOrder'] = {profile: list(DEFAULT_WIDGET_ORDER[profile]) for profile in PROFILES}
before = copy.deepcopy(native)
projection = default_layout_for_viewport((2880, 1080))
# Legacy AA layouts must not inherit native large ordering.
assert 'large' not in projection_customization(projection, native)['widgetOrder']
# Legacy AA layouts must not inherit native large ordering; they draw the editor's default layers.
default_order = layout_metadata_for_viewport((2880, 1080))['widgetOrder']
assert projection_customization(projection, native)['widgetOrder']['large'] == default_order
projection['widgetOrder'] = [*reversed([key for key in default_order if key != CAR_EXIT]), CAR_EXIT]
decoded = decode_layout_for_viewport(json.dumps(projection).encode(), (2880, 1080))
assert decoded == projection