diff --git a/openpilot/starpilot/galaxy/batch_routes.py b/openpilot/starpilot/galaxy/batch_routes.py
index 922fe89b3a..de33961457 100644
--- a/openpilot/starpilot/galaxy/batch_routes.py
+++ b/openpilot/starpilot/galaxy/batch_routes.py
@@ -54,7 +54,7 @@ def delete_records(self, path, payload, sentry_events_lock, crash_reports_lock,
def widget_art_header(image):
- """The renderer's widget-art tEXt chunk, base64-encoded for an HTTP header ('' when absent or oversized)."""
+ """Encode bounded PNG sprite metadata for the preview response header."""
import base64
import struct
signature = b'starpilot-widget-art\0'
diff --git a/openpilot/starpilot/galaxy/hotspot_worker.py b/openpilot/starpilot/galaxy/hotspot_worker.py
index 4c79391efa..d2f1892f40 100644
--- a/openpilot/starpilot/galaxy/hotspot_worker.py
+++ b/openpilot/starpilot/galaxy/hotspot_worker.py
@@ -258,7 +258,7 @@ class HotspotController:
self.failures = 0
def backoff(self):
- return min(RETRY_S * 2 ** self.failures, MAX_RETRY_S)
+ return min(RETRY_S * 2 ** min(self.failures, 5), MAX_RETRY_S)
def tick(self, config):
if not config['enabled']:
diff --git a/openpilot/starpilot/galaxy/tests/test_hotspot.py b/openpilot/starpilot/galaxy/tests/test_hotspot.py
index ee2a5b011d..4b1f975528 100644
--- a/openpilot/starpilot/galaxy/tests/test_hotspot.py
+++ b/openpilot/starpilot/galaxy/tests/test_hotspot.py
@@ -360,6 +360,8 @@ def test_repeated_failures_back_off_and_success_resets():
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():
diff --git a/openpilot/starpilot/galaxy/web/js/layout-preview.js b/openpilot/starpilot/galaxy/web/js/layout-preview.js
index 65e99b0db4..deb0271762 100644
--- a/openpilot/starpilot/galaxy/web/js/layout-preview.js
+++ b/openpilot/starpilot/galaxy/web/js/layout-preview.js
@@ -59,7 +59,7 @@ 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.
-// Room for a rendered PNG to cross the Galaxy tunnel, not just the local network.
+// Allow for Galaxy tunnel latency.
const PREVIEW_TIMEOUT_MS = 20000
export class PreviewQueue {
@@ -184,8 +184,7 @@ export class LayoutPreviewFeed {
this.last = item
const request = new AbortController()
this.request = request
- // The clock starts once this feed holds the renderer: waiting behind the other feed (slow over a tunnel)
- // used to eat the whole budget and flip the backdrop to "unavailable" before it was even sent.
+ // 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")) })
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js
index db0beb1438..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) {
@@ -299,8 +302,8 @@ export const LayoutWidgetPreview = {
-
- Desert Inn Road
+
+ Desert Inn Road
diff --git a/openpilot/starpilot/galaxy/web/js/widget-art.js b/openpilot/starpilot/galaxy/web/js/widget-art.js
index 3cd292e0f9..7a9d8fe5de 100644
--- a/openpilot/starpilot/galaxy/web/js/widget-art.js
+++ b/openpilot/starpilot/galaxy/web/js/widget-art.js
@@ -1,6 +1,5 @@
// Pixels and sprite locations come from the actual onroad renderer, not browser widget copies.
-// The sheet also travels in an X-Widget-Art header: image-optimising proxies (such as the one in front of the
-// Galaxy tunnel) re-encode PNGs and drop the tEXt chunk, which used to reject the art over the tunnel.
+// 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))
diff --git a/openpilot/starpilot/soloist/supervisor.py b/openpilot/starpilot/soloist/supervisor.py
index 6e70d05501..ecea0d5050 100644
--- a/openpilot/starpilot/soloist/supervisor.py
+++ b/openpilot/starpilot/soloist/supervisor.py
@@ -441,6 +441,7 @@ 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:
@@ -476,7 +477,7 @@ class Supervisor:
def _retry_login(self, now: float) -> None:
self.stop_children()
self.login_failures += 1
- delay = min(LOGIN_RETRY_MAX_SECONDS, 5.0 * 2 ** (self.login_failures - 1)) * random.uniform(0.8, 1.2)
+ 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
diff --git a/openpilot/starpilot/soloist/tests/test_connect.py b/openpilot/starpilot/soloist/tests/test_connect.py
index 6398bcdbed..12917c27a8 100644
--- a/openpilot/starpilot/soloist/tests/test_connect.py
+++ b/openpilot/starpilot/soloist/tests/test_connect.py
@@ -129,6 +129,25 @@ def test_first_pairing_and_audio_lines_never_trigger_a_login_retry(tmp_path, mon
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)
diff --git a/openpilot/starpilot/system/starport/car_interface.py b/openpilot/starpilot/system/starport/car_interface.py
index 4a7da3cc1d..96fa012501 100644
--- a/openpilot/starpilot/system/starport/car_interface.py
+++ b/openpilot/starpilot/system/starport/car_interface.py
@@ -100,7 +100,7 @@ class DedicatedLease:
self._opening: threading.Thread | None = None
self._open_error: Exception | None = None
self._cancel = threading.Event()
- self.home = None # a hold on wlan0's autoconnect, from hold_home_wifi until the session ends
+ self.home = None
self._home_lock = threading.Lock()
@property
@@ -109,7 +109,7 @@ class DedicatedLease:
@property
def dedicated(self) -> bool:
- """The car is on aa0, sharing the radio with wlan0; the supervisor's radio watch keys off this."""
+ """Whether the car uses aa0 on the shared radio."""
return not self.legacy
@property
@@ -159,7 +159,7 @@ class DedicatedLease:
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.home is not None:
+ 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:
@@ -184,6 +184,7 @@ class DedicatedLease:
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:
@@ -194,7 +195,6 @@ class DedicatedLease:
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."""
diff --git a/openpilot/starpilot/system/starport/projection_layout.py b/openpilot/starpilot/system/starport/projection_layout.py
index 84a1ae282c..57c936995f 100644
--- a/openpilot/starpilot/system/starport/projection_layout.py
+++ b/openpilot/starpilot/system/starport/projection_layout.py
@@ -385,8 +385,7 @@ 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)
- # Without a saved order the car screen draws the same default layers the theme maker shows,
- # instead of the native view's unordered fallback (which puts the map over PiP).
+ # 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'] = {}
diff --git a/openpilot/starpilot/system/starport/tests/test_car_interface.py b/openpilot/starpilot/system/starport/tests/test_car_interface.py
index 612b766ef8..84a623bf0a 100644
--- a/openpilot/starpilot/system/starport/tests/test_car_interface.py
+++ b/openpilot/starpilot/system/starport/tests/test_car_interface.py
@@ -179,6 +179,14 @@ def test_home_wifi_is_never_held_while_the_car_uses_it():
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())
diff --git a/openpilot/starpilot/ui/onroad_map.py b/openpilot/starpilot/ui/onroad_map.py
index fb65fb7701..21be86e1ba 100644
--- a/openpilot/starpilot/ui/onroad_map.py
+++ b/openpilot/starpilot/ui/onroad_map.py
@@ -59,6 +59,7 @@ 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
@@ -788,7 +789,7 @@ class MapOverlay:
rect.y + rect.height * CAR_ANCHOR + 40, rl.Color(225, 228, 238, round(200 * opacity)))
def _draw_road_name(self, rect: rl.Rectangle) -> None:
- """The road under the car, in a pill just below it; solid whatever the map's opacity so it always reads."""
+ """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
@@ -920,6 +921,7 @@ class MapFeed:
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
@@ -986,19 +988,19 @@ 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._maneuver_m if route else None, self._road_name())
+ 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) -> str:
- """The last road name mapd reported."""
- if self.sm.updated["mapdOut"] and self.sm.valid["mapdOut"]:
+ 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 or self._road
- return self._road
+ 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 ''
diff --git a/openpilot/starpilot/ui/onroad_navigation.py b/openpilot/starpilot/ui/onroad_navigation.py
index d868fa4237..8c4d202287 100644
--- a/openpilot/starpilot/ui/onroad_navigation.py
+++ b/openpilot/starpilot/ui/onroad_navigation.py
@@ -98,8 +98,8 @@ class NavigationCard:
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(), [], ''
@@ -111,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:
@@ -147,14 +147,14 @@ class NavigationCard:
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
- distance_size, text_size = (36, 22) if compact else (54, 30 if lanes else 28)
+ 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)
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)
- for i, text in enumerate(lines[:1] if far or lanes else lines):
+ 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
@@ -174,7 +174,7 @@ class NavigationCard:
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 if compact else 7), chip, state.metric)
+ 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'
trip_size = 27
@@ -203,7 +203,7 @@ class NavigationCard:
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, metric):
+ def _then(self, nav, x, y, size):
"""A "Then " chip when the turn after this one comes soon after it."""
if not nav.then_type or nav.then_distance_m is None:
return
diff --git a/openpilot/starpilot/ui/tests/test_onroad_map.py b/openpilot/starpilot/ui/tests/test_onroad_map.py
index 68e990b9a3..cf7f248093 100644
--- a/openpilot/starpilot/ui/tests/test_onroad_map.py
+++ b/openpilot/starpilot/ui/tests/test_onroad_map.py
@@ -810,15 +810,18 @@ def test_map_feed_reports_maneuver_distance_and_road_name(monkeypatch):
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] += 3.1
+ 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 == "Main Street", "a gap in mapd does not blink the name away"
+ 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 == "Main Street", "nor does a briefly unnamed way"
- clock[0] += 60.
- assert feed.read(sm).road_name == "Main Street", "it stays until a new name arrives"
+ 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"
diff --git a/openpilot/starpilot/ui/tests/test_onroad_navigation.py b/openpilot/starpilot/ui/tests/test_onroad_navigation.py
index dd25209ab0..3bf66edafc 100644
--- a/openpilot/starpilot/ui/tests/test_onroad_navigation.py
+++ b/openpilot/starpilot/ui/tests/test_onroad_navigation.py
@@ -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
@@ -202,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()