mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 23:43:44 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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 ||
|
||||
|
||||
@@ -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'] == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user