Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions PRIVACY
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions app/apps/weather/weather-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,19 @@ 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;
};

/** The current reading, or null when there is none or it has gone stale. */
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)}°`,
};
}

Expand Down
27 changes: 15 additions & 12 deletions app/apps/weather/weather.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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;
Expand Down
117 changes: 117 additions & 0 deletions app/native/weather-conditions.ts
Original file line number Diff line number Diff line change
@@ -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<number, WeatherCondition> = {
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<string, WeatherCondition> = {
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;
}
29 changes: 29 additions & 0 deletions app/native/weather-providers/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, WeatherProvider> = {
"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,
}));
}
Loading