From the 2026-06-15 on-box hardware test (all 6 prior fixes confirmed; these are the new findings, baked into the fork source rather than shipped as pacman-hook patches, per the Phase-2 "we own the shell" approach): - F-W1 (Weather.qml): fetch at startup. Stock only called reload() on a config CHANGE, so a saved location blanked after reboot and an empty/auto location never populated on a fresh boot. Add Component.onCompleted: reload() and switch the hourly Timer to reload() (re-detects auto/IP too). - F-W3 (Weather.qml): 7-day forecast showed "undefined°" in °F mode — only maxTempC/minTempC were stored. Store rounded maxTempF/minTempF (and round °C). - F-W2 (Weather.qml): empty/auto-IP location never populated — the in-shell ipinfo.io fetch didn't set loc (provider-specific; the city path works via the same Requests.get). Repair attempt: defensive JSON parse + error callback + fallback to geojs.io. Compiled Requests module not inspectable, so this is best-effort; a saved city still works regardless. - F-D1a (modules/dashboard/*.qml): square the 25 card radii bound to the compiled Tokens.rounding.* Material defaults (which ignore shell.json rounding.scale=0) so the dashboard matches the square redesign. - F-D1b (Glyphs.qml): remap the weather glyphs to the Material-weather family (every codepoint verified present in JetBrainsMono Nerd Font Mono); the old Font-Awesome codepoints were absent (rendered "?") and thunderstorm / snowing_heavy were unmapped. - F-T1 (NsBar.qml): gate the system-tray pill on SystemTray.items count so it only shows when an app registers a tray icon (was opening an empty panel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
9.8 KiB
QML
266 lines
9.8 KiB
QML
pragma Singleton
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Caelestia
|
|
import Caelestia.Config
|
|
import qs.utils
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property string city
|
|
property string loc
|
|
property var cc
|
|
property list<var> forecast
|
|
property list<var> hourlyForecast
|
|
|
|
readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert"
|
|
readonly property string description: cc?.weatherDesc ?? qsTr("No weather")
|
|
readonly property string temp: formatTemp(cc?.tempC)
|
|
readonly property string feelsLike: formatTemp(cc?.feelsLikeC)
|
|
readonly property int humidity: cc?.humidity ?? 0
|
|
readonly property real windSpeed: cc?.windSpeed ?? 0
|
|
readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), GlobalConfig.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--"
|
|
readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), GlobalConfig.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--"
|
|
|
|
readonly property var cachedCities: new Map()
|
|
|
|
function formatTemp(temp: var): string {
|
|
return GlobalConfig.services.useFahrenheit ? `${temp !== undefined ? Math.round(toFahrenheit(temp)) : "--"}°F` : `${temp !== undefined ? Math.round(temp) : "--"}°C`;
|
|
}
|
|
|
|
function reload(): void {
|
|
const configLocation = GlobalConfig.services.weatherLocation;
|
|
|
|
if (configLocation) {
|
|
if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) {
|
|
loc = configLocation;
|
|
fetchCityFromCoords(configLocation);
|
|
} else {
|
|
fetchCoordsFromCity(configLocation);
|
|
}
|
|
} else if (!loc || timer.elapsed() > 900) {
|
|
fetchLocationFromIp();
|
|
}
|
|
}
|
|
|
|
// Auto-detect location from the public IP (used when weatherLocation is
|
|
// empty). Stock caelestia only queried ipinfo.io with no error handling;
|
|
// on the 2026-06-15 hardware test that call never populated `loc` (the
|
|
// city path works via the same Requests.get, so it is provider-specific —
|
|
// finding F-W2). Repair: wrap parsing defensively and fall back to a second
|
|
// provider (geojs.io, which returns separate latitude/longitude fields) on
|
|
// any error or empty response.
|
|
function fetchLocationFromIp(): void {
|
|
const useGeoJs = () => {
|
|
Requests.get("https://get.geojs.io/v1/ip/geo.json", text => {
|
|
try {
|
|
const g = JSON.parse(text);
|
|
if (g.latitude && g.longitude) {
|
|
loc = g.latitude + "," + g.longitude;
|
|
city = g.city ?? "";
|
|
timer.restart();
|
|
}
|
|
} catch (e) {}
|
|
});
|
|
};
|
|
|
|
Requests.get("https://ipinfo.io/json", text => {
|
|
try {
|
|
const response = JSON.parse(text);
|
|
if (response.loc) {
|
|
loc = response.loc;
|
|
city = response.city ?? "";
|
|
timer.restart();
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
useGeoJs();
|
|
}, useGeoJs);
|
|
}
|
|
|
|
function fetchCityFromCoords(coords: string): void {
|
|
if (cachedCities.has(coords)) {
|
|
city = cachedCities.get(coords);
|
|
return;
|
|
}
|
|
|
|
const [lat, lon] = coords.split(",").map(s => s.trim());
|
|
|
|
const fallbackToBigDataCloud = () => {
|
|
const fallbackUrl = `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`;
|
|
Requests.get(fallbackUrl, text => {
|
|
const geo = JSON.parse(text);
|
|
const geoCity = geo.city || geo.locality;
|
|
if (geoCity) {
|
|
city = geoCity;
|
|
cachedCities.set(coords, geoCity);
|
|
} else {
|
|
city = "Unknown City";
|
|
}
|
|
});
|
|
};
|
|
|
|
const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`;
|
|
Requests.get(nominatimUrl, text => {
|
|
const geo = JSON.parse(text).features?.[0]?.properties.geocoding;
|
|
if (geo) {
|
|
const geoCity = geo.type === "city" ? geo.name : geo.city;
|
|
if (geoCity) {
|
|
city = geoCity;
|
|
cachedCities.set(coords, geoCity);
|
|
return;
|
|
}
|
|
}
|
|
fallbackToBigDataCloud();
|
|
}, fallbackToBigDataCloud);
|
|
}
|
|
|
|
function fetchCoordsFromCity(cityName: string): void {
|
|
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`;
|
|
|
|
Requests.get(url, text => {
|
|
const json = JSON.parse(text);
|
|
if (json.results && json.results.length > 0) {
|
|
const result = json.results[0];
|
|
loc = result.latitude + "," + result.longitude;
|
|
city = result.name;
|
|
} else {
|
|
loc = "";
|
|
reload();
|
|
}
|
|
});
|
|
}
|
|
|
|
function fetchWeatherData(): void {
|
|
const url = getWeatherUrl();
|
|
if (url === "")
|
|
return;
|
|
|
|
Requests.get(url, text => {
|
|
const json = JSON.parse(text);
|
|
if (!json.current || !json.daily)
|
|
return;
|
|
|
|
cc = {
|
|
weatherCode: json.current.weather_code,
|
|
weatherDesc: getWeatherCondition(json.current.weather_code),
|
|
tempC: json.current.temperature_2m,
|
|
feelsLikeC: json.current.apparent_temperature,
|
|
humidity: json.current.relative_humidity_2m,
|
|
windSpeed: json.current.wind_speed_10m,
|
|
isDay: json.current.is_day,
|
|
sunrise: json.daily.sunrise[0].replace("T", " "),
|
|
sunset: json.daily.sunset[0].replace("T", " ")
|
|
};
|
|
|
|
const forecastList = [];
|
|
for (let i = 0; i < json.daily.time.length; i++)
|
|
forecastList.push({
|
|
date: json.daily.time[i].replace(/-/g, "/"),
|
|
maxTempC: Math.round(json.daily.temperature_2m_max[i]),
|
|
minTempC: Math.round(json.daily.temperature_2m_min[i]),
|
|
maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])),
|
|
minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])),
|
|
weatherCode: json.daily.weather_code[i],
|
|
icon: Icons.getWeatherIcon(json.daily.weather_code[i])
|
|
});
|
|
forecast = forecastList;
|
|
|
|
const hourlyList = [];
|
|
const now = new Date();
|
|
for (let i = 0; i < json.hourly.time.length; i++) {
|
|
const time = new Date(json.hourly.time[i].replace("T", " "));
|
|
|
|
if (time < now)
|
|
continue;
|
|
|
|
hourlyList.push({
|
|
timestamp: json.hourly.time[i],
|
|
hour: time.getHours(),
|
|
tempC: Math.round(json.hourly.temperature_2m[i]),
|
|
precipChance: json.hourly.precipitation_probability[i],
|
|
weatherCode: json.hourly.weather_code[i],
|
|
icon: Icons.getWeatherIcon(json.hourly.weather_code[i])
|
|
});
|
|
}
|
|
hourlyForecast = hourlyList;
|
|
});
|
|
}
|
|
|
|
function toFahrenheit(celcius: real): real {
|
|
return celcius * 9 / 5 + 32;
|
|
}
|
|
|
|
function getWeatherUrl(): string {
|
|
if (!loc || loc.indexOf(",") === -1)
|
|
return "";
|
|
|
|
const [lat, lon] = loc.split(",").map(s => s.trim());
|
|
const baseUrl = "https://api.open-meteo.com/v1/forecast";
|
|
const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m,precipitation_probability", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"];
|
|
|
|
return baseUrl + "?" + params.join("&");
|
|
}
|
|
|
|
function getWeatherCondition(code: string): string {
|
|
const conditions = {
|
|
"0": "Clear",
|
|
"1": "Clear",
|
|
"2": "Partly cloudy",
|
|
"3": "Overcast",
|
|
"45": "Fog",
|
|
"48": "Fog",
|
|
"51": "Drizzle",
|
|
"53": "Drizzle",
|
|
"55": "Drizzle",
|
|
"56": "Freezing drizzle",
|
|
"57": "Freezing drizzle",
|
|
"61": "Light rain",
|
|
"63": "Rain",
|
|
"65": "Heavy rain",
|
|
"66": "Light rain",
|
|
"67": "Heavy rain",
|
|
"71": "Light snow",
|
|
"73": "Snow",
|
|
"75": "Heavy snow",
|
|
"77": "Snow",
|
|
"80": "Light rain",
|
|
"81": "Rain",
|
|
"82": "Heavy rain",
|
|
"85": "Light snow showers",
|
|
"86": "Heavy snow showers",
|
|
"95": "Thunderstorm",
|
|
"96": "Thunderstorm with hail",
|
|
"99": "Thunderstorm with hail"
|
|
};
|
|
return conditions[code] || "Unknown";
|
|
}
|
|
|
|
onLocChanged: fetchWeatherData()
|
|
|
|
// Stock caelestia only called reload() on a config CHANGE, never at startup,
|
|
// so a saved location blanked after a reboot and an empty (auto/IP) location
|
|
// never populated on a fresh boot (finding F-W1). Fetch once on load.
|
|
Component.onCompleted: reload()
|
|
|
|
Connections {
|
|
function onWeatherLocationChanged(): void {
|
|
root.reload();
|
|
}
|
|
|
|
target: GlobalConfig.services
|
|
}
|
|
|
|
Timer {
|
|
interval: 3600000 // 1 hour
|
|
running: true
|
|
repeat: true
|
|
onTriggered: reload() // re-detect an auto/IP location too, not just refetch a known one
|
|
}
|
|
|
|
ElapsedTimer {
|
|
id: timer
|
|
}
|
|
}
|