Add optional onroad weather widget

This commit is contained in:
firestar5683
2026-11-05 14:12:15 -06:00
parent 37df49d937
commit fa0fd7b57e
10 changed files with 414 additions and 8 deletions
@@ -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",
+13
View File
@@ -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
+17 -6
View File
@@ -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),
}
+3
View File
@@ -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):
+73
View File
@@ -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)))
+10 -1
View File
@@ -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
+97
View File
@@ -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