diff --git a/PRIVACY b/PRIVACY index 16293640..d138bdeb 100644 --- a/PRIVACY +++ b/PRIVACY @@ -148,6 +148,28 @@ The Weather app will retrieve the user's coarse location, and will use it to query a third-party weather information provider. +## Weather + +The Weather app can fetch its data from either of two providers; which one +receives the user's coarse location depends on the source selected in the +Weather settings. Faceclaw sends the location only to the selected source, +and only when it fetches weather data. + +Open-Meteo (the default) requires no account or API key; each request +carries the approximate coordinates to look up. Open-Meteo's terms, which +include its privacy policy, state that server logs may contain requested +coordinates for troubleshooting, that this data is not shared with any +third party, and that all log files are deleted after 90 days: + + Open-Meteo: https://open-meteo.com/en/terms + +The National Weather Service (US only), which the Weather app used before +Open-Meteo was added, receives the same approximate coordinates. It is a +US government service whose data is in the public domain: + + NWS: https://www.weather.gov/privacy + + ## EvenHub The EvenHub app store is subject to Even Realities' privacy policy: diff --git a/app/apps/weather/weather-icons.ts b/app/apps/weather/weather-icons.ts index 0c2a3008..91618853 100644 --- a/app/apps/weather/weather-icons.ts +++ b/app/apps/weather/weather-icons.ts @@ -79,7 +79,7 @@ export function renderWeatherIcon(condition: WeatherCondition, isDaytime: boolea export type WeatherReading = { condition: WeatherCondition | null; isDaytime: boolean; - /** Rounded, unit-less ("72°"), to stay compact. */ + /** Rounded, unit-less ("72°") in the state's display unit, to stay compact. */ temperature: string; }; @@ -87,10 +87,11 @@ export type WeatherReading = { export function weatherReading(state: WeatherState, nowMs: number): WeatherReading | null { const current = state.current; if (!current || state.lastUpdatedMs === null || nowMs - state.lastUpdatedMs > INDICATOR_STALE_MS) return null; + const value = state.unit === "fahrenheit" ? current.temperatureF : current.temperatureC; return { condition: current.condition, isDaytime: current.isDaytime, - temperature: current.temperatureF === null ? "--°" : `${Math.round(current.temperatureF)}°`, + temperature: value === null || !Number.isFinite(value) ? "--°" : `${Math.round(value)}°`, }; } diff --git a/app/apps/weather/weather.ts b/app/apps/weather/weather.ts index db97c810..f11507b6 100644 --- a/app/apps/weather/weather.ts +++ b/app/apps/weather/weather.ts @@ -1,7 +1,7 @@ import { getDefaultLargeFont, getDefaultMediumFont, getDefaultSmallFont } from "../../graphics/ui-fonts"; import { GrayImage, type UiFont } from "../../graphics/image"; import { wrapText, truncateText } from "../../graphics/textwrap"; -import { type ForecastPeriod, type WeatherState } from "../../native/weather"; +import { type ForecastPeriod, type WeatherState, formatTemperature, formatWindSpeed } from "../../native/weather"; import { GESTURE_CLICK, type InputEvent } from "../../ui/gestures"; import { type Layer, type LayerContext } from "../../ui/layers"; import { Menu, type MenuDrawArgs } from "../../ui/menu-core"; @@ -115,7 +115,8 @@ export class WeatherLayer implements Layer { const large = getDefaultLargeFont(); const medium = getDefaultMediumFont(); const small = getDefaultSmallFont(); - const temperature = current.temperatureF === null ? "--°" : `${Math.round(current.temperatureF)}°F`; + const unit = weather.unit ?? "celsius"; + const temperature = formatTemperature(current.temperatureC, current.temperatureF, unit); const temperatureY = CURRENT_TOP + 8; let temperatureX = PAGE_X; if (current.condition) { @@ -140,12 +141,13 @@ export class WeatherLayer implements Layer { const details: string[] = []; if (current.humidityPercent !== null) details.push(`Humidity ${Math.round(current.humidityPercent)}%`); - if (current.windSpeedMph !== null) { - details.push(`Wind ${current.windDirection ? `${current.windDirection} ` : ""}${Math.round(current.windSpeedMph)} mph`); + const windText = formatWindSpeed(current.windSpeedKmh, current.windSpeedMph, unit); + if (windText) { + details.push(`${current.windDirection ? `${current.windDirection} ` : ""}${windText}`); } image.drawText(small, textX, cy, details.join(" ") || "Current forecast", 170); cy += lineStep(small); - const source = current.observed ? "Observed" : "Forecast"; + const source = current.observed ? "Live" : "Forecast"; const age = current.timestampMs ? formatAge(Date.now() - current.timestampMs) : ""; const updating = weather.phase === "locating" || weather.phase === "loading" ? " · updating" : ""; image.drawText(small, textX, cy, `${source}${age ? ` ${age}` : ""}${updating}`, 105); @@ -161,7 +163,7 @@ export class WeatherLayer implements Layer { ): void { const font = getDefaultSmallFont(); if (!forecast.length) { - image.drawText(font, PAGE_X, FORECAST_TOP, "No upcoming forecast periods.", 160); + image.drawText(font, PAGE_X, FORECAST_TOP, "No further forecast.", 160); return; } const rowH = forecastRowHeight(font); @@ -176,16 +178,16 @@ export class WeatherLayer implements Layer { // Columns are sized to the widest period name so names ("Wednesday // Night") never truncate; the summary column absorbs what's left. const nameWidth = Math.max( - font.measureText("Upcoming"), + font.measureText("Forecast"), ...forecast.map((period) => font.measureText(period.name)), ); const tempX = PAGE_X + nameWidth + FORECAST_COL_GAP; - const precipX = tempX + Math.max(font.measureText("Temp"), font.measureText("100°F")) + FORECAST_COL_GAP; - const summaryX = precipX + Math.max(font.measureText("Rain"), font.measureText("100%")) + FORECAST_COL_GAP; + const precipX = tempX + Math.max(font.measureText("Temp"), font.measureText("100°C")) + FORECAST_COL_GAP; + const summaryX = precipX + Math.max(font.measureText("Precip"), font.measureText("100%")) + FORECAST_COL_GAP; - image.drawText(font, PAGE_X, FORECAST_HEADER_Y, "Upcoming", 150); + image.drawText(font, PAGE_X, FORECAST_HEADER_Y, "Forecast", 150); image.drawText(font, tempX, FORECAST_HEADER_Y, "Temp", 105); - image.drawText(font, precipX, FORECAST_HEADER_Y, "Rain", 105); + image.drawText(font, precipX, FORECAST_HEADER_Y, "Precip", 105); this.columns = { name: PAGE_X - FORECAST_BOX_X, @@ -206,8 +208,9 @@ export class WeatherLayer implements Layer { const columns = this.columns; const shade = selected ? 245 : 190; const textY = centeredTextY(font, y, height); + const unit = this.state().unit ?? "celsius"; image.drawText(font, x + columns.name, textY, period.name, shade); - image.drawText(font, x + columns.temp, textY, period.temperatureF === null ? "--" : `${Math.round(period.temperatureF)}°F`, shade); + image.drawText(font, x + columns.temp, textY, formatTemperature(period.temperatureC, period.temperatureF, unit), shade); image.drawText(font, x + columns.precip, textY, period.precipitationPercent === null ? "--" : `${Math.round(period.precipitationPercent)}%`, selected ? 220 : 155); // The summary stops PAGE_X short of the viewport's right edge, as the name starts PAGE_X in from the left. const summaryWidth = width - columns.summary - columns.name; diff --git a/app/native/weather-conditions.ts b/app/native/weather-conditions.ts new file mode 100644 index 00000000..5109ae1b --- /dev/null +++ b/app/native/weather-conditions.ts @@ -0,0 +1,117 @@ +/** + * Condition and day/night helpers shared by the weather providers. + */ +import type { WeatherCondition } from "./weather-types"; + +/** WMO weather codes (Open-Meteo) by the condition they show. */ +const WMO_CONDITIONS: Record = { + 0: "clear", + 1: "partly-cloudy", + 2: "partly-cloudy", + 3: "cloudy", + 45: "fog", + 48: "fog", + 51: "drizzle", + 53: "drizzle", + 55: "drizzle", + 56: "sleet", + 57: "sleet", + 61: "rain", + 63: "rain", + 65: "rain", + 66: "sleet", + 67: "sleet", + 71: "snow", + 73: "snow", + 75: "snow", + 77: "snow", + 80: "showers", + 81: "showers", + 82: "showers", + 85: "snow", + 86: "snow", + 95: "thunderstorm", + 96: "thunderstorm", + 99: "thunderstorm", +}; + +// NWS icon codes (https://api.weather.gov/icons) by the condition they show. +const NWS_ICON_CONDITIONS: Record = { + skc: "clear", + hot: "clear", + cold: "clear", + few: "partly-cloudy", + sct: "partly-cloudy", + bkn: "cloudy", + ovc: "cloudy", + wind_skc: "wind", + wind_few: "wind", + wind_sct: "wind", + wind_bkn: "wind", + wind_ovc: "wind", + fog: "fog", + haze: "fog", + smoke: "fog", + dust: "fog", + rain: "rain", + rain_showers: "rain", + rain_showers_hi: "showers", + tsra: "thunderstorm", + tsra_sct: "thunderstorm", + tsra_hi: "thunderstorm", + tornado: "thunderstorm", + hurricane: "thunderstorm", + tropical_storm: "thunderstorm", + snow: "snow", + blizzard: "snow", + sleet: "sleet", + fzra: "sleet", + rain_fzra: "sleet", + snow_fzra: "sleet", + rain_sleet: "sleet", + snow_sleet: "sleet", + rain_snow: "sleet", +}; + +/** The condition a WMO weather code shows, or null for an unknown code. */ +export function conditionFromWmoCode(code: number | null | undefined): WeatherCondition | null { + if (code === null || code === undefined) return null; + return WMO_CONDITIONS[code] ?? null; +} + +/** + * Condition and day/night from an NWS icon URL such as + * ".../icons/land/day/tsra_sct,40/rain,20?size=medium". A period that + * changes partway lists two codes; the first is the one in effect now. + */ +export function conditionFromNwsIcon(value: unknown): { + condition: WeatherCondition | null; + isDaytime: boolean | null; +} { + const text = typeof value === "string" ? value.trim() : ""; + const match = text.match(/\/icons\/[^/]+\/(day|night)\/([a-z_]+)/); + if (!match) return { condition: null, isDaytime: null }; + return { condition: NWS_ICON_CONDITIONS[match[2]!] ?? null, isDaytime: match[1] === "day" }; +} + +/** Condition from a provider's text description ("Chance Rain Showers"). */ +export function conditionFromText(text: string): WeatherCondition | null { + const lower = text.toLowerCase(); + if (/thunder|t-storm|tstorm/.test(lower)) return "thunderstorm"; + if (/freezing|sleet|ice pellets|wintry|hail|rain and snow|snow and rain/.test(lower)) return "sleet"; + if (/snow|flurr|blizzard/.test(lower)) return "snow"; + if (/drizzle/.test(lower)) return "drizzle"; + if (/rain|showers/.test(lower)) return /chance|slight|scattered|isolated|patchy/.test(lower) ? "showers" : "rain"; + if (/fog|haze|smoke|dust|mist/.test(lower)) return "fog"; + if (/windy|breezy|blustery/.test(lower)) return "wind"; + if (/partly|few clouds|mostly sunny|mostly clear/.test(lower)) return "partly-cloudy"; + if (/cloud|overcast/.test(lower)) return "cloudy"; + if (/sunny|clear|fair/.test(lower)) return "clear"; + return null; +} + +/** Rough day/night by the phone's clock, for providers that do not report it. */ +export function isLocalDaytime(): boolean { + const hour = new Date().getHours(); + return hour >= 6 && hour < 18; +} diff --git a/app/native/weather-providers/index.ts b/app/native/weather-providers/index.ts new file mode 100644 index 00000000..47714cde --- /dev/null +++ b/app/native/weather-providers/index.ts @@ -0,0 +1,29 @@ +/** + * Weather provider registry. + */ +import type { WeatherProvider } from "../weather-types"; +import { NwsWeatherProvider } from "./nws-provider"; +import { OpenMeteoWeatherProvider } from "./open-meteo-provider"; + +export const WEATHER_PROVIDERS: Record = { + "open-meteo": new OpenMeteoWeatherProvider(), + nws: new NwsWeatherProvider(), +}; + +/** + * Open-Meteo covers the whole world without an account, so it is the default; + * NWS stays available because it reports US conditions from local stations. + */ +export const DEFAULT_WEATHER_PROVIDER_ID = "open-meteo"; + +export function getWeatherProvider(id?: string): WeatherProvider { + return (id !== undefined ? WEATHER_PROVIDERS[id] : undefined) ?? WEATHER_PROVIDERS[DEFAULT_WEATHER_PROVIDER_ID]!; +} + +export function listWeatherProviders(): Array<{ id: string; name: string; requiresApiKey: boolean }> { + return Object.values(WEATHER_PROVIDERS).map((provider) => ({ + id: provider.id, + name: provider.name, + requiresApiKey: provider.requiresApiKey, + })); +} diff --git a/app/native/weather-providers/nws-provider.ts b/app/native/weather-providers/nws-provider.ts new file mode 100644 index 00000000..324de2cc --- /dev/null +++ b/app/native/weather-providers/nws-provider.ts @@ -0,0 +1,271 @@ +/** + * NWS (National Weather Service) Provider for Faceclaw + */ +import { fetchWithUserAgent } from "../../util/http"; +import type { CurrentLocation } from "../location"; +import type { + CurrentWeather, + ForecastPeriod, + ProviderFetchOptions, + WeatherFetchResult, + WeatherProvider, + WeatherCondition, +} from "../weather-types"; +import { normalizeTemperature, normalizeWindSpeed } from "../weather-units"; +import { conditionFromNwsIcon, conditionFromText, isLocalDaytime } from "../weather-conditions"; + +const NWS_API_ROOT = "https://api.weather.gov"; +const USER_AGENT = "Faceclaw/1.0 (https://github.com/jimrandomh/faceclaw)"; +const NWS_HEADERS = { + Accept: "application/geo+json, application/json", + "User-Agent": USER_AGENT, +}; +const FETCH_TIMEOUT_MS = 15000; +const MAX_FORECAST_PERIODS = 14; + +type NwsMeasure = { + value?: unknown; + unitCode?: unknown; +}; + +type NwsPointResponse = { + properties?: { + forecast?: unknown; + forecastHourly?: unknown; + observationStations?: unknown; + relativeLocation?: { + properties?: { city?: unknown; state?: unknown }; + }; + }; +}; + +type NwsForecastResponse = { + properties?: { + periods?: Array<{ + name?: unknown; + startTime?: unknown; + temperature?: unknown; + temperatureUnit?: unknown; + shortForecast?: unknown; + detailedForecast?: unknown; + probabilityOfPrecipitation?: { value?: unknown }; + windSpeed?: unknown; + windDirection?: unknown; + isDaytime?: unknown; + icon?: unknown; + }>; + }; +}; + +type NwsStationsResponse = { + features?: Array<{ id?: unknown }>; +}; + +type NwsObservationResponse = { + properties?: { + timestamp?: unknown; + textDescription?: unknown; + icon?: unknown; + temperature?: NwsMeasure; + relativeHumidity?: NwsMeasure; + windSpeed?: NwsMeasure; + windDirection?: NwsMeasure; + }; +}; + +export class NwsWeatherProvider implements WeatherProvider { + readonly id = "nws"; + readonly name = "National Weather Service (US Only)"; + readonly requiresApiKey = false; + + async fetchWeather( + location: CurrentLocation, + _options?: ProviderFetchOptions, + ): Promise { + const latitude = location.latitude.toFixed(4); + const longitude = location.longitude.toFixed(4); + const point = await fetchNwsJson(`${NWS_API_ROOT}/points/${latitude},${longitude}`); + const properties = point.properties; + const forecastUrl = stringValue(properties?.forecast); + const hourlyUrl = stringValue(properties?.forecastHourly); + const stationsUrl = stringValue(properties?.observationStations); + if (!forecastUrl) throw new Error("NWS did not provide a forecast for this location."); + + const [forecastResponse, hourlyResponse, observation] = await Promise.all([ + fetchNwsJson(forecastUrl), + hourlyUrl ? fetchNwsJson(hourlyUrl).catch(() => null) : Promise.resolve(null), + stationsUrl ? loadLatestObservation(stationsUrl).catch(() => null) : Promise.resolve(null), + ]); + + const forecast = (forecastResponse.properties?.periods ?? []) + .map(normalizeForecastPeriod) + .filter((period): period is ForecastPeriod => period !== null) + .slice(0, MAX_FORECAST_PERIODS); + if (!forecast.length) throw new Error("NWS returned an empty forecast."); + + const hourly = (hourlyResponse?.properties?.periods ?? []) + .map(normalizeForecastPeriod) + .find((period): period is ForecastPeriod => period !== null); + + const current = observation ? normalizeObservation(observation, hourly) : currentFromHourly(hourly, forecast[0]!); + + return { + locationName: pointLocationName(point, latitude, longitude), + current, + forecast, + }; + } +} + +async function loadLatestObservation(stationsUrl: string): Promise { + const stations = await fetchNwsJson(stationsUrl); + const stationUrl = stringValue(stations.features?.[0]?.id); + if (!stationUrl) return null; + return fetchNwsJson(`${stationUrl}/observations/latest`); +} + +async function fetchNwsJson(url: string): Promise { + const request = fetchWithUserAgent(url, { headers: NWS_HEADERS }); + let timeoutHandle: ReturnType | null = null; + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new Error("Weather request timed out.")), FETCH_TIMEOUT_MS); + }); + try { + const response = await Promise.race([request, timeout]); + if (!response.ok) { + if (response.status === 404 && url.includes("/points/")) { + throw new Error("This location is outside National Weather Service coverage."); + } + throw new Error(`National Weather Service request failed (HTTP ${response.status}).`); + } + return (await response.json()) as T; + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +} + +function normalizeForecastPeriod(period: unknown): ForecastPeriod | null { + if (typeof period !== "object" || period === null) return null; + const raw = period as Record; + const rawTemp = numericValue(raw.temperature); + const unit = stringValue(raw.temperatureUnit); + let tempF: number | null = rawTemp; + if (rawTemp !== null && unit === "C") { + tempF = (rawTemp * 9) / 5 + 32; + } + const temps = normalizeTemperature(tempF, "fahrenheit"); + const startTimeMs = Date.parse(stringValue(raw.startTime) ?? ""); + const shortForecast = stringValue(raw.shortForecast) ?? "Unknown"; + const icon = parseNwsIcon(raw.icon); + + return { + name: stringValue(raw.name) ?? "Forecast", + startTimeMs: Number.isFinite(startTimeMs) ? startTimeMs : Date.now(), + temperatureF: temps.temperatureF, + temperatureC: temps.temperatureC, + shortForecast, + detailedForecast: stringValue(raw.detailedForecast) ?? shortForecast, + precipitationPercent: numericValue(raw.probabilityOfPrecipitation && (raw.probabilityOfPrecipitation as any).value), + windSpeed: stringValue(raw.windSpeed) ?? "", + windDirection: stringValue(raw.windDirection) ?? "", + isDaytime: icon.isDaytime ?? Boolean(raw.isDaytime), + condition: icon.condition ?? conditionFromText(shortForecast), + }; +} + +function normalizeObservation( + observation: NwsObservationResponse, + hourlyFallback: ForecastPeriod | null | undefined, +): CurrentWeather { + const properties = observation.properties; + const tempF = measureToFahrenheit(properties?.temperature); + const temps = normalizeTemperature(tempF, "fahrenheit"); + const windMph = measureToMph(properties?.windSpeed); + const winds = normalizeWindSpeed(windMph, "mph"); + const textDescription = stringValue(properties?.textDescription); + const icon = parseNwsIcon(properties?.icon); + + return { + temperatureF: temps.temperatureF ?? hourlyFallback?.temperatureF ?? null, + temperatureC: temps.temperatureC ?? hourlyFallback?.temperatureC ?? null, + description: textDescription ?? hourlyFallback?.shortForecast ?? "Unknown", + humidityPercent: numericValue(properties?.relativeHumidity?.value), + windSpeedMph: winds.windSpeedMph ?? numericSpeed(hourlyFallback?.windSpeed), + windSpeedKmh: winds.windSpeedKmh, + windDirection: compassDegreesToLabel(numericValue(properties?.windDirection?.value)) || (hourlyFallback?.windDirection ?? ""), + timestampMs: Date.parse(stringValue(properties?.timestamp) ?? "") || Date.now(), + observed: true, + condition: icon.condition ?? conditionFromText(textDescription ?? "") ?? hourlyFallback?.condition ?? null, + isDaytime: icon.isDaytime ?? hourlyFallback?.isDaytime ?? isLocalDaytime(), + }; +} + +function currentFromHourly(hourly: ForecastPeriod | null | undefined, firstPeriod: ForecastPeriod): CurrentWeather { + const fallback = hourly ?? firstPeriod; + const winds = normalizeWindSpeed(numericSpeed(fallback.windSpeed), "mph"); + return { + temperatureF: fallback.temperatureF, + temperatureC: fallback.temperatureC, + description: fallback.shortForecast, + humidityPercent: null, + windSpeedMph: winds.windSpeedMph, + windSpeedKmh: winds.windSpeedKmh, + windDirection: fallback.windDirection, + timestampMs: fallback.startTimeMs, + observed: false, + condition: fallback.condition, + isDaytime: fallback.isDaytime, + }; +} + +function pointLocationName(point: NwsPointResponse, latitude: string, longitude: string): string { + const relative = point.properties?.relativeLocation?.properties; + const city = stringValue(relative?.city); + const state = stringValue(relative?.state); + if (city && state) return `${city}, ${state}`; + if (city) return city; + return `${latitude}, ${longitude}`; +} + +function measureToFahrenheit(measure?: NwsMeasure): number | null { + const value = numericValue(measure?.value); + if (value === null) return null; + const unitCode = stringValue(measure?.unitCode) ?? ""; + if (unitCode.endsWith(":degC")) return (value * 9) / 5 + 32; + return value; +} + +function measureToMph(measure?: NwsMeasure): number | null { + const value = numericValue(measure?.value); + if (value === null) return null; + const unitCode = stringValue(measure?.unitCode) ?? ""; + if (unitCode.endsWith(":m_s-1")) return value * 2.23694; + if (unitCode.endsWith(":km_h-1")) return value * 0.621371; + return value; +} + +function compassDegreesToLabel(degrees: number | null): string { + if (degrees === null) return ""; + const directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; + const index = Math.round(((degrees % 360) / 22.5)) % 16; + return directions[index]!; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function numericValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function numericSpeed(value?: string): number | null { + if (!value) return null; + const match = value.match(/(\d+)/); + return match ? parseInt(match[1]!, 10) : null; +} + +/** Condition and day/night from an NWS icon URL; the code table is in weather-conditions.ts. */ +function parseNwsIcon(value: unknown): { condition: WeatherCondition | null; isDaytime: boolean | null } { + return conditionFromNwsIcon(value); +} diff --git a/app/native/weather-providers/open-meteo-provider.ts b/app/native/weather-providers/open-meteo-provider.ts new file mode 100644 index 00000000..e6a630d5 --- /dev/null +++ b/app/native/weather-providers/open-meteo-provider.ts @@ -0,0 +1,200 @@ +/** + * Open-Meteo provider: worldwide weather by latitude and longitude, with no + * account or API key. https://open-meteo.com/en/docs + */ +import { fetchWithUserAgent } from "../../util/http"; +import type { CurrentLocation } from "../location"; +import type { + CurrentWeather, + ForecastPeriod, + ProviderFetchOptions, + WeatherFetchResult, + WeatherProvider, +} from "../weather-types"; +import { formatTemperature, normalizeTemperature, normalizeWindSpeed } from "../weather-units"; +import { conditionFromWmoCode, isLocalDaytime } from "../weather-conditions"; + +const API_ROOT = "https://api.open-meteo.com/v1/forecast"; +const FETCH_TIMEOUT_MS = 15000; +/** Days of daily forecast to ask for; the app shows a handful. */ +const FORECAST_DAYS = 5; + +/** WMO 4677 weather codes, as Open-Meteo reports them. */ +const WMO_DESCRIPTIONS: Record = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Light rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Light snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Light snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with light hail", + 99: "Thunderstorm with heavy hail", +}; + +type OpenMeteoCurrent = { + temperature_2m?: number; + relative_humidity_2m?: number; + weather_code?: number; + is_day?: number; + wind_speed_10m?: number; + wind_direction_10m?: number; +}; + +type OpenMeteoDaily = { + time?: string[]; + weather_code?: number[]; + temperature_2m_max?: number[]; + temperature_2m_min?: number[]; + precipitation_probability_max?: number[]; +}; + +type OpenMeteoResponse = { + current?: OpenMeteoCurrent; + daily?: OpenMeteoDaily; +}; + +const COMPASS_POINTS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + +export function wmoCodeToDescription(code: number | undefined): string { + return (code !== undefined ? WMO_DESCRIPTIONS[code] : undefined) ?? "Unknown conditions"; +} + +export function degreesToCompass(degrees: number | undefined): string { + if (degrees === undefined || !Number.isFinite(degrees)) return ""; + return COMPASS_POINTS[Math.round(((degrees % 360) / 45)) % 8]!; +} + +/** The date an ISO "YYYY-MM-DD" names, read in the same calendar it was written in. */ +function parseIsoDate(value: string): Date | null { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return null; + return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12); +} + +export class OpenMeteoWeatherProvider implements WeatherProvider { + readonly id = "open-meteo"; + readonly name = "Open-Meteo"; + readonly requiresApiKey = false; + + async fetchWeather( + location: CurrentLocation, + options?: ProviderFetchOptions, + ): Promise { + // Metric units are asked for explicitly so the parsing never has to guess + // what the server decided to send. + const url = + `${API_ROOT}?latitude=${location.latitude.toFixed(4)}&longitude=${location.longitude.toFixed(4)}` + + "¤t=temperature_2m,relative_humidity_2m,weather_code,is_day,wind_speed_10m,wind_direction_10m" + + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" + + `&temperature_unit=celsius&wind_speed_unit=kmh&timezone=auto&forecast_days=${FORECAST_DAYS}`; + + const json = await fetchOpenMeteoJson(url); + + const currentData = json.current ?? {}; + const temperatures = normalizeTemperature(currentData.temperature_2m, "celsius"); + const wind = normalizeWindSpeed(currentData.wind_speed_10m, "kmh"); + const windDirection = degreesToCompass(currentData.wind_direction_10m); + + const current: CurrentWeather = { + temperatureC: temperatures.temperatureC, + temperatureF: temperatures.temperatureF, + description: wmoCodeToDescription(currentData.weather_code), + humidityPercent: currentData.relative_humidity_2m ?? null, + windSpeedKmh: wind.windSpeedKmh, + windSpeedMph: wind.windSpeedMph, + windDirection, + timestampMs: Date.now(), + observed: true, + condition: conditionFromWmoCode(currentData.weather_code), + isDaytime: currentData.is_day === undefined ? isLocalDaytime() : currentData.is_day === 1, + }; + + return { + locationName: coordinateName(location), + current, + forecast: dailyForecast(json.daily ?? {}, options?.unit ?? "celsius", windDirection), + }; + } +} + +async function fetchOpenMeteoJson(url: string): Promise { + const request = fetchWithUserAgent(url, { headers: { Accept: "application/json" } }); + let timeoutHandle: ReturnType | null = null; + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new Error("Weather request timed out.")), FETCH_TIMEOUT_MS); + }); + try { + const response = await Promise.race([request, timeout]); + if (!response.ok) throw new Error(`Open-Meteo request failed (HTTP ${response.status}).`); + return (await response.json()) as OpenMeteoResponse; + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +} + +function dailyForecast( + daily: OpenMeteoDaily, + unit: ProviderFetchOptions["unit"], + windDirection: string, +): ForecastPeriod[] { + const dates = daily.time ?? []; + const periods: ForecastPeriod[] = []; + for (let i = 0; i < Math.min(dates.length, FORECAST_DAYS); i++) { + const date = parseIsoDate(dates[i]!); + if (!date) continue; + const code = daily.weather_code?.[i]; + const highC = daily.temperature_2m_max?.[i]; + const lowC = daily.temperature_2m_min?.[i]; + // The row shows one temperature per day: its average. + const meanC = highC !== undefined && lowC !== undefined ? (highC + lowC) / 2 : highC ?? lowC; + const temperatures = normalizeTemperature(meanC, "celsius"); + const description = wmoCodeToDescription(code); + const high = formatTemperature(highC ?? null, null, unit); + const low = formatTemperature(lowC ?? null, null, unit); + periods.push({ + name: i === 0 ? "Today" : date.toLocaleDateString("en-US", { weekday: "short" }), + startTimeMs: date.getTime(), + temperatureC: temperatures.temperatureC, + temperatureF: temperatures.temperatureF, + shortForecast: description, + detailedForecast: `${description}. High ${high}, low ${low}.`, + precipitationPercent: daily.precipitation_probability_max?.[i] ?? null, + windSpeed: "", + windDirection, + // A daily period spans the whole day rather than a day or night half. + isDaytime: true, + condition: conditionFromWmoCode(code), + }); + } + return periods; +} + +/** + * Open-Meteo has no reverse geocoding, so the location reads as the + * coordinates that were sent, which is also all it knows of the place. + */ +function coordinateName(location: CurrentLocation): string { + const latitude = `${Math.abs(location.latitude).toFixed(2)}°${location.latitude >= 0 ? "N" : "S"}`; + const longitude = `${Math.abs(location.longitude).toFixed(2)}°${location.longitude >= 0 ? "E" : "W"}`; + return `${latitude}, ${longitude}`; +} diff --git a/app/native/weather-types.ts b/app/native/weather-types.ts new file mode 100644 index 00000000..577bca1b --- /dev/null +++ b/app/native/weather-types.ts @@ -0,0 +1,78 @@ +/** + * Weather abstraction types and Provider interface for Faceclaw + */ +import type { CurrentLocation } from "./location"; + +export type WeatherPhase = "permission-required" | "locating" | "loading" | "ready" | "error"; + +export type TemperatureUnit = "celsius" | "fahrenheit"; + +/** Sky and precipitation state, as coarse as the icons that show it. */ +export type WeatherCondition = + | "clear" + | "partly-cloudy" + | "cloudy" + | "fog" + | "wind" + | "drizzle" + | "showers" + | "rain" + | "thunderstorm" + | "snow" + | "sleet"; + +export type CurrentWeather = { + temperatureF: number | null; + temperatureC: number | null; + description: string; + humidityPercent: number | null; + windSpeedMph: number | null; + windSpeedKmh: number | null; + windDirection: string; + timestampMs: number | null; + observed: boolean; + condition: WeatherCondition | null; + isDaytime: boolean; +}; + +export type ForecastPeriod = { + name: string; + startTimeMs: number; + temperatureF: number | null; + temperatureC: number | null; + shortForecast: string; + detailedForecast: string; + precipitationPercent: number | null; + windSpeed: string; + windDirection: string; + isDaytime: boolean; + condition: WeatherCondition | null; +}; + +export type WeatherState = { + phase: WeatherPhase; + status: string; + locationName: string; + current: CurrentWeather | null; + forecast: ForecastPeriod[]; + lastUpdatedMs: number | null; + providerId?: string; + unit?: TemperatureUnit; +}; + +export type ProviderFetchOptions = { + unit?: TemperatureUnit; +}; + +export type WeatherFetchResult = { + locationName: string; + current: CurrentWeather; + forecast: ForecastPeriod[]; +}; + +export interface WeatherProvider { + readonly id: string; + readonly name: string; + readonly requiresApiKey: boolean; + fetchWeather(location: CurrentLocation, options?: ProviderFetchOptions): Promise; +} diff --git a/app/native/weather-units.ts b/app/native/weather-units.ts new file mode 100644 index 00000000..6cc51aea --- /dev/null +++ b/app/native/weather-units.ts @@ -0,0 +1,104 @@ +/** + * Weather Units and Conversion Utilities + */ +import type { TemperatureUnit } from "./weather-types"; + +export function fToC(f: number): number { + return ((f - 32) * 5) / 9; +} + +export function cToF(c: number): number { + return (c * 9) / 5 + 32; +} + +export function mphToKmh(mph: number): number { + return mph * 1.60934; +} + +export function kmhToMph(kmh: number): number { + return kmh / 1.60934; +} + +export function normalizeTemperature( + temp: number | null | undefined, + inputUnit: TemperatureUnit = "celsius", +): { temperatureC: number | null; temperatureF: number | null } { + if (temp === null || temp === undefined || !Number.isFinite(temp)) { + return { temperatureC: null, temperatureF: null }; + } + if (inputUnit === "celsius") { + return { + temperatureC: Math.round(temp * 10) / 10, + temperatureF: Math.round(cToF(temp) * 10) / 10, + }; + } + return { + temperatureC: Math.round(fToC(temp) * 10) / 10, + temperatureF: Math.round(temp * 10) / 10, + }; +} + +export function normalizeWindSpeed( + speed: number | null | undefined, + inputUnit: "kmh" | "mph" | "ms" = "kmh", +): { windSpeedKmh: number | null; windSpeedMph: number | null } { + if (speed === null || speed === undefined || !Number.isFinite(speed)) { + return { windSpeedKmh: null, windSpeedMph: null }; + } + let kmh = speed; + if (inputUnit === "mph") { + kmh = mphToKmh(speed); + } else if (inputUnit === "ms") { + kmh = speed * 3.6; + } + return { + windSpeedKmh: Math.round(kmh * 10) / 10, + windSpeedMph: Math.round(kmhToMph(kmh) * 10) / 10, + }; +} + +export function formatTemperature( + tempC: number | null | undefined, + tempF: number | null | undefined, + displayUnit: TemperatureUnit = "celsius", +): string { + if (displayUnit === "celsius") { + if (tempC !== null && tempC !== undefined && Number.isFinite(tempC)) { + return `${Math.round(tempC)}°C`; + } + if (tempF !== null && tempF !== undefined && Number.isFinite(tempF)) { + return `${Math.round(fToC(tempF))}°C`; + } + } else { + if (tempF !== null && tempF !== undefined && Number.isFinite(tempF)) { + return `${Math.round(tempF)}°F`; + } + if (tempC !== null && tempC !== undefined && Number.isFinite(tempC)) { + return `${Math.round(cToF(tempC))}°F`; + } + } + return "--°"; +} + +export function formatWindSpeed( + speedKmh: number | null | undefined, + speedMph: number | null | undefined, + displayUnit: TemperatureUnit = "celsius", +): string { + if (displayUnit === "celsius") { + if (speedKmh !== null && speedKmh !== undefined && Number.isFinite(speedKmh)) { + return `${Math.round(speedKmh)} km/h`; + } + if (speedMph !== null && speedMph !== undefined && Number.isFinite(speedMph)) { + return `${Math.round(mphToKmh(speedMph))} km/h`; + } + } else { + if (speedMph !== null && speedMph !== undefined && Number.isFinite(speedMph)) { + return `${Math.round(speedMph)} mph`; + } + if (speedKmh !== null && speedKmh !== undefined && Number.isFinite(speedKmh)) { + return `${Math.round(kmhToMph(speedKmh))} mph`; + } + } + return ""; +} diff --git a/app/native/weather.ts b/app/native/weather.ts index e8e55dcb..1a0019f5 100644 --- a/app/native/weather.ts +++ b/app/native/weather.ts @@ -1,132 +1,58 @@ import { hasLocationPermission } from "./location-permissions"; -import { getCurrentLocation, type CurrentLocation } from "./location"; -import { getStringSetting, setStringSetting } from "./settings-store"; -import { fetchWithUserAgent } from "../util/http"; -import { USER_AGENT } from "../version"; - -export type WeatherPhase = "permission-required" | "locating" | "loading" | "ready" | "error"; - -/** Sky and precipitation state, as coarse as the icons that show it. */ -export type WeatherCondition = - | "clear" - | "partly-cloudy" - | "cloudy" - | "fog" - | "wind" - | "drizzle" - | "showers" - | "rain" - | "thunderstorm" - | "snow" - | "sleet"; - -export type CurrentWeather = { - temperatureF: number | null; - description: string; - humidityPercent: number | null; - windSpeedMph: number | null; - windDirection: string; - timestampMs: number | null; - observed: boolean; - condition: WeatherCondition | null; - isDaytime: boolean; -}; - -export type ForecastPeriod = { - name: string; - startTimeMs: number; - temperatureF: number | null; - shortForecast: string; - detailedForecast: string; - precipitationPercent: number | null; - windSpeed: string; - windDirection: string; - isDaytime: boolean; - condition: WeatherCondition | null; -}; +import { getCurrentLocation } from "./location"; +import { + type CurrentWeather, + type ForecastPeriod, + type TemperatureUnit, + type WeatherState, +} from "./weather-types"; +import { + DEFAULT_WEATHER_PROVIDER_ID, + getWeatherProvider, +} from "./weather-providers"; +import { + getStringSetting, + onSettingsStoreChanged, + setStringSetting, +} from "./settings-store"; + +export * from "./weather-types"; +export * from "./weather-units"; +export * from "./weather-providers"; -export type WeatherState = { - phase: WeatherPhase; - status: string; - locationName: string; - current: CurrentWeather | null; - forecast: ForecastPeriod[]; - lastUpdatedMs: number | null; -}; - -type NwsPointResponse = { - properties?: { - forecast?: unknown; - forecastHourly?: unknown; - observationStations?: unknown; - relativeLocation?: { - properties?: { city?: unknown; state?: unknown }; - }; - }; -}; - -type NwsForecastResponse = { - properties?: { - periods?: NwsForecastPeriodResponse[]; - }; -}; - -type NwsForecastPeriodResponse = { - name?: unknown; - startTime?: unknown; - temperature?: unknown; - temperatureUnit?: unknown; - shortForecast?: unknown; - detailedForecast?: unknown; - probabilityOfPrecipitation?: { value?: unknown }; - windSpeed?: unknown; - windDirection?: unknown; - isDaytime?: unknown; - icon?: unknown; -}; - -type NwsStationsResponse = { - features?: Array<{ id?: unknown }>; -}; - -type NwsObservationResponse = { - properties?: { - timestamp?: unknown; - textDescription?: unknown; - icon?: unknown; - temperature?: NwsMeasure; - relativeHumidity?: NwsMeasure; - windSpeed?: NwsMeasure; - windDirection?: NwsMeasure; - }; -}; - -type NwsMeasure = { - value?: unknown; - unitCode?: unknown; -}; - -const NWS_API_ROOT = "https://api.weather.gov"; -const NWS_HEADERS = { - Accept: "application/geo+json", - // NWS asks callers to identify themselves with a contact address. - "User-Agent": `${USER_AGENT} (https://github.com/jimrandomh/faceclaw)`, -}; /** Refresh period while the Weather app is open. */ const APP_REFRESH_MS = 30 * 60 * 1000; /** Refresh period for the status-bar and system-card indicators. */ const BACKGROUND_REFRESH_MS = 60 * 60 * 1000; /** Delay before retrying after a failed refresh (capped at the period). */ const RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000; -const FETCH_TIMEOUT_MS = 20_000; -const MAX_FORECAST_PERIODS = 14; /** Settings-store key holding the last successful refresh. */ const CACHE_KEY = "weather.cache"; const CACHE_VERSION = 1; +export const WEATHER_PROVIDER_KEY = "weather.provider"; +export const WEATHER_UNIT_KEY = "weather.unit"; + +export function getWeatherUnitSetting(): TemperatureUnit { + const raw = getStringSetting(WEATHER_UNIT_KEY, "celsius"); + return raw === "fahrenheit" ? "fahrenheit" : "celsius"; +} + +export function setWeatherUnitSetting(unit: TemperatureUnit): void { + setStringSetting(WEATHER_UNIT_KEY, unit); +} + +export function getWeatherProviderSetting(): string { + return getStringSetting(WEATHER_PROVIDER_KEY, DEFAULT_WEATHER_PROVIDER_ID); +} + +export function setWeatherProviderSetting(providerId: string): void { + setStringSetting(WEATHER_PROVIDER_KEY, providerId); +} + const DEFAULT_STATE: WeatherState = { phase: "permission-required", - status: "Location permission is required for local weather.", + status: "Location permission is required to get local weather.", locationName: "", current: null, forecast: [], @@ -139,6 +65,8 @@ type WeatherCache = { current: CurrentWeather | null; forecast: ForecastPeriod[]; lastUpdatedMs: number; + providerId?: string; + unit?: TemperatureUnit; }; /** @@ -158,6 +86,16 @@ export class WeatherBridge { private appOpen = false; private sessionActive = false; private backgroundWanted = false; + private settingsListenerCleanup: (() => void) | null = null; + + constructor() { + this.settingsListenerCleanup = onSettingsStoreChanged((key) => { + if (key === WEATHER_PROVIDER_KEY || key === WEATHER_UNIT_KEY) { + // Refresh as soon as the provider or unit changes. + void this.refreshNow(); + } + }); + } onStateChange(listener: (state: WeatherState) => void): () => void { this.listeners.add(listener); @@ -262,16 +200,27 @@ export class WeatherBridge { this.emit(); const location = await getCurrentLocation(); - this.state = { ...this.state, phase: "loading", status: "Loading National Weather Service data..." }; + const providerId = getWeatherProviderSetting(); + const provider = getWeatherProvider(providerId); + const unit = getWeatherUnitSetting(); + + this.state = { + ...this.state, + phase: "loading", + status: `Fetching weather from ${provider.name}...`, + }; this.emit(); - const weather = await loadNwsWeather(location); + + const weather = await provider.fetchWeather(location, { unit }); this.state = { phase: "ready", - status: "Weather updated.", + status: "Weather updated", locationName: weather.locationName, current: weather.current, forecast: weather.forecast, lastUpdatedMs: Date.now(), + providerId: provider.id, + unit, }; saveCachedState(this.state); this.emit(); @@ -315,6 +264,8 @@ function loadCachedState(): WeatherState { current: cache.current && typeof cache.current === "object" ? cache.current : null, forecast: cache.forecast, lastUpdatedMs: cache.lastUpdatedMs, + providerId: typeof cache.providerId === "string" ? cache.providerId : undefined, + unit: cache.unit, }); } catch (error) { console.warn(`weather cache unreadable: ${error}`); @@ -330,6 +281,8 @@ function saveCachedState(state: WeatherState): void { current: state.current, forecast: state.forecast, lastUpdatedMs: state.lastUpdatedMs, + providerId: state.providerId, + unit: state.unit, }; try { setStringSetting(CACHE_KEY, JSON.stringify(cache)); @@ -337,266 +290,22 @@ function saveCachedState(state: WeatherState): void { console.warn(`weather cache write failed: ${error}`); } } - -async function loadNwsWeather(location: CurrentLocation): Promise<{ - locationName: string; - current: CurrentWeather; - forecast: ForecastPeriod[]; -}> { - // NWS recommends no more than four decimals for its point lookup. - const latitude = location.latitude.toFixed(4); - const longitude = location.longitude.toFixed(4); - const point = await fetchNwsJson(`${NWS_API_ROOT}/points/${latitude},${longitude}`); - const properties = point.properties; - const forecastUrl = stringValue(properties?.forecast); - const hourlyUrl = stringValue(properties?.forecastHourly); - const stationsUrl = stringValue(properties?.observationStations); - if (!forecastUrl) throw new Error("NWS did not provide a forecast for this location."); - - const [forecastResponse, hourlyResponse, observation] = await Promise.all([ - fetchNwsJson(forecastUrl), - hourlyUrl ? fetchNwsJson(hourlyUrl).catch(() => null) : Promise.resolve(null), - stationsUrl ? loadLatestObservation(stationsUrl).catch(() => null) : Promise.resolve(null), - ]); - const forecast = (forecastResponse.properties?.periods ?? []) - .map(normalizeForecastPeriod) - .filter((period): period is ForecastPeriod => period !== null) - .slice(0, MAX_FORECAST_PERIODS); - if (!forecast.length) throw new Error("NWS returned an empty forecast."); - - const hourly = (hourlyResponse?.properties?.periods ?? []) - .map(normalizeForecastPeriod) - .find((period): period is ForecastPeriod => period !== null); - - return { - locationName: pointLocationName(point, latitude, longitude), - current: observation ? normalizeObservation(observation, hourly) : currentFromHourly(hourly, forecast[0]!), - forecast, - }; -} - -async function loadLatestObservation(stationsUrl: string): Promise { - const stations = await fetchNwsJson(stationsUrl); - const stationUrl = stringValue(stations.features?.[0]?.id); - if (!stationUrl) return null; - return fetchNwsJson(`${stationUrl}/observations/latest`); -} - -async function fetchNwsJson(url: string): Promise { - const request = fetchWithUserAgent(url, { headers: NWS_HEADERS }); - let timeoutHandle: ReturnType | null = null; - const timeout = new Promise((_resolve, reject) => { - timeoutHandle = setTimeout(() => reject(new Error("Weather request timed out.")), FETCH_TIMEOUT_MS); - }); - try { - const response = await Promise.race([request, timeout]); - if (!response.ok) { - if (response.status === 404 && url.includes("/points/")) { - throw new Error("This location is outside National Weather Service coverage."); - } - throw new Error(`National Weather Service request failed (HTTP ${response.status}).`); - } - return (await response.json()) as T; - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - -function normalizeObservation( - response: NwsObservationResponse, - hourly: ForecastPeriod | undefined, -): CurrentWeather { - const properties = response.properties; - const temperatureF = convertTemperatureToF(properties?.temperature); - const windSpeedMph = convertSpeedToMph(properties?.windSpeed); - const windDegrees = finiteNumber(properties?.windDirection?.value); - const textDescription = stringValue(properties?.textDescription); - const icon = parseNwsIcon(properties?.icon); - return { - temperatureF: temperatureF ?? hourly?.temperatureF ?? null, - description: textDescription || hourly?.shortForecast || "Current conditions", - humidityPercent: finiteNumber(properties?.relativeHumidity?.value), - windSpeedMph, - windDirection: windDegrees === null ? hourly?.windDirection ?? "" : degreesToCompass(windDegrees), - timestampMs: timestampValue(properties?.timestamp), - observed: true, - condition: icon.condition ?? conditionFromText(textDescription) ?? hourly?.condition ?? null, - isDaytime: icon.isDaytime ?? hourly?.isDaytime ?? isLocalDaytime(), - }; -} - -function currentFromHourly(hourly: ForecastPeriod | undefined, fallback: ForecastPeriod): CurrentWeather { - const source = hourly ?? fallback; - return { - temperatureF: source.temperatureF, - description: source.shortForecast || "Current forecast", - humidityPercent: null, - windSpeedMph: firstNumberInText(source.windSpeed), - windDirection: source.windDirection, - timestampMs: source.startTimeMs || null, - observed: false, - condition: source.condition, - isDaytime: source.isDaytime, - }; -} - -function normalizeForecastPeriod(value: NwsForecastPeriodResponse): ForecastPeriod | null { - if (!value || typeof value !== "object") return null; - const name = stringValue(value.name); - const temperature = finiteNumber(value.temperature); - const unit = stringValue(value.temperatureUnit).toUpperCase(); - const shortForecast = stringValue(value.shortForecast); - const icon = parseNwsIcon(value.icon); - return { - name: name || "Forecast", - startTimeMs: timestampValue(value.startTime) ?? 0, - temperatureF: temperature === null ? null : unit === "C" ? temperature * 9 / 5 + 32 : temperature, - shortForecast, - detailedForecast: stringValue(value.detailedForecast), - precipitationPercent: finiteNumber(value.probabilityOfPrecipitation?.value), - windSpeed: stringValue(value.windSpeed), - windDirection: stringValue(value.windDirection), - isDaytime: icon.isDaytime ?? Boolean(value.isDaytime), - condition: icon.condition ?? conditionFromText(shortForecast), - }; -} - -// NWS icon codes (https://api.weather.gov/icons) by the condition they show. -const NWS_ICON_CONDITIONS: Record = { - skc: "clear", - hot: "clear", - cold: "clear", - few: "partly-cloudy", - sct: "partly-cloudy", - bkn: "cloudy", - ovc: "cloudy", - wind_skc: "wind", - wind_few: "wind", - wind_sct: "wind", - wind_bkn: "wind", - wind_ovc: "wind", - fog: "fog", - haze: "fog", - smoke: "fog", - dust: "fog", - rain: "rain", - rain_showers: "rain", - rain_showers_hi: "showers", - tsra: "thunderstorm", - tsra_sct: "thunderstorm", - tsra_hi: "thunderstorm", - tornado: "thunderstorm", - hurricane: "thunderstorm", - tropical_storm: "thunderstorm", - snow: "snow", - blizzard: "snow", - sleet: "sleet", - fzra: "sleet", - rain_fzra: "sleet", - snow_fzra: "sleet", - rain_sleet: "sleet", - snow_sleet: "sleet", - rain_snow: "sleet", -}; - -/** - * Condition and day/night from an NWS icon URL such as - * ".../icons/land/day/tsra_sct,40/rain,20?size=medium". A period that - * changes partway lists two codes; the first is the one in effect now. - */ -function parseNwsIcon(value: unknown): { condition: WeatherCondition | null; isDaytime: boolean | null } { - const match = stringValue(value).match(/\/icons\/[^/]+\/(day|night)\/([a-z_]+)/); - if (!match) return { condition: null, isDaytime: null }; - return { condition: NWS_ICON_CONDITIONS[match[2]!] ?? null, isDaytime: match[1] === "day" }; -} - -/** Condition from an NWS text description ("Chance Rain Showers"), for when there is no icon. */ -function conditionFromText(text: string): WeatherCondition | null { - const lower = text.toLowerCase(); - if (/thunder|t-storm|tstorm/.test(lower)) return "thunderstorm"; - if (/freezing|sleet|ice pellets|wintry|hail|rain and snow|snow and rain/.test(lower)) return "sleet"; - if (/snow|flurr|blizzard/.test(lower)) return "snow"; - if (/drizzle/.test(lower)) return "drizzle"; - if (/rain|showers/.test(lower)) return /chance|slight|scattered|isolated|patchy/.test(lower) ? "showers" : "rain"; - if (/fog|haze|smoke|dust|mist/.test(lower)) return "fog"; - if (/windy|breezy|blustery/.test(lower)) return "wind"; - if (/partly|few clouds|mostly sunny|mostly clear/.test(lower)) return "partly-cloudy"; - if (/cloud|overcast/.test(lower)) return "cloudy"; - if (/sunny|clear|fair/.test(lower)) return "clear"; - return null; -} - -/** Rough day/night by the phone's clock, for when NWS gives no hint. */ -function isLocalDaytime(): boolean { - const hour = new Date().getHours(); - return hour >= 6 && hour < 18; -} - -function convertTemperatureToF(measure: NwsMeasure | undefined): number | null { - const value = finiteNumber(measure?.value); - if (value === null) return null; - const unit = stringValue(measure?.unitCode).toLowerCase(); - if (unit.includes("degc")) return value * 9 / 5 + 32; - return value; -} - -function convertSpeedToMph(measure: NwsMeasure | undefined): number | null { - const value = finiteNumber(measure?.value); - if (value === null) return null; - const unit = stringValue(measure?.unitCode).toLowerCase(); - if (unit.includes("km_h")) return value * 0.621371; - if (unit.includes("m_s")) return value * 2.23694; - return value; -} - -function pointLocationName(point: NwsPointResponse, latitude: string, longitude: string): string { - const relative = point.properties?.relativeLocation?.properties; - const city = stringValue(relative?.city); - const state = stringValue(relative?.state); - return [city, state].filter(Boolean).join(", ") || `${latitude}, ${longitude}`; -} - -function degreesToCompass(value: number): string { - const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; - return directions[Math.round((((value % 360) + 360) % 360) / 45) % directions.length]!; -} - -function firstNumberInText(value: string): number | null { - const match = value.match(/\d+(?:\.\d+)?/); - return match ? Number(match[0]) : null; -} - -function finiteNumber(value: unknown): number | null { - if (value === null || value === undefined || typeof value === "boolean") return null; - if (typeof value === "string" && !value.trim()) return null; - const number = typeof value === "number" ? value : Number(value); - return Number.isFinite(number) ? number : null; -} - -function timestampValue(value: unknown): number | null { - if (typeof value !== "string") return null; - const timestamp = Date.parse(value); - return Number.isFinite(timestamp) ? timestamp : null; -} - -function stringValue(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function friendlyWeatherError(error: unknown): string { - const message = (error as Error)?.message || String(error); - if (/network request failed|failed to fetch|unable to resolve host/i.test(message)) { - return "Couldn't reach the National Weather Service. Check the phone's connection and retry."; + const text = error instanceof Error ? error.message : String(error); + if (text.includes("timed out")) return "Weather request timed out. Check your network and try again."; + if (text.includes("outside National Weather Service coverage")) { + return "This location is outside NWS coverage. Switch to Open-Meteo in the Weather settings."; } - return message; + return text || "Failed to fetch weather data."; } function cloneState(state: WeatherState): WeatherState { return { ...state, current: state.current ? { ...state.current } : null, - forecast: state.forecast.map((period) => ({ ...period })), + forecast: state.forecast.map((p) => ({ ...p })), }; } export const weatherBridge = new WeatherBridge(); + diff --git a/app/ui/dashboard-settings.ts b/app/ui/dashboard-settings.ts index 87450ba7..a29bcf0b 100644 --- a/app/ui/dashboard-settings.ts +++ b/app/ui/dashboard-settings.ts @@ -1579,3 +1579,36 @@ export class EditTextSettingLayer implements Layer { } } } + +// --- Weather Settings --- + +export const weatherProviderSetting = new ConfigSettingEnum({ + id: "weather-provider", + label: "Weather provider", + storageKey: "weather.provider", + defaultValue: "open-meteo", + values: ["open-meteo", "nws"], + formatValue: (value) => { + switch (value) { + case "open-meteo": + return "Open-Meteo (Worldwide)"; + case "nws": + return "NWS (US Only)"; + default: + return value; + } + }, + description: + "Weather data source. Open-Meteo works worldwide with no account; the National Weather Service only covers the United States.", +}); + +export const weatherUnitSetting = new ConfigSettingEnum({ + id: "weather-unit", + label: "Temperature unit", + storageKey: "weather.unit", + defaultValue: "celsius", + values: ["celsius", "fahrenheit"], + formatValue: (value) => (value === "celsius" ? "Celsius (°C)" : "Fahrenheit (°F)"), + description: "Temperature display unit: Celsius (°C) or Fahrenheit (°F).", +}); + diff --git a/app/ui/dashboard/settings-menus.ts b/app/ui/dashboard/settings-menus.ts index d9e8bbc1..f69de761 100644 --- a/app/ui/dashboard/settings-menus.ts +++ b/app/ui/dashboard/settings-menus.ts @@ -88,6 +88,8 @@ import { watchCrownClockwiseNextSetting, watchMirrorAssistantSetting, watchRemoteEnabledSetting, + weatherProviderSetting, + weatherUnitSetting, } from "../dashboard-settings"; import { wearBridge } from "../../native/wear-bridge"; import { openSettingsSubMenu, SettingsPanelLayer, type SettingsSection } from "./settings-panel"; @@ -168,6 +170,13 @@ function settingsSections(): SettingsSection[] { textSettingMenuItem(mapboxApiKeySetting), ], }, + { + label: "Weather", + items: [ + enumSettingMenuItem(weatherProviderSetting), + enumSettingMenuItem(weatherUnitSetting), + ], + }, { label: "Phone display", // The phone app's mirror of the glasses screen and its controls diff --git a/tests/helpers/load-typescript.cjs b/tests/helpers/load-typescript.cjs index 69702bf2..9d179ac4 100644 --- a/tests/helpers/load-typescript.cjs +++ b/tests/helpers/load-typescript.cjs @@ -19,7 +19,8 @@ exports.loader = (globals = {}, mocks = {}) => { require(name) { if (name in mocks) return mocks[name]; if (!name.startsWith('.')) throw new Error(`Unmocked module: ${name}`); - return load(path.resolve(path.dirname(file), name + '.ts')); + const base = path.resolve(path.dirname(file), name); + return load(fs.existsSync(base + '.ts') ? base + '.ts' : path.join(base, 'index.ts')); }, }, { filename: file }); return output; diff --git a/tests/ios-location.test.cjs b/tests/ios-location.test.cjs index cdc5d5df..86203945 100644 --- a/tests/ios-location.test.cjs +++ b/tests/ios-location.test.cjs @@ -98,7 +98,8 @@ test('Weather uses the portable location provider and preserves NWS parsing', as './settings-store': { getStringSetting: (_key, fallback) => fallback, setStringSetting() {} }, './location': { getCurrentLocation: async () => fix }, '../version': { USER_AGENT: 'test' }, - '../util/http': { fetchWithUserAgent: async url => { requests.push(url); return { ok: true, json: async () => + './settings-store': { getStringSetting: (key, fallback) => key === 'weather.provider' ? 'nws' : (fallback ?? ''), setStringSetting: () => {}, onSettingsStoreChanged: () => () => {} }, + '../../util/http': { fetchWithUserAgent: async url => { requests.push(url); return { ok: true, json: async () => url.includes('/points/') ? { properties: { forecast: 'https://fixture/forecast', relativeLocation: { properties: { city: 'San Francisco', state: 'CA' } } } } : { properties: { periods: [period] } } }; } }, })('app/native/weather.ts'); diff --git a/tests/weather-providers.test.cjs b/tests/weather-providers.test.cjs new file mode 100644 index 00000000..996e0403 --- /dev/null +++ b/tests/weather-providers.test.cjs @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { loader } = require('./helpers/load-typescript.cjs'); + +const FIXTURE = { + current: { + temperature_2m: 18.5, + relative_humidity_2m: 65, + weather_code: 1, + is_day: 1, + wind_speed_10m: 12.4, + wind_direction_10m: 180, + }, + daily: { + time: ['2026-09-10', '2026-09-11', '2026-09-12'], + weather_code: [1, 2, 61], + temperature_2m_max: [22, 24.5, 20], + temperature_2m_min: [14, 15, 13], + precipitation_probability_max: [10, 20, 80], + }, +}; + +const LOCATION = { latitude: 37.7749, longitude: -122.4194 }; + +function loadOpenMeteo(respond) { + const requests = []; + const load = loader({ setTimeout, clearTimeout }, { + '../../util/http': { + fetchWithUserAgent: async (url) => { + requests.push(url); + return respond(url); + }, + }, + }); + return { provider: new (load('app/native/weather-providers/open-meteo-provider.ts').OpenMeteoWeatherProvider)(), requests }; +} + +const jsonResponse = (body) => ({ ok: true, status: 200, json: async () => body }); + +test('Open-Meteo requests metric units for the given coordinates', async () => { + const { provider, requests } = loadOpenMeteo(() => jsonResponse(FIXTURE)); + await provider.fetchWeather(LOCATION, { unit: 'celsius' }); + const url = new URL(requests[0]); + assert.equal(url.host, 'api.open-meteo.com'); + assert.equal(url.searchParams.get('latitude'), '37.7749'); + assert.equal(url.searchParams.get('longitude'), '-122.4194'); + assert.equal(url.searchParams.get('temperature_unit'), 'celsius'); + assert.equal(url.searchParams.get('wind_speed_unit'), 'kmh'); +}); + +test('Open-Meteo turns the current observation into both temperature units', async () => { + const { provider } = loadOpenMeteo(() => jsonResponse(FIXTURE)); + const { current, locationName } = await provider.fetchWeather(LOCATION); + assert.equal(current.temperatureC, 18.5); + assert.equal(current.temperatureF, 65.3); + assert.equal(current.humidityPercent, 65); + assert.equal(current.windSpeedKmh, 12.4); + assert.equal(current.windDirection, 'S'); + assert.equal(current.condition, 'partly-cloudy'); + assert.equal(current.description, 'Mainly clear'); + assert.equal(current.isDaytime, true); + assert.equal(current.observed, true); + assert.equal(locationName, '37.77°N, 122.42°W'); +}); + +test('Open-Meteo reports one forecast row per day, and the unit decides the text', async () => { + const { provider } = loadOpenMeteo(() => jsonResponse(FIXTURE)); + const celsius = await provider.fetchWeather(LOCATION, { unit: 'celsius' }); + assert.deepEqual([...celsius.forecast.map((period) => period.name)], ['Today', 'Fri', 'Sat']); + assert.equal(celsius.forecast[0].temperatureC, 18); + assert.equal(celsius.forecast[0].shortForecast, 'Mainly clear'); + assert.equal(celsius.forecast[0].detailedForecast, 'Mainly clear. High 22°C, low 14°C.'); + assert.equal(celsius.forecast[2].precipitationPercent, 80); + assert.equal(celsius.forecast[2].condition, 'rain'); + + const fahrenheit = await provider.fetchWeather(LOCATION, { unit: 'fahrenheit' }); + assert.equal(fahrenheit.forecast[0].detailedForecast, 'Mainly clear. High 72°F, low 57°F.'); +}); + +test('Open-Meteo surfaces an HTTP failure instead of an empty forecast', async () => { + const { provider } = loadOpenMeteo(() => ({ ok: false, status: 503, json: async () => ({}) })); + await assert.rejects(() => provider.fetchWeather(LOCATION), /Open-Meteo request failed \(HTTP 503\)/); +}); + +test('Unknown WMO codes fall back to a neutral description and condition', async () => { + const { provider } = loadOpenMeteo(() => jsonResponse({ + current: { weather_code: 42, temperature_2m: 5 }, + daily: { time: ['2026-09-10'], weather_code: [42] }, + })); + const { current, forecast } = await provider.fetchWeather(LOCATION); + assert.equal(current.description, 'Unknown conditions'); + assert.equal(current.condition, null); + assert.equal(forecast[0].shortForecast, 'Unknown conditions'); +}); + +test('Unit converters round-trip temperatures and wind speeds', () => { + const units = loader({ setTimeout, clearTimeout }, {})('app/native/weather-units.ts'); + assert.equal(Math.round(units.cToF(0)), 32); + assert.equal(Math.round(units.fToC(212)), 100); + assert.equal(units.normalizeTemperature(68, 'fahrenheit').temperatureC, 20); + assert.equal(units.normalizeWindSpeed(10, 'ms').windSpeedKmh, 36); + assert.equal(units.formatTemperature(18.5, 65.3, 'celsius'), '19°C'); + assert.equal(units.formatTemperature(18.5, 65.3, 'fahrenheit'), '65°F'); + assert.equal(units.formatTemperature(null, null, 'celsius'), '--°'); + assert.equal(units.formatWindSpeed(12.4, 7.7, 'celsius'), '12 km/h'); + assert.equal(units.formatWindSpeed(12.4, 7.7, 'fahrenheit'), '8 mph'); +}); + +test('The provider registry defaults to Open-Meteo and falls back for unknown ids', () => { + const load = loader({ setTimeout, clearTimeout }, {}); + const registry = load('app/native/weather-providers/index.ts'); + assert.equal(registry.DEFAULT_WEATHER_PROVIDER_ID, 'open-meteo'); + assert.equal(registry.getWeatherProvider().id, 'open-meteo'); + assert.equal(registry.getWeatherProvider('nws').id, 'nws'); + assert.equal(registry.getWeatherProvider('nope').id, 'open-meteo'); + assert.deepEqual([...registry.listWeatherProviders().map((provider) => provider.id)], ['open-meteo', 'nws']); +}); diff --git a/tests/weather.test.cjs b/tests/weather.test.cjs index 14530cff..a3ef5f10 100644 --- a/tests/weather.test.cjs +++ b/tests/weather.test.cjs @@ -6,18 +6,10 @@ const HOUR_MS = 60 * 60 * 1000; const fix = { latitude: 37.7, longitude: -122.4, accuracyMeters: 8, timestampMs: 1234 }; /** weather.ts with fake timers, settings store and NWS responses. */ -function weatherHarness({ periods, observation = null, store = new Map(), permission = () => true }) { +function weatherHarness({ periods, observation = null, store = new Map([['weather.provider', 'nws']]), permission = () => true }) { const timers = new Map(), requests = []; let nextTimer = 1; - const api = loader({ - setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn: () => { timers.delete(id); fn(); }, ms }); return id; }, - clearTimeout: id => timers.delete(id), - }, { - './location-permissions': { hasLocationPermission: permission }, - './location': { getCurrentLocation: async () => fix }, - './settings-store': { getStringSetting: (key, fallback) => store.get(key) ?? fallback, setStringSetting: (key, value) => store.set(key, value) }, - '../version': { USER_AGENT: 'test' }, - '../util/http': { fetchWithUserAgent: async url => { + const fetchMock = { fetchWithUserAgent: async url => { requests.push(url); let body; if (url.includes('/points/')) { @@ -34,7 +26,18 @@ function weatherHarness({ periods, observation = null, store = new Map(), permis body = { properties: { periods } }; } return { ok: true, json: async () => body }; - } }, + } }; + + const api = loader({ + setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn: () => { timers.delete(id); fn(); }, ms }); return id; }, + clearTimeout: id => timers.delete(id), + }, { + './location-permissions': { hasLocationPermission: permission }, + './location': { getCurrentLocation: async () => fix }, + './settings-store': { getStringSetting: (key, fallback) => store.get(key) ?? fallback, setStringSetting: (key, value) => store.set(key, value), onSettingsStoreChanged: () => () => {} }, + '../version': { USER_AGENT: 'test' }, + '../util/http': fetchMock, + '../../util/http': fetchMock, })('app/native/weather.ts'); // Only the bridge's refresh timer outlives a refresh (fetch timeouts are cleared). const refreshTimer = () => { @@ -105,7 +108,7 @@ test('Weather restores its cache on restart and refreshes hourly only during a s }); test('Weather refreshes a stale cache at once and backs off after a failure', async () => { - const store = new Map([['weather.cache', JSON.stringify({ + const store = new Map([['weather.provider', 'nws'], ['weather.cache', JSON.stringify({ version: 1, locationName: 'Old', current: null, forecast: [], lastUpdatedMs: Date.now() - 2 * HOUR_MS, })]]); let permission = true; @@ -184,13 +187,17 @@ test('Weather tray icon follows the status-bar setting and hides a stale reading assert.equal(h.background.at(-1), true); h.setSetting('weather.showInStatusBar', true); - const current = { temperatureF: 71.6, condition: 'rain', isDaytime: true }; + const current = { temperatureC: 22, temperatureF: 71.6, condition: 'rain', isDaytime: true }; h.setState({ phase: 'ready', current, forecast: [], lastUpdatedMs: Date.now() }); const [id, icon] = h.trays.at(-1); assert.equal(id, 'weather'); assert.equal(icon.height, 24); assert.deepEqual(icon.draws.map(draw => draw[0]), ['image', 'text']); - assert.equal(icon.draws[1][3], '72°'); + assert.equal(icon.draws[1][3], '22°', 'the status bar follows the Celsius default'); + + h.setState({ phase: 'ready', current, forecast: [], lastUpdatedMs: Date.now(), unit: 'fahrenheit' }); + assert.equal(h.trays.at(-1)[1].draws[1][3], '72°', 'and switches with the unit setting'); + h.setState({ phase: 'ready', current, forecast: [], lastUpdatedMs: Date.now(), unit: 'celsius' }); // An unchanged reading does not rebuild the icon. const count = h.trays.length;