openmeteo adapter

This commit is contained in:
firestarsdog
2026-03-28 12:50:33 -04:00
parent 8fafa6e8d4
commit 82619bd7db
4 changed files with 91 additions and 115 deletions
+89 -38
View File
@@ -1,15 +1,52 @@
#!/usr/bin/env python3
import requests
import json
import time
import urllib.error
import urllib.request
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from openpilot.starpilot.common.starpilot_utilities import calculate_distance_to_point, get_starpilot_api_info, is_url_pingable
from openpilot.starpilot.common.starpilot_variables import STARPILOT_API
from openpilot.starpilot.common.starpilot_utilities import calculate_distance_to_point
CACHE_DISTANCE = 25
CHECK_INTERVAL = 15 * 60
MAX_RETRIES = 3
RETRY_DELAY = 60
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
# WMO Weather interpretation codes -> OpenWeatherMap condition IDs
# Reference: https://open-meteo.com/en/docs#weather_variable_documentation
WMO_TO_OWM = {
0: 800, # Clear sky
1: 801, # Mainly clear
2: 802, # Partly cloudy
3: 803, # Overcast
45: 741, # Fog
48: 741, # Depositing rime fog
51: 300, # Drizzle: Light
53: 301, # Drizzle: Moderate
55: 302, # Drizzle: Dense
56: 311, # Freezing Drizzle: Light
57: 312, # Freezing Drizzle: Dense
61: 500, # Rain: Slight
63: 501, # Rain: Moderate
65: 502, # Rain: Heavy
66: 511, # Freezing Rain: Light
67: 511, # Freezing Rain: Heavy
71: 600, # Snow fall: Slight
73: 601, # Snow fall: Moderate
75: 602, # Snow fall: Heavy
77: 600, # Snow grains
80: 520, # Rain showers: Slight
81: 521, # Rain showers: Moderate
82: 522, # Rain showers: Violent
85: 620, # Snow showers: Slight
86: 621, # Snow showers: Heavy
95: 200, # Thunderstorm: Slight/Moderate
96: 201, # Thunderstorm with slight hail
99: 202, # Thunderstorm with heavy hail
}
# Reference: https://openweathermap.org/weather-conditions
WEATHER_CATEGORIES = {
@@ -35,14 +72,47 @@ WEATHER_CATEGORIES = {
},
}
def _iso_to_unix(iso_str):
"""Convert an ISO8601 string (without timezone) to a UTC Unix timestamp."""
try:
return int(datetime.fromisoformat(iso_str).replace(tzinfo=timezone.utc).timestamp())
except Exception:
return 0
def _normalize_response(data):
"""Convert Open-Meteo's columnar response into the row-based format expected by StarPilot."""
daily = data.get("daily", {})
current = data.get("current", {})
hourly = data.get("hourly", {})
current_wmo = current.get("weather_code", 0)
normalized = {
"current": {
"sunrise": _iso_to_unix(daily.get("sunrise", [None])[0] or ""),
"sunset": _iso_to_unix(daily.get("sunset", [None])[0] or ""),
"weather": [{"id": WMO_TO_OWM.get(current_wmo, 800)}],
},
"hourly": [
{
"dt": _iso_to_unix(t),
"weather": [{"id": WMO_TO_OWM.get(wc, 800)}],
}
for t, wc in zip(hourly.get("time", []), hourly.get("weather_code", []))
],
}
return normalized
class WeatherChecker:
def __init__(self, StarPilotPlanner):
self.starpilot_planner = StarPilotPlanner
self.is_daytime = False
self.api_25_calls = 0
self.api_3_calls = 0
self.increase_following_distance = 0
self.increase_stopped_distance = 0
self.reduce_acceleration = 0
@@ -56,17 +126,7 @@ class WeatherChecker:
self.last_updated = None
self.requesting = False
self.user_api_key = self.starpilot_planner.params.get("WeatherToken", encoding="utf-8")
if self.user_api_key:
self.check_interval = 60
else:
self.check_interval = 15 * 60
self.api_token, self.build_metadata, self.device_type, self.dongle_id = get_starpilot_api_info()
self.session = requests.Session()
self.session.headers.update({"Accept-Language": "en", "User-Agent": "starpilot-api/1.0"})
self.check_interval = CHECK_INTERVAL
self.executor = ThreadPoolExecutor(max_workers=1)
@@ -137,33 +197,24 @@ class WeatherChecker:
self.update_offsets(starpilot_toggles)
def make_request():
if not is_url_pingable(STARPILOT_API):
return None
payload = {
"api_key": self.user_api_key,
"api_token": self.api_token,
"build_metadata": self.build_metadata,
"device": self.device_type,
"starpilot_dongle_id": self.dongle_id,
"lat": self.starpilot_planner.gps_position["latitude"],
"lon": self.starpilot_planner.gps_position["longitude"],
params = {
"latitude": self.starpilot_planner.gps_position["latitude"],
"longitude": self.starpilot_planner.gps_position["longitude"],
"current": "weather_code",
"hourly": "weather_code",
"daily": "sunrise,sunset",
"timezone": "UTC",
}
url = f"{OPEN_METEO_URL}?{urllib.parse.urlencode(params)}"
for attempt in range(1, MAX_RETRIES + 1):
try:
response = self.session.post(f"{STARPILOT_API}/weather", json=payload, headers={"Content-Type": "application/json"}, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("api_version") == "2.5":
self.api_25_calls += 1
else:
self.api_3_calls += 1
return data
req = urllib.request.Request(url, headers={"User-Agent": "starpilot/1.0"})
with urllib.request.urlopen(req, timeout=10) as response:
return _normalize_response(json.loads(response.read().decode()))
except Exception:
if attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY)
time.sleep(5)
continue
return None
-8
View File
@@ -130,14 +130,6 @@ class StarPilotTracking:
if self.starpilot_events.stopped_for_light:
self.starpilot_stats["StopLightTime"] = self.starpilot_stats.get("StopLightTime", 0) + DT_MDL
weather_api_calls = self.starpilot_stats.get("WeatherAPICalls", {})
weather_api_calls["2.5"] = weather_api_calls.get("2.5", 0) + self.starpilot_weather.api_25_calls
weather_api_calls["3.0"] = weather_api_calls.get("3.0", 0) + self.starpilot_weather.api_3_calls
self.starpilot_stats["WeatherAPICalls"] = weather_api_calls
self.starpilot_weather.api_25_calls = 0
self.starpilot_weather.api_3_calls = 0
suffix = "unknown"
for category in WEATHER_CATEGORIES.values():
if any(start <= self.starpilot_weather.weather_id <= end for start, end in category["ranges"]):
@@ -185,7 +185,6 @@ StarPilotLongitudinalPanel::StarPilotLongitudinalPanel(StarPilotSettingsWindow *
{"ReduceAccelerationSnow", tr("Reduce Acceleration by:"), tr("<b>Lower the maximum acceleration in snow.</b> Increase for softer takeoffs; decrease for quicker but less stable takeoffs."), ""},
{"ReduceLateralAccelerationSnow", tr("Reduce Speed in Curves by:"), tr("<b>Lower the desired speed while driving through curves in snow.</b> Increase for safer, gentler turns; decrease for more aggressive driving in curves."), ""},
{"SetWeatherKey", tr("Set Your Own Key"), tr("<b>Set your own \"OpenWeatherMap\" key to increase the weather update rate.</b><br><br><i>Personal keys grant 1,000 free calls per day, allowing for updates every minute. The default key is shared and only updates every 15 minutes.</i>"), ""},
{"SpeedLimitController", tr("Speed Limit Controller"), tr("<b>Limit openpilot's maximum driving speed to the current speed limit</b> obtained from downloaded maps, Mapbox, or the dashboard for supported vehicles (Ford, Genesis, Hyundai, Kia, Lexus, Toyota)."), "../../starpilot/assets/toggle_icons/icon_speed_limit.png"},
{"SLCFallback", tr("Fallback Speed"), tr("<b>The speed used by \"Speed Limit Controller\" when no speed limit is found.</b><br><br>- <b>Set Speed</b>: Use the cruise set speed<br>- <b>Experimental Mode</b>: Estimate the limit using the driving model<br>- <b>Previous Limit</b>: Keep using the last confirmed limit"), ""},
@@ -406,70 +405,6 @@ StarPilotLongitudinalPanel::StarPilotLongitudinalPanel(StarPilotSettingsWindow *
qolOpen = true;
});
longitudinalToggle = weatherToggle;
} else if (param == "SetWeatherKey") {
weatherKeyControl = new StarPilotButtonsControl(title, desc, icon, {tr("ADD"), tr("TEST")});
QObject::connect(weatherKeyControl, &StarPilotButtonsControl::buttonClicked, [this](int id) {
if (id == 0) {
if (!params.get("WeatherToken").empty()) {
if (StarPilotConfirmationDialog::yesorno(tr("Are you sure you want to remove your key?"), this)) {
params.remove("WeatherToken");
weatherKeyControl->setText(0, tr("ADD"));
weatherKeyControl->setVisibleButton(1, false);
}
} else {
int keyLength = 32;
QString currentKey = QString::fromStdString(params.get("WeatherToken"));
QString newKey = InputDialog::getText(tr("Enter your \"OpenWeatherMap\" key"), this, tr("Characters: 0/%1").arg(keyLength), false, -1, currentKey, keyLength).trimmed();
if (!newKey.isEmpty()) {
params.put("WeatherToken", newKey.toStdString());
weatherKeyControl->setText(0, tr("REMOVE"));
weatherKeyControl->setVisibleButton(1, true);
}
}
} else if (id == 1) {
weatherKeyControl->setValue(tr("Testing..."));
QString key = QString::fromStdString(params.get("WeatherToken")).trimmed();
QString url30 = QString("https://api.openweathermap.org/data/3.0/onecall?lat=42.4293&lon=-83.9850&exclude=current,minutely,hourly,daily,alerts&appid=%1").arg(key);
QNetworkRequest request(url30);
QNetworkReply *reply = networkManager->get(request);
QObject::connect(reply, &QNetworkReply::finished, this, [=]() {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError) {
weatherKeyControl->setValue("");
ConfirmationDialog::alert(tr("Key is valid!"), this);
return;
}
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 401 || status == 403) {
QString url25 = QString("https://api.openweathermap.org/data/2.5/weather?lat=42.4293&lon=-83.9850&appid=%1").arg(key);
QNetworkRequest request25(url25);
QNetworkReply *reply25 = networkManager->get(request25);
QObject::connect(reply25, &QNetworkReply::finished, this, [=]() {
reply25->deleteLater();
weatherKeyControl->setValue("");
if (reply25->error() == QNetworkReply::NoError) {
ConfirmationDialog::alert(tr("Your key is valid for version 2.5, but version 3.0 is highly recommended! Please subscribe to the \"One Call API 3.0\" plan!"), this);
} else {
int status25 = reply25->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
ConfirmationDialog::alert(tr("Invalid key! (Error: %1)").arg(status25), this);
}
});
} else {
weatherKeyControl->setValue("");
ConfirmationDialog::alert(tr("An error occurred: %1").arg(reply->errorString()), this);
}
});
}
});
longitudinalToggle = weatherKeyControl;
} else if (param == "LowVisibilityOffsets") {
ButtonControl *manageLowVisibilitOffsetsButton = new ButtonControl(title, tr("MANAGE"), desc);
QObject::connect(manageLowVisibilitOffsetsButton, &ButtonControl::clicked, [longitudinalLayout, weatherLowVisibilityPanel, this]() {
@@ -872,9 +807,7 @@ void StarPilotLongitudinalPanel::showEvent(QShowEvent *event) {
vEgoStartingToggle->setTitle(QString(tr("Start Speed (Default: %1)")).arg(QString::number(parent->vEgoStarting, 'f', 2)));
vEgoStoppingToggle->setTitle(QString(tr("Stop Speed (Default: %1)")).arg(QString::number(parent->vEgoStopping, 'f', 2)));
bool keyExists = !params.get("WeatherToken").empty();
weatherKeyControl->setText(0, keyExists ? tr("REMOVE") : tr("ADD"));
weatherKeyControl->setVisibleButton(1, keyExists && fs.starpilot_scene.online);
updateToggles();
}
@@ -50,7 +50,7 @@ private:
QSet<QString> parentKeys;
StarPilotButtonsControl *weatherKeyControl;
StarPilotParamValueControl *longitudinalActuatorDelayToggle;
StarPilotParamValueControl *startAccelToggle;