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.
Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.
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.
+
Weather data by Open-Meteo. Uses your GPS area when enabled; updates in the background about every 10 minutes.
The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.
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.
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.
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js
index c8a9a6e5de..77d2e7818e 100644
--- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js
+++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js
@@ -192,6 +192,19 @@ export const LayoutWidgetPreview = {
14 seconds
+
+
+
+ 70°F
+ Open-Meteo
+ 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",
diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py
index 007bf73f8b..dfc812aa2f 100644
--- a/openpilot/starpilot/ui/onroad.py
+++ b/openpilot/starpilot/ui/onroad.py
@@ -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
diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py
index 8c39604fb7..8fb7d0e935 100644
--- a/openpilot/starpilot/ui/onroad_customization.py
+++ b/openpilot/starpilot/ui/onroad_customization.py
@@ -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),
}
diff --git a/openpilot/starpilot/ui/onroad_state.py b/openpilot/starpilot/ui/onroad_state.py
index 7b175e0fee..5930ae072a 100644
--- a/openpilot/starpilot/ui/onroad_state.py
+++ b/openpilot/starpilot/ui/onroad_state.py
@@ -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):
diff --git a/openpilot/starpilot/ui/onroad_weather.py b/openpilot/starpilot/ui/onroad_weather.py
new file mode 100644
index 0000000000..bcc0a6f291
--- /dev/null
+++ b/openpilot/starpilot/ui/onroad_weather.py
@@ -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)))
diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py
index df6e57bd46..d4b855a8d9 100644
--- a/openpilot/starpilot/ui/runtime_snapshot.py
+++ b/openpilot/starpilot/ui/runtime_snapshot.py
@@ -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 '',
diff --git a/openpilot/starpilot/ui/tests/test_weather.py b/openpilot/starpilot/ui/tests/test_weather.py
new file mode 100644
index 0000000000..cf96bfba9e
--- /dev/null
+++ b/openpilot/starpilot/ui/tests/test_weather.py
@@ -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
diff --git a/openpilot/starpilot/ui/weather.py b/openpilot/starpilot/ui/weather.py
new file mode 100644
index 0000000000..477b3cfca9
--- /dev/null
+++ b/openpilot/starpilot/ui/weather.py
@@ -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