mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 23:43:44 +08:00
backoff logic and improve widget art handling
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user