mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
Add optional onroad weather widget
This commit is contained in:
@@ -168,6 +168,7 @@ export const ONROAD_LAYOUT_TEMPLATE = `
|
||||
<p class="gx-note">Drag widgets to place them. Behind them is the comma's own screen for the chosen scene, including its border, road and alerts. Readings are samples.</p>
|
||||
<p class="gx-note">Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
|
||||
<p class="gx-note">General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.</p>
|
||||
<p v-if="selectedWidget?.kind === 'weather'" class="gx-note">Weather data by <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>. Uses your GPS area when enabled; updates in the background about every 10 minutes.</p>
|
||||
<p class="gx-note">The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.</p>
|
||||
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout.{{ profile.widgets.nav_map ? ' The map overlay has adjustable size and opacity.' : '' }} Existing display preferences and driving state still control when widgets appear.</p>
|
||||
<p v-if="!projection" class="gx-note">Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.</p>
|
||||
|
||||
@@ -192,6 +192,19 @@ export const LayoutWidgetPreview = {
|
||||
<text :x="widget.width / 2" :y="widget.height * 101 / 112" text-anchor="middle" dominant-baseline="middle" :font-size="widget.height * 30 / 112" font-weight="500" fill="#fffffff2"
|
||||
style="filter: drop-shadow(2px 2px 1px #000000aa)">14 seconds</text>
|
||||
</template>
|
||||
<g v-else-if="widget.kind === 'weather'">
|
||||
<rect x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" :rx="widget.height * .1"
|
||||
:fill="palette.cardFill" :stroke="palette.cardBorder" />
|
||||
<svg :x="widget.height * .1" :y="widget.height * .05" :width="widget.height * .8" :height="widget.height * .8" viewBox="0 0 80 80">
|
||||
<g fill="none" stroke="#c7aef7" stroke-width="3" stroke-linecap="round">
|
||||
<circle cx="27" cy="28" r="13" fill="#c7aef7" />
|
||||
<path d="M27 6v-4 M27 50v4 M5 28H1 M49 28h4 M11 12l-3-3 M43 12l3-3 M11 44l-3 3" />
|
||||
</g>
|
||||
<path d="M20 61a13 13 0 0 1 0-26 18 18 0 0 1 34-3 14 14 0 1 1 4 29Z" fill="#edf0ff" />
|
||||
</svg>
|
||||
<text :x="widget.height" :y="widget.height * .55" :font-size="profile === 'large' ? 44 : 24" font-weight="600">70°F</text>
|
||||
<text :x="widget.height" :y="widget.height * .88" :font-size="profile === 'large' ? 16 : 8" fill="#c7aef7">Open-Meteo</text>
|
||||
</g>
|
||||
<template v-else-if="widget.kind === 'clock'">
|
||||
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" dominant-baseline="middle"
|
||||
:font-size="profile === 'large' ? 48 : 21" font-weight="600"
|
||||
|
||||
@@ -74,7 +74,8 @@ def sample_state(scene: str, document: dict) -> OnroadState:
|
||||
lateral = scene not in ('long_only', 'slc_disengaged')
|
||||
longitudinal = scene not in ('aol', 'slc_disengaged')
|
||||
pending = scene in ('slc_pending', 'slc_notice')
|
||||
state = OnroadState(engaged=lateral or longitudinal, camera_available=False,
|
||||
from openpilot.starpilot.ui.weather import WeatherReading
|
||||
state = OnroadState(weather=WeatherReading(21, 2, True), engaged=lateral or longitudinal, camera_available=False,
|
||||
speed_mps=0.0 if scene in ("cem_lead", "stopped") else 22.0, cruise_kph=88.0,
|
||||
stopped_duration_s=PREVIEW_DURATION_S if scene == "stopped" else None,
|
||||
speed_limit=SpeedLimitObservation(kind=ObservationKind.VALID, source="dashboard",
|
||||
|
||||
@@ -288,6 +288,16 @@ class OnroadView:
|
||||
self.fonts.draw(text, role, size, x + offset_px, y + offset_px, rl.Color(0, 0, 0, min(color.a, 170)))
|
||||
self.fonts.draw(text, role, size, x, y, color)
|
||||
|
||||
def _weather(self, state: OnroadState, center_shift: float = 0) -> None:
|
||||
if (not placement(state.customization, self.fonts.profile, 'weather')['enabled'] or
|
||||
state.alert.size != AlertSize.NONE or state.appearance.camera_view == CameraViewChoice.DRIVER or state.reverse_driver_camera):
|
||||
return
|
||||
from openpilot.starpilot.ui.weather import WeatherClient
|
||||
from openpilot.starpilot.ui.onroad_weather import render_weather
|
||||
if not hasattr(self, '_weather_client'):
|
||||
self._weather_client = WeatherClient()
|
||||
render_weather(self.fonts, state, self._weather_client, center_shift)
|
||||
|
||||
def _ordered_widgets(self, content, state, *, driver_camera=False, stock_layer=None, only_widget=None):
|
||||
profile = str(self.fonts.profile)
|
||||
large = profile == "large"
|
||||
@@ -354,6 +364,7 @@ class OnroadView:
|
||||
submit("driver_monitor", lambda: monitor(content, state))
|
||||
submit(MODE_WIDGET, lambda: self._driving_mode(state, (width - 1860) / 2 if large else 0))
|
||||
submit(CLOCK_WIDGET, lambda: self._clock(state, (width - 1860) / 2 if large else 0))
|
||||
submit("weather", lambda: self._weather(state, (width - 1860) / 2 if large else 0))
|
||||
pending = manual_slc_confirmation(self.fonts.profile, state)
|
||||
for key in widget_order(state.customization, profile):
|
||||
if only_widget is not None and key != only_widget:
|
||||
@@ -434,6 +445,7 @@ class OnroadView:
|
||||
jetlink.render(state)
|
||||
self._driving_mode(state, right_shift / 2)
|
||||
self._clock(state, right_shift / 2)
|
||||
self._weather(state, right_shift / 2)
|
||||
if exit_layer := getattr(self, "projection_exit_layer", None):
|
||||
exit_layer(state)
|
||||
if not ordered and manual_slc_confirmation(self.fonts.profile, state):
|
||||
@@ -529,6 +541,7 @@ class OnroadView:
|
||||
if not ordered:
|
||||
self._driving_mode(state)
|
||||
self._clock(state)
|
||||
self._weather(state)
|
||||
if not ordered and not driver_camera and manual_slc_confirmation(self.fonts.profile, state):
|
||||
self._slc_actions(state)
|
||||
signals = state.border_signals
|
||||
|
||||
@@ -179,6 +179,13 @@ for _profile, (_x, _y) in {"compact": (18, 48), "large": (600, 520)}.items():
|
||||
_timer_widget["note"] = "Counts how long you have been stopped, after the first minute of the drive"
|
||||
PROFILES[_profile]["widgets"][STOPPED_TIMER_WIDGET] = _timer_widget
|
||||
|
||||
WEATHER_WIDGET = "weather"
|
||||
for _profile, _geometry in {"large": (280, 100, 640, 540), "compact": (112, 48, 172, 174)}.items():
|
||||
_weather_widget = _widget("Weather", WEATHER_WIDGET, *_geometry)
|
||||
_weather_widget["default"]["enabled"] = False
|
||||
_weather_widget["note"] = "Outside temperature and conditions. Weather by Open-Meteo.com; requires internet and a GPS fix."
|
||||
PROFILES[_profile]["widgets"][WEATHER_WIDGET] = _weather_widget
|
||||
|
||||
NAV_CARD_WIDGET, NAV_MAP_WIDGET = "nav_card", "nav_map"
|
||||
NAV_CARD_SIZE = (560, 195)
|
||||
MAP_MIN_SIZE = (280, 200)
|
||||
@@ -216,7 +223,7 @@ PROFILES['large']['widgets'][CEM_GAUGE_WIDGET] = _widget('CEM road gauge', CEM_G
|
||||
ROAD_BASE_WIDTH = 1860
|
||||
SIDEBAR_EXTRA = 300
|
||||
ANCHOR_SHIFT = {"steering_wheel": 1.0, NAV_CARD_WIDGET: 1.0, NAV_MAP_WIDGET: 1.0,
|
||||
"current_speed": .5, "torque_bar": .5, MODE_WIDGET: .5, CLOCK_WIDGET: .5}
|
||||
"current_speed": .5, "torque_bar": .5, MODE_WIDGET: .5, CLOCK_WIDGET: .5, WEATHER_WIDGET: .5}
|
||||
for _key, _widget_data in PROFILES["large"]["widgets"].items():
|
||||
if _key in ANCHOR_SHIFT:
|
||||
_widget_data["anchorShift"] = ANCHOR_SHIFT[_key]
|
||||
@@ -224,7 +231,8 @@ for _key, _widget_data in PROFILES["large"]["widgets"].items():
|
||||
_widget_data["bounds"] = {**_base, "x": _base["x"] - ANCHOR_SHIFT.get(_key, 0) * SIDEBAR_EXTRA, "width": _base["width"] + SIDEBAR_EXTRA}
|
||||
_widget_data["expandable"] = True
|
||||
# Older documents retain every saved placement and gain this independent widget.
|
||||
OPTIONAL_WIDGETS = frozenset({MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_WIDGET, NAV_CARD_WIDGET, NAV_MAP_WIDGET, JETLINK_WIDGET})
|
||||
OPTIONAL_WIDGETS = frozenset({WEATHER_WIDGET, MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_WIDGET,
|
||||
NAV_CARD_WIDGET, NAV_MAP_WIDGET, JETLINK_WIDGET})
|
||||
|
||||
# The torque arc grows when the developer sidebar closes; protect both road widths.
|
||||
_torque = PROFILES['large']['widgets']['torque_bar']
|
||||
@@ -238,8 +246,9 @@ _torque['confirmationObstacle'] = {
|
||||
|
||||
# These editor sprites use the runtime components and their canonical assets/fonts.
|
||||
RUNTIME_PREVIEW_WIDGETS = {
|
||||
"large": ("current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "following_distance", "driver_monitor", "speed_limit_actions"),
|
||||
"compact": ("max_speed", "speed_limit", "steering_wheel", "conditional_mode", "following_distance",
|
||||
"large": (WEATHER_WIDGET, "current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "following_distance",
|
||||
"driver_monitor", "speed_limit_actions"),
|
||||
"compact": (WEATHER_WIDGET, "max_speed", "speed_limit", "steering_wheel", "conditional_mode", "following_distance",
|
||||
"model_confidence", "driver_monitor", "speed_limit_actions"),
|
||||
}
|
||||
|
||||
@@ -249,6 +258,7 @@ WIDGET_COLORS = {
|
||||
"large": {
|
||||
MODE_WIDGET: {"text": "#FFFFFFFF"},
|
||||
CLOCK_WIDGET: {"text": "#FFFFFFFF"},
|
||||
WEATHER_WIDGET: {"cardFill": "#0F0D17CC", "cardBorder": "#C7AEF744", "text": "#F5F2FCFF"},
|
||||
CEM_GAUGE_WIDGET: {},
|
||||
"current_speed": {"text": None},
|
||||
"cruise_limits": {"cardFill": "#10191FFF", "cardBorder": "#35434DFF", "text": None},
|
||||
@@ -260,6 +270,7 @@ WIDGET_COLORS = {
|
||||
"compact": {
|
||||
MODE_WIDGET: {"text": "#FFFFFFFF"},
|
||||
CLOCK_WIDGET: {"text": "#FFFFFFFF"},
|
||||
WEATHER_WIDGET: {"cardFill": "#0F0D17CC", "cardBorder": "#C7AEF744", "text": "#F5F2FCFF"},
|
||||
"max_speed": {"text": None},
|
||||
"speed_limit_actions": {**_ACTION_COLORS, "text": "#F1F6F3FF"},
|
||||
"driver_monitor": _FRAME_COLORS,
|
||||
@@ -273,10 +284,10 @@ WIDGET_COLORS = {
|
||||
# Back-to-front order used when a layout first opts into custom layers.
|
||||
DEFAULT_WIDGET_ORDER = {
|
||||
"large": (NAV_MAP_WIDGET, "pip_left", "pip_right", "cruise_limits", "speed_limit_actions", CEM_GAUGE_WIDGET, "current_speed",
|
||||
"steering_wheel", "torque_bar", "following_distance", "driver_monitor", MODE_WIDGET, CLOCK_WIDGET,
|
||||
"steering_wheel", "torque_bar", "following_distance", "driver_monitor", MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET,
|
||||
STOPPED_TIMER_WIDGET, NAV_CARD_WIDGET, JETLINK_WIDGET),
|
||||
"compact": ("pip_left", "pip_right", "speed_limit", "max_speed", "steering_wheel", "torque_bar",
|
||||
"speed_limit_actions", "driver_monitor", *RAIL_WIDGETS, MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET),
|
||||
"speed_limit_actions", "driver_monitor", *RAIL_WIDGETS, MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET, STOPPED_TIMER_WIDGET),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import math
|
||||
from typing import Protocol
|
||||
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
from openpilot.starpilot.ui.weather import WeatherReading
|
||||
from openpilot.starpilot.ui.large_speed_geometry import ACTION_BOUNDS, LIMIT_RECTS, confirmation_bounds, confirmation_buttons
|
||||
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance
|
||||
from openpilot.starpilot.curve_speed.status import CurveObservation
|
||||
@@ -243,6 +244,8 @@ class OnroadState:
|
||||
return self.longitudinal_active or self.longitudinal_overridden or self.stock_cruise_active
|
||||
|
||||
stop_sign_observed_ns: int = 0
|
||||
weather_location: tuple[float, float] | None = None
|
||||
weather: WeatherReading | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.speed_mps is not None and (not math.isfinite(self.speed_mps) or self.speed_mps < 0):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Small vector weather art in the onroad widget palette."""
|
||||
|
||||
import math
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
from openpilot.starpilot.ui.presentation import FontRole
|
||||
from openpilot.starpilot.ui.onroad_customization import placement, rgba, widget_size
|
||||
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
|
||||
|
||||
def draw_weather_icon(x, y, size, code, is_day):
|
||||
def point(a, b):
|
||||
return rl.Vector2(x + a * size, y + b * size)
|
||||
def line(a, b, c, d, color, thickness=.045):
|
||||
rl.draw_line_ex(point(a, b), point(c, d), max(1, size * thickness), color)
|
||||
lavender, blue, white = rl.Color(199, 174, 247, 255), rl.Color(125, 202, 255, 255), rl.Color(237, 240, 255, 255)
|
||||
if code is None:
|
||||
white = rl.Color(145, 151, 166, 200)
|
||||
if code in (0, 1, 2):
|
||||
if is_day:
|
||||
rl.draw_circle_v(point(.38, .37), size * .17, lavender)
|
||||
for angle in range(0, 360, 45):
|
||||
a = math.radians(angle)
|
||||
line(.38 + .23 * math.cos(a), .37 + .23 * math.sin(a),
|
||||
.38 + .30 * math.cos(a), .37 + .30 * math.sin(a), lavender)
|
||||
else:
|
||||
for step in range(16):
|
||||
a, b = (math.radians(55 + index * 250 / 16) for index in (step, step + 1))
|
||||
thickness = .03 + .14 * math.sin(math.pi * (step + .5) / 16)
|
||||
line(.42 + .25 * math.cos(a), .35 + .25 * math.sin(a),
|
||||
.42 + .25 * math.cos(b), .35 + .25 * math.sin(b), lavender, thickness)
|
||||
rl.draw_circle_v(point(.42 + .25 * math.cos(b), .35 + .25 * math.sin(b)), size * thickness / 2, lavender)
|
||||
rl.draw_circle_v(point(.77, .19), size * .035, blue)
|
||||
if code == 0:
|
||||
return
|
||||
rl.draw_circle_v(point(.32, .55), size * .18, white)
|
||||
rl.draw_circle_v(point(.52, .44), size * .23, white)
|
||||
rl.draw_circle_v(point(.74, .57), size * .16, white)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(x + size * .23, y + size * .54, size * .58, size * .19), .8, 8, white)
|
||||
if code in (45, 48):
|
||||
for row in (.82, .94):
|
||||
line(.22, row, .8, row, blue, .035)
|
||||
elif code in (71, 73, 75, 77, 85, 86):
|
||||
for column in (.32, .56, .78):
|
||||
rl.draw_circle_v(point(column, .9), size * .04, blue)
|
||||
elif code in (95, 96, 99):
|
||||
line(.57, .7, .43, .87, lavender, .07)
|
||||
line(.43, .87, .62, .84, lavender, .07)
|
||||
line(.62, .84, .48, 1., lavender, .07)
|
||||
elif code is not None and code >= 51:
|
||||
for column in (.32, .56, .78):
|
||||
line(column, .81, column - .06, .98, blue)
|
||||
|
||||
|
||||
def render_weather(fonts, state, client, center_shift=0):
|
||||
profile = fonts.profile
|
||||
position = placement(state.customization, profile, 'weather')
|
||||
width, height = widget_size(state.customization, profile, 'weather')
|
||||
x, y = position['x'] + center_shift, position['y']
|
||||
reading = state.weather or client.observe(state.weather_location)
|
||||
draw_widget_frame(rl.Rectangle(x, y, width, height), state.customization, profile, 'weather')
|
||||
large = profile == Profile.LARGE
|
||||
size = 44 if large else 24
|
||||
icon_size = height * .76
|
||||
draw_weather_icon(x + height * .09, y + height * .1, icon_size,
|
||||
reading.code if reading else None, reading.is_day if reading else True)
|
||||
text = '--°' if reading is None else f'{reading.temperature_c if state.metric else reading.temperature_c * 1.8 + 32:.0f}°'
|
||||
text += 'C' if state.metric else 'F'
|
||||
color = rl.Color(*rgba(state.customization, 'text', profile, 'weather'))
|
||||
fonts.draw(text, FontRole.SEMI_BOLD, size, x + height, y + height * .09, color)
|
||||
fonts.draw('Open-Meteo' if reading else 'Outside', FontRole.NORMAL, 16 if large else 8,
|
||||
x + height, y + height * .69, rl.Color(199, 174, 247, min(color.a, 220)))
|
||||
@@ -686,7 +686,16 @@ class RuntimeSnapshotAdapter:
|
||||
stop_evidence = (read_stop_evidence(sm, now_ns=now_ns, now_boot_ns=now_ns + pair[2],
|
||||
drive_id=drive_id, after_frame=after) if started and pair is not None else None)
|
||||
stop_observed_ns = self._vision_stop.observe(stop_evidence, now_ns=now_ns)
|
||||
onroad = OnroadState(developer_metrics=developer_metrics, stop_sign_observed_ns=stop_observed_ns,
|
||||
weather_location = None
|
||||
if (started and getattr(ui, 'replay_clock', None) is None and
|
||||
any(layout.get('weather', {}).get('enabled', False) for layout in self._customization_value['layouts'].values())):
|
||||
from openpilot.starpilot.ui.weather import valid_location
|
||||
for service in ('gpsLocationExternal', 'gpsLocation'):
|
||||
gps = current_message(sm, service, now_ns, after_frame=after)
|
||||
if gps is not None and gps.hasFix and valid_location((gps.latitude, gps.longitude)):
|
||||
weather_location = (gps.latitude, gps.longitude)
|
||||
break
|
||||
onroad = OnroadState(weather_location=weather_location, developer_metrics=developer_metrics, stop_sign_observed_ns=stop_observed_ns,
|
||||
engaged=display_lat_active or display_long_active or longitudinal_overridden, camera_available=started,
|
||||
speed_mps=raw_speed, cruise_kph=cruise, speed_limit=observation,
|
||||
toyota_cruise_status=str(toyota.status) if toyota is not None else '',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Optional weather stays off the driving path and never invents a temperature."""
|
||||
import copy
|
||||
from dataclasses import replace
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document, customization_metadata
|
||||
from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert, OnroadState, SpeedLimitObservation
|
||||
from openpilot.starpilot.ui.weather import WeatherClient, WeatherReading, fetch_weather, valid_location, nearby
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile', ('large', 'compact'))
|
||||
def test_weather_default_off_and_saved_layouts_keep_their_positions(profile):
|
||||
document = default_document()
|
||||
assert not document['layouts'][profile]['weather']['enabled']
|
||||
assert 'Open-Meteo.com' in customization_metadata()['profiles'][profile]['widgets']['weather']['note']
|
||||
old = copy.deepcopy(document)
|
||||
for widgets in old['layouts'].values():
|
||||
del widgets['weather']
|
||||
old['widgetOrder'] = {name: list(widgets) for name, widgets in old['layouts'].items()}
|
||||
saved = copy.deepcopy(old)
|
||||
migrated = validate_document(old)
|
||||
assert old == saved
|
||||
for name in ('large', 'compact'):
|
||||
assert not migrated['layouts'][name]['weather']['enabled']
|
||||
assert [key for key in migrated['widgetOrder'][name] if key != 'weather'] == saved['widgetOrder'][name]
|
||||
assert {key: value for key, value in migrated['layouts'][name].items() if key != 'weather'} == saved['layouts'][name]
|
||||
document['layouts'][profile]['weather']['enabled'] = True
|
||||
assert validate_document(document)['layouts'][profile]['weather']['enabled']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('point', (None, (float('nan'), 0), (91, 0), (0, 181), (True, 5), (1,), '0,0'))
|
||||
def test_bad_location_never_starts_network_work(point):
|
||||
with patch('openpilot.starpilot.ui.weather.threading.Thread') as thread:
|
||||
assert not valid_location(point)
|
||||
assert WeatherClient().observe(point) is None
|
||||
thread.assert_not_called()
|
||||
|
||||
|
||||
def test_weather_is_nonblocking_single_flight_cached_and_expires():
|
||||
now = [1000.]
|
||||
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
|
||||
def fetch(point):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
return WeatherReading(-5, 73, False)
|
||||
client = WeatherClient(fetch=fetch, clock=lambda: now[0])
|
||||
original = client._refresh
|
||||
def refresh(point):
|
||||
try:
|
||||
original(point)
|
||||
finally:
|
||||
finished.set()
|
||||
client._refresh = refresh
|
||||
try:
|
||||
start = time.monotonic()
|
||||
assert client.observe((41., -87.)) is None
|
||||
assert time.monotonic() - start < .5
|
||||
assert entered.wait(1)
|
||||
for _ in range(10):
|
||||
assert client.observe((41., -87.)) is None
|
||||
release.set()
|
||||
assert finished.wait(1)
|
||||
assert client.observe((41., -87.)) == WeatherReading(-5, 73, False)
|
||||
assert client.observe((45., -87.)) is None
|
||||
assert client.observe(None) is None
|
||||
client._next_request = 10000
|
||||
now[0] += 1801
|
||||
assert client.observe((41., -87.)) is None
|
||||
finally:
|
||||
release.set()
|
||||
assert finished.wait(2)
|
||||
|
||||
|
||||
def test_failed_request_backs_off_and_keeps_a_still_current_reading():
|
||||
now = [1000.]
|
||||
fetch = Mock(side_effect=OSError('offline'))
|
||||
client = WeatherClient(fetch=fetch, clock=lambda: now[0])
|
||||
client._reading, client._position, client._received = WeatherReading(20, 0, True), (0, 0), 900
|
||||
with patch('openpilot.starpilot.ui.weather.threading.Thread') as thread:
|
||||
thread.return_value.start.side_effect = lambda: client._refresh((0, 0))
|
||||
assert client.observe((0, 0)).temperature_c == 20
|
||||
for _ in range(10):
|
||||
client.observe((0, 0))
|
||||
assert fetch.call_count == 1
|
||||
assert not client._running
|
||||
now[0] += 600
|
||||
client.observe((0, 0))
|
||||
assert fetch.call_count == 2
|
||||
|
||||
|
||||
def test_dateline_is_nearby():
|
||||
assert nearby((0, 179.99), (0, -179.99))
|
||||
assert not nearby((0, 0), (0, 1))
|
||||
|
||||
|
||||
def response(**changes):
|
||||
data = {'current_units': {'temperature_2m': '°C'}, 'current': {'temperature_2m': 21, 'weather_code': 2,
|
||||
'is_day': 1, 'time': 10000}}
|
||||
data['current'].update(changes)
|
||||
return json.dumps(data).encode()
|
||||
|
||||
|
||||
def test_actual_http_decoder_and_bounded_celsius_request():
|
||||
with patch('urllib.request.urlopen', return_value=io.BytesIO(response())) as get, patch('openpilot.starpilot.ui.weather.datetime') as clock:
|
||||
clock.now.return_value.timestamp.return_value = 10000
|
||||
assert fetch_weather((41.1234, -87.1234)) == WeatherReading(21, 2, True)
|
||||
request = get.call_args.args[0]
|
||||
assert request.full_url.startswith('https://api.open-meteo.com/v1/forecast?')
|
||||
assert 'latitude=41.12' in request.full_url and 'temperature_unit=celsius' in request.full_url
|
||||
assert get.call_args.kwargs['timeout'] == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', ({'temperature_2m': float('nan')}, {'temperature_2m': True}, {'weather_code': 999},
|
||||
{'is_day': 2}, {'time': 0}, {'time': 11000}))
|
||||
def test_malformed_and_stale_weather_is_refused(changes):
|
||||
with patch('urllib.request.urlopen', return_value=io.BytesIO(response(**changes))), patch('openpilot.starpilot.ui.weather.datetime') as clock:
|
||||
clock.now.return_value.timestamp.return_value = 10000
|
||||
with pytest.raises(ValueError):
|
||||
fetch_weather((0, 0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('profile,metric,expected', (('large', True, '21°C'), ('large', False, '70°F'),
|
||||
('compact', True, '21°C'), ('compact', False, '70°F')))
|
||||
def test_native_widget_uses_units_and_real_vector_art(profile, metric, expected):
|
||||
from openpilot.starpilot.ui import onroad_weather
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
fonts = Mock(profile=Profile(profile))
|
||||
state = OnroadState(False, False, None, None, SpeedLimitObservation(), metric=metric,
|
||||
weather=WeatherReading(21, 2, True))
|
||||
with patch.object(onroad_weather, 'draw_widget_frame'), patch.object(onroad_weather, 'draw_weather_icon') as icon:
|
||||
onroad_weather.render_weather(fonts, state, Mock())
|
||||
assert fonts.draw.call_args_list[0].args[0] == expected
|
||||
assert icon.call_args.args[-2:] == (2, True)
|
||||
|
||||
|
||||
def test_disabled_and_alerted_widget_never_constructs_a_client():
|
||||
from openpilot.starpilot.ui.onroad import OnroadView
|
||||
from openpilot.starpilot.ui.presentation import Profile
|
||||
view = Mock(fonts=Mock(profile=Profile.COMPACT))
|
||||
state = OnroadState(False, False, None, None, SpeedLimitObservation())
|
||||
with patch('openpilot.starpilot.ui.weather.WeatherClient') as client:
|
||||
OnroadView._weather(view, state)
|
||||
document = default_document()
|
||||
document['layouts']['compact']['weather']['enabled'] = True
|
||||
OnroadView._weather(view, replace(state, customization=document, alert=OnroadAlert(size=AlertSize.FULL)))
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case', ('off', 'valid', 'invalid', 'no_fix', 'stale', 'future', 'bad_latitude', 'replay', 'fallback'))
|
||||
def test_runtime_uses_only_current_fixed_gps_and_never_fetches(case, tmp_path):
|
||||
from types import SimpleNamespace as NS
|
||||
from openpilot.starpilot.ui.tests.test_runtime_snapshot import ui_fake, RuntimeSnapshotAdapter, NOW
|
||||
from openpilot.starpilot.ui.shell import ShellMode
|
||||
ui = ui_fake()
|
||||
document = default_document()
|
||||
document['layouts']['compact']['weather']['enabled'] = case != 'off'
|
||||
ui.params.values['OnroadCustomizations'] = json.dumps(document).encode()
|
||||
ui.params.get_param_path = lambda key: str(tmp_path / key)
|
||||
(tmp_path / 'OnroadCustomizations').write_bytes(ui.params.values['OnroadCustomizations'])
|
||||
gps = NS(hasFix=case != 'no_fix', latitude=91. if case == 'bad_latitude' else 41., longitude=-87.)
|
||||
ui.sm.put('gpsLocationExternal', gps, age_ns=3_000_000_000 if case in ('stale', 'fallback') else -1_000_000_000 if case == 'future' else 0)
|
||||
if case == 'invalid':
|
||||
ui.sm.valid['gpsLocationExternal'] = False
|
||||
if case == 'fallback':
|
||||
ui.sm.put('gpsLocation', NS(hasFix=True, latitude=40., longitude=-88.))
|
||||
if case == 'replay':
|
||||
ui.replay_clock = object()
|
||||
with patch('openpilot.starpilot.ui.weather.WeatherClient') as client:
|
||||
state = RuntimeSnapshotAdapter(ui).build(ShellMode.ONROAD, now_ns=NOW).onroad
|
||||
expected = (41., -87.) if case == 'valid' else (40., -88.) if case == 'fallback' else None
|
||||
assert state.customization['layouts']['compact']['weather']['enabled'] == (case != 'off')
|
||||
assert state.weather_location == expected
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
def test_existing_positional_state_fields_keep_their_meaning():
|
||||
from openpilot.starpilot.ui.appearance_preferences import OnroadAppearance
|
||||
state = OnroadState(False, False, None, None, SpeedLimitObservation(), OnroadAppearance(), OnroadAlert(), False, False)
|
||||
assert not state.show_slc_offset and state.weather_location is None and state.weather is None
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Optional, cached display weather. Network work never runs on the UI thread."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeatherReading:
|
||||
temperature_c: float
|
||||
code: int
|
||||
is_day: bool
|
||||
|
||||
|
||||
CODES = frozenset((0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67,
|
||||
71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99))
|
||||
REFRESH_SECONDS = 600
|
||||
MAX_AGE_SECONDS = 1800
|
||||
|
||||
|
||||
def valid_location(point) -> bool:
|
||||
return bool(isinstance(point, tuple) and len(point) == 2 and
|
||||
all(type(value) in (int, float) and math.isfinite(value) for value in point) and
|
||||
-90 <= point[0] <= 90 and -180 <= point[1] <= 180)
|
||||
|
||||
|
||||
def nearby(left, right) -> bool:
|
||||
latitude = math.radians((left[0] + right[0]) / 2)
|
||||
longitude_delta = (left[1] - right[1] + 180) % 360 - 180
|
||||
return math.hypot(left[0] - right[0], longitude_delta * math.cos(latitude)) * 111.2 <= 25
|
||||
|
||||
|
||||
def fetch_weather(point) -> WeatherReading:
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
query = urlencode({'latitude': round(point[0], 2), 'longitude': round(point[1], 2),
|
||||
'current': 'temperature_2m,weather_code,is_day', 'temperature_unit': 'celsius', 'timeformat': 'unixtime'})
|
||||
request = Request('https://api.open-meteo.com/v1/forecast?' + query, headers={'User-Agent': 'StarPilot-Weather/1.0'})
|
||||
with urlopen(request, timeout=5) as response:
|
||||
raw = response.read(16385)
|
||||
if len(raw) > 16384:
|
||||
raise ValueError('Weather response is too large')
|
||||
data = json.loads(raw)
|
||||
current = data['current']
|
||||
temperature, code, day, stamp = (current[key] for key in ('temperature_2m', 'weather_code', 'is_day', 'time'))
|
||||
if (data['current_units']['temperature_2m'] != '°C' or type(temperature) not in (int, float) or
|
||||
not math.isfinite(temperature) or not -100 <= temperature <= 70 or type(code) is not int or code not in CODES or
|
||||
type(day) is not int or day not in (0, 1) or type(stamp) not in (int, float) or
|
||||
not math.isfinite(stamp) or not -300 <= datetime.now(UTC).timestamp() - stamp <= 2700):
|
||||
raise ValueError('Invalid or stale current weather')
|
||||
return WeatherReading(temperature, code, bool(day))
|
||||
|
||||
|
||||
class WeatherClient:
|
||||
def __init__(self, *, fetch=fetch_weather, clock=time.monotonic):
|
||||
self._fetch = fetch
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._reading = None
|
||||
self._position = None
|
||||
self._received = 0.0
|
||||
self._next_request = 0.0
|
||||
self._running = False
|
||||
|
||||
def observe(self, point) -> WeatherReading | None:
|
||||
if not valid_location(point):
|
||||
return None
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
reading = self._reading if (self._position is not None and nearby(point, self._position) and
|
||||
0 <= now - self._received <= MAX_AGE_SECONDS) else None
|
||||
start = not self._running and now >= self._next_request
|
||||
if start:
|
||||
self._running = True
|
||||
self._next_request = now + REFRESH_SECONDS
|
||||
if start:
|
||||
try:
|
||||
threading.Thread(target=self._refresh, args=(point,), name='weather', daemon=True).start()
|
||||
except RuntimeError:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
return reading
|
||||
|
||||
def _refresh(self, point):
|
||||
try:
|
||||
reading = self._fetch(point)
|
||||
with self._lock:
|
||||
self._reading, self._position, self._received = reading, point, self._clock()
|
||||
except (OSError, ValueError, TypeError, KeyError, OverflowError):
|
||||
pass
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
Reference in New Issue
Block a user