backoff logic and improve widget art handling

This commit is contained in:
Prabhaav Pillai
2026-11-05 13:15:42 -05:00
parent aab624393e
commit aa631c8e9f
15 changed files with 99 additions and 38 deletions
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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']:
@@ -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():
@@ -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")) })
@@ -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 = {
<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="-Math.max(18, Math.min(widget.width, widget.height) * .075) * 4.4" y="0" :width="Math.max(18, Math.min(widget.width, widget.height) * .075) * 8.8" :height="Math.max(18, Math.min(widget.width, widget.height) * .075) * 1.55" :rx="Math.max(18, Math.min(widget.width, widget.height) * .075) * .78" fill="#080c14be" />
<text x="0" :y="Math.max(18, Math.min(widget.width, widget.height) * .075) * 1.12" text-anchor="middle" :font-size="Math.max(18, Math.min(widget.width, widget.height) * .075)" fill="#eceef6">Desert Inn Road</text>
<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>
@@ -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))
+2 -1
View File
@@ -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
@@ -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)
@@ -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."""
@@ -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'] = {}
@@ -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())
+9 -7
View File
@@ -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 ''
+9 -9
View File
@@ -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 <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
@@ -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"
@@ -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()