diff --git a/.gitattributes b/.gitattributes index 8e634483da..07a64921d3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index ce7ba91ecb..8c74b4006f 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -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; } diff --git a/openpilot/starpilot/curve_speed/status.py b/openpilot/starpilot/curve_speed/status.py index 213f5ff139..fbb0ba0944 100644 --- a/openpilot/starpilot/curve_speed/status.py +++ b/openpilot/starpilot/curve_speed/status.py @@ -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) diff --git a/openpilot/starpilot/galaxy/batch_routes.py b/openpilot/starpilot/galaxy/batch_routes.py index ed362787e0..de33961457 100644 --- a/openpilot/starpilot/galaxy/batch_routes.py +++ b/openpilot/starpilot/galaxy/batch_routes.py @@ -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: diff --git a/openpilot/starpilot/galaxy/bin/LICENSE.hostapd b/openpilot/starpilot/galaxy/bin/LICENSE.hostapd new file mode 100644 index 0000000000..fd97bd4e47 --- /dev/null +++ b/openpilot/starpilot/galaxy/bin/LICENSE.hostapd @@ -0,0 +1,422 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: hostapd +Upstream-Contact: Jouni Malinen +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 +License: BSD-3-clause + +Files: hostapd/logwatch/* +Copyright: 2005, Henrik Brix Andersen +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 +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 + 2002-2004, Instant802 Networks, Inc. + 2005-2006, Devicescape Software, Inc. +License: BSD-3-clause + +Files: src/ap/dfs.* +Copyright: 2002-2013, Jouni Malinen + 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 + 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 +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 + 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 + 2000, Antoon Bosselaers + 2000, Paulo Barreto + 2003-2012, Jouni Malinen +License: BSD-3-clause + +Files: src/crypto/des-internal.c +Copyright: 2005, Tom St Denis + 2006-2009, Jouni Malinen +License: BSD-3-clause + +Files: src/crypto/md4-internal.c +Copyright: 1993, Colin Plumb + 2004, Todd C. Miller + 2006, Jouni Malinen +License: BSD-3-clause + +Files: src/crypto/md5-internal.c +Copyright: 1993, Colin Plumb + 2003-2005, Jouni Malinen +License: BSD-3-clause + +Files: src/crypto/sha1-internal.c +Copyright: 1998, Steve Reid + 1998, James H. Brown + 2001, Saul Kravitz + 2001-2005, Jouni Malinen +License: BSD-3-clause + +Files: src/drivers/driver_atheros.c +Copyright: 2004, Sam Leffler + 2004, Video54 Technologies + 2005-2007, Jouni Malinen + 2009, Atheros Communications +License: BSD-3-clause + +Files: src/drivers/driver_bsd.c +Copyright: 2004, Sam Leffler + 2004, 2Wire, Inc +License: BSD-3-clause + +Files: src/drivers/driver_macsec_qca.c +Copyright: 2004, Gunter Burchardt + 2005-2009, Jouni Malinen + 2013-2014, Qualcomm Atheros, Inc. +License: BSD-3-clause + +Files: src/drivers/driver_nl80211.c +Copyright: 2002-2014, Jouni Malinen + 2003-2004, Instant802 Networks, Inc. + 2005-2006, Devicescape Software, Inc. + 2007, Johannes Berg + 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 +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 + 2004, Gunter Burchardt +License: BSD-3-clause + +Files: src/drivers/nl80211_copy.h +Copyright: 2006-2010, Johannes Berg + 2008, Michael Wu + 2008, Luis Carlos Cobo + 2008, Michael Buesch + 2008-2009, Luis R. Rodriguez + 2008, Jouni Malinen + 2008, Colin McCabe +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 +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 + 2005, Sam Leffler +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 +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 + 2009, Johannes Berg +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 +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 +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 and Red Hat, Inc. + 2009-2010, Witold Sowa + 2009-2010, Jouni Malinen +License: BSD-3-clause + +Files: wpa_supplicant/dbus/dbus_dict_helpers.* +Copyright: 2006, Dan Williams 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 , 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 +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 +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 + 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 +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 +License: BSD-3-clause + +Files: debian/* +Copyright: 2004-2006, Kyle McMartin + 2005-2009, Faidon Liambotis + 2006-2008, Reinhard Tartler + 2006-2012, Kel Modderman + 2010, Jan Dittberner + 2010-2014, Stefan Lippers-Hollmann + 2016-2021, Andrej Shadura +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. + diff --git a/openpilot/starpilot/galaxy/bin/hostapd.sha256 b/openpilot/starpilot/galaxy/bin/hostapd.sha256 new file mode 100644 index 0000000000..c727b483d7 --- /dev/null +++ b/openpilot/starpilot/galaxy/bin/hostapd.sha256 @@ -0,0 +1,2 @@ +a435b110265dc32cd646c9ea7a001c41eed7586d7312aadf8eff4464bd60695a hostapd_linux_arm64 +1aa95e8d7831e32fb64a427056540f0963e1d33fcf14f45fd44c87adb1de753a hostapd_cli_linux_arm64 diff --git a/openpilot/starpilot/galaxy/bin/hostapd_cli_linux_arm64 b/openpilot/starpilot/galaxy/bin/hostapd_cli_linux_arm64 new file mode 100755 index 0000000000..5fe0c84154 Binary files /dev/null and b/openpilot/starpilot/galaxy/bin/hostapd_cli_linux_arm64 differ diff --git a/openpilot/starpilot/galaxy/bin/hostapd_linux_arm64 b/openpilot/starpilot/galaxy/bin/hostapd_linux_arm64 new file mode 100755 index 0000000000..9428edbd45 Binary files /dev/null and b/openpilot/starpilot/galaxy/bin/hostapd_linux_arm64 differ diff --git a/openpilot/starpilot/galaxy/hotspot_worker.py b/openpilot/starpilot/galaxy/hotspot_worker.py index 2ab6bf15ba..d2f1892f40 100644 --- a/openpilot/starpilot/galaxy/hotspot_worker.py +++ b/openpilot/starpilot/galaxy/hotspot_worker.py @@ -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.'} diff --git a/openpilot/starpilot/galaxy/tests/test_hotspot.py b/openpilot/starpilot/galaxy/tests/test_hotspot.py index aca69afc4c..4b1f975528 100644 --- a/openpilot/starpilot/galaxy/tests/test_hotspot.py +++ b/openpilot/starpilot/galaxy/tests/test_hotspot.py @@ -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) diff --git a/openpilot/starpilot/galaxy/tests/test_widget_art.mjs b/openpilot/starpilot/galaxy/tests/test_widget_art.mjs index 3752564a12..7cc1efafb3 100644 --- a/openpilot/starpilot/galaxy/tests/test_widget_art.mjs +++ b/openpilot/starpilot/galaxy/tests/test_widget_art.mjs @@ -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() diff --git a/openpilot/starpilot/galaxy/tests/test_widget_art_header.py b/openpilot/starpilot/galaxy/tests/test_widget_art_header.py new file mode 100644 index 0000000000..67f09ec5c2 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_widget_art_header.py @@ -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() diff --git a/openpilot/starpilot/galaxy/web/js/layout-preview.js b/openpilot/starpilot/galaxy/web/js/layout-preview.js index 8f864f399d..deb0271762 100644 --- a/openpilot/starpilot/galaxy/web/js/layout-preview.js +++ b/openpilot/starpilot/galaxy/web/js/layout-preview.js @@ -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() diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index 6e5d529f23..c8a9a6e5de 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -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 = { - - + 500 ft + Turn right onto Las Vegas Blvd + + + + + + - Turn right onto - Las Vegas Blvd - 500 ft - 4.2 mi - 9 min + + + ETA 3:51 PM @@ -293,6 +301,10 @@ export const LayoutWidgetPreview = { + + + Desert Inn Road +