diff --git a/cspell.config.json b/cspell.config.json index 2bcac3aed6..dd2804cc43 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -29,6 +29,7 @@ "boxspinner", "Brasileiro", "Brento", + "brightsky", "browserwindow", "bryanzzhu", "btoconnor", @@ -222,6 +223,7 @@ "MMPM", "modernizr", "modulename", + "MOSMIX", "multiday", "Mystara", "Ñandú", diff --git a/defaultmodules/weather/providers/brightsky.js b/defaultmodules/weather/providers/brightsky.js new file mode 100644 index 0000000000..cb74c4e271 --- /dev/null +++ b/defaultmodules/weather/providers/brightsky.js @@ -0,0 +1,304 @@ +const Log = require("logger"); +const { convertKmhToMs, getDateString, getSunTimes, isDayTime } = require("../provider-utils"); +const WeatherProvider = require("../weatherprovider"); + +// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api +const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client"; +const BRIGHTSKY_BASE = "https://api.brightsky.dev"; +const ERROR_TRANSLATION_KEY = "MODULE_ERROR_UNSPECIFIED"; + +// Bright Sky icon aliases, see https://brightsky.dev/docs/#/operations/getWeather +const ICON_MAP = { + "clear-day": ["day-sunny", "night-clear"], + "clear-night": ["day-sunny", "night-clear"], + "partly-cloudy-day": ["day-cloudy", "night-alt-cloudy"], + "partly-cloudy-night": ["day-cloudy", "night-alt-cloudy"], + cloudy: ["cloudy", "cloudy"], + fog: ["day-fog", "night-fog"], + wind: ["strong-wind", "strong-wind"], + rain: ["day-rain", "night-alt-rain"], + sleet: ["day-sleet", "night-alt-sleet"], + snow: ["day-snow", "night-alt-snow"], + hail: ["day-hail", "night-alt-hail"], + thunderstorm: ["day-thunderstorm", "night-alt-thunderstorm"] +}; + +/** + * Server-side weather provider for Bright Sky, a free JSON API for the open + * data of the German Meteorological Service (DWD): station observations and + * MOSMIX forecasts. No API key required. Best coverage in Germany. + * see https://brightsky.dev/ + */ +class BrightSkyProvider extends WeatherProvider { + constructor (config) { + super(); + this.config = { + apiBase: BRIGHTSKY_BASE, + lat: 0, + lon: 0, + type: "current", + maxNumberOfDays: 5, + maxEntries: 5, + updateInterval: 10 * 60 * 1000, + ...config + }; + } + + async initialize () { + if (!Number.isFinite(this.config.lat) || !Number.isFinite(this.config.lon)) { + Log.error("[brightsky] Latitude and longitude are required"); + this.#sendError("Latitude and longitude are required"); + return; + } + + await this.#fetchLocation(); + this._createJSONFetcher({ + urlFactory: () => this.#getUrl(), + reloadInterval: this.config.updateInterval, + logContext: "weatherprovider.brightsky" + }, (data) => this.#handleResponse(data)); + } + + async #fetchLocation () { + const params = new URLSearchParams({ + latitude: this.config.lat, + longitude: this.config.lon, + localityLanguage: this.config.lang || "en" + }); + + try { + const response = await fetch(`${GEOCODE_BASE}?${params}`, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + if (data?.city) { + this.locationName = data.principalSubdivisionCode ? `${data.city}, ${data.principalSubdivisionCode}` : data.city; + } + } catch (error) { + Log.debug("[brightsky] Could not load location data:", error.message); + } + } + + /** + * Builds the request URL. Called before every fetch, so the requested date + * range always starts today, even after midnight. + * @returns {string} The URL for the configured weather type + */ + #getUrl () { + const params = new URLSearchParams({ lat: this.config.lat, lon: this.config.lon }); + + if (this.config.type === "current") { + return `${this.config.apiBase}/current_weather?${params}`; + } + + const days = this.config.type === "hourly" + ? Math.ceil(Math.min(this.config.maxEntries, 48) / 24) + 1 + : this.config.maxNumberOfDays + 1; + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + days); + + params.set("date", start.toISOString()); + params.set("last_date", end.toISOString()); + return `${this.config.apiBase}/weather?${params}`; + } + + async #handleResponse (data) { + try { + let weatherData; + switch (this.config.type) { + case "current": + if (!data?.weather) throw new Error("Invalid API response"); + weatherData = await this.#generateCurrentWeather(data.weather); + break; + case "forecast": + case "daily": + if (!Array.isArray(data?.weather)) throw new Error("Invalid API response"); + weatherData = this.#generateDailyForecast(data.weather); + break; + case "hourly": + if (!Array.isArray(data?.weather)) throw new Error("Invalid API response"); + weatherData = this.#generateHourlyForecast(data.weather); + break; + default: + throw new Error(`Unknown weather type: ${this.config.type}`); + } + + this.onDataCallback?.(weatherData); + } catch (error) { + Log.error("[brightsky] Error processing weather data:", error); + this.#sendError(error.message); + } + } + + #sendError (message) { + this.onErrorCallback?.({ message, translationKey: ERROR_TRANSLATION_KEY }); + } + + async #generateCurrentWeather (record) { + const date = new Date(record.timestamp); + const { sunrise, sunset } = getSunTimes(date, this.config.lat, this.config.lon); + const precipitation = record.precipitation_60 ?? record.precipitation_10; + + return { + date, + sunrise, + sunset, + temperature: record.temperature, + humidity: record.relative_humidity, + windSpeed: this.#toMs(record.wind_speed_60 ?? record.wind_speed_10), + windFromDirection: record.wind_direction_60 ?? record.wind_direction_10, + weatherType: this.#convertWeatherType(record.icon, isDayTime(date, sunrise, sunset)), + precipitationAmount: precipitation, + precipitationUnits: precipitation != null ? "mm" : null, + sunshineHours: this.config.showSunshineHours ? await this.#fetchSunshineToday() : null + }; + } + + /** + * Sums up the sunshine of today's records (observations and forecast). + * @returns {Promise} Sunshine hours of today, null if unavailable + */ + async #fetchSunshineToday () { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const params = new URLSearchParams({ + lat: this.config.lat, + lon: this.config.lon, + date: start.toISOString(), + last_date: end.toISOString() + }); + + try { + const response = await fetch(`${this.config.apiBase}/weather?${params}`, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const today = getDateString(start); + const records = (data?.weather ?? []).filter((record) => this.#dayOf(record) === today); + const minutes = this.#sum(records, "sunshine"); + return minutes === null ? null : minutes / 60; + } catch (error) { + Log.debug("[brightsky] Could not load sunshine data:", error.message); + return null; + } + } + + /** + * Returns the local calendar day a record belongs to. Records describe the + * previous hour, so a record at 00:00 still belongs to the previous day. + * @param {object} record A Bright Sky weather record + * @returns {string} Date string in YYYY-MM-DD format + */ + #dayOf (record) { + return getDateString(new Date(new Date(record.timestamp).getTime() - 60 * 60 * 1000)); + } + + #generateHourlyForecast (records) { + const now = new Date(); + + return records + .map((record) => ({ record, date: new Date(record.timestamp) })) + .filter(({ date }) => date > now) + .map(({ record, date }) => { + const { sunrise, sunset } = getSunTimes(date, this.config.lat, this.config.lon); + return { + date, + sunrise, + sunset, + temperature: record.temperature, + humidity: record.relative_humidity, + windSpeed: this.#toMs(record.wind_speed), + windFromDirection: record.wind_direction, + weatherType: this.#convertWeatherType(record.icon, isDayTime(date, sunrise, sunset)), + precipitationAmount: record.precipitation, + precipitationUnits: record.precipitation != null ? "mm" : null, + precipitationProbability: record.precipitation_probability + }; + }); + } + + /** + * Aggregates the hourly records into calendar days (local time). Records + * describe the previous hour, so a record at 00:00 still belongs to the + * previous day. Days are kept complete, including past hours of today. + * @param {object[]} records Hourly Bright Sky weather records + * @returns {object[]} One weather object per day, starting today + */ + #generateDailyForecast (records) { + const today = getDateString(new Date()); + const days = new Map(); + + for (const record of records) { + const key = this.#dayOf(record); + if (key < today) continue; + + if (!days.has(key)) days.set(key, []); + days.get(key).push(record); + } + + return [...days.entries()] + .slice(0, this.config.maxNumberOfDays) + .map(([key, dayRecords]) => this.#aggregateDay(key, dayRecords)) + .filter((day) => day !== null); + } + + #aggregateDay (key, records) { + const temperatures = records.map((record) => record.temperature).filter(Number.isFinite); + if (temperatures.length === 0) return null; + + const [year, month, day] = key.split("-").map(Number); + const date = new Date(year, month - 1, day); + const { sunrise, sunset } = getSunTimes(new Date(year, month - 1, day, 12), this.config.lat, this.config.lon); + const daytimeRecords = records.filter((record) => isDayTime(new Date(record.timestamp), sunrise, sunset)); + const precipitation = this.#sum(records, "precipitation"); + const sunshineMinutes = this.#sum(records, "sunshine"); + const probabilities = records.map((record) => record.precipitation_probability).filter(Number.isFinite); + const windSpeeds = records.map((record) => record.wind_speed).filter(Number.isFinite); + + return { + date, + sunrise, + sunset, + minTemperature: Math.min(...temperatures), + maxTemperature: Math.max(...temperatures), + temperature: (Math.min(...temperatures) + Math.max(...temperatures)) / 2, + weatherType: this.#convertWeatherType(this.#mostFrequentIcon(daytimeRecords.length > 0 ? daytimeRecords : records), true), + windSpeed: windSpeeds.length > 0 ? this.#toMs(Math.max(...windSpeeds)) : null, + precipitationAmount: precipitation, + precipitationUnits: precipitation != null ? "mm" : null, + precipitationProbability: probabilities.length > 0 ? Math.max(...probabilities) : null, + sunshineHours: sunshineMinutes === null ? null : sunshineMinutes / 60 + }; + } + + #sum (records, field) { + const values = records.map((record) => record[field]).filter(Number.isFinite); + return values.length > 0 ? values.reduce((total, value) => total + value, 0) : null; + } + + #mostFrequentIcon (records) { + const counts = new Map(); + for (const { icon } of records) { + if (icon) counts.set(icon, (counts.get(icon) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; + } + + #toMs (kmh) { + return Number.isFinite(kmh) ? convertKmhToMs(kmh) : null; + } + + #convertWeatherType (icon, dayTime) { + const mapping = ICON_MAP[icon]; + if (!mapping) return null; + return dayTime ? mapping[0] : mapping[1]; + } +} + +module.exports = BrightSkyProvider; diff --git a/js/releasenotes.js b/js/releasenotes.js index ea10f11597..174f8d44a7 100644 --- a/js/releasenotes.js +++ b/js/releasenotes.js @@ -52,7 +52,7 @@ const createReleaseNotes = async () => { const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node; // Search strings - const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "fmi", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; + const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "brightsky", "envcanada", "fmi", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; // Map search strings to categories const getFirstLabel = (text) => { @@ -73,6 +73,7 @@ const createReleaseNotes = async () => { case "deps": res = "dependencies"; break; + case "brightsky": case "envcanada": case "fmi": case "openmeteo": diff --git a/tests/unit/modules/default/weather/providers/brightsky_spec.js b/tests/unit/modules/default/weather/providers/brightsky_spec.js new file mode 100644 index 0000000000..9c70ee5d51 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/brightsky_spec.js @@ -0,0 +1,274 @@ +/** + * Bright Sky Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Bright Sky serves DWD data, no API key required. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const BRIGHTSKY_CURRENT_URL = "https://api.brightsky.dev/current_weather"; +const BRIGHTSKY_WEATHER_URL = "https://api.brightsky.dev/weather"; +const GEOCODE_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client"; + +/** + * Formats a local date as an ISO timestamp with offset, like Bright Sky does. + * @param {Date} date The date to format + * @returns {string} ISO 8601 timestamp with UTC offset + */ +const toIsoWithOffset = (date) => { + const pad = (value) => String(Math.abs(value)).padStart(2, "0"); + const offset = -date.getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00:00${sign}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`; +}; + +/** + * Builds hourly Bright Sky records for consecutive hours. + * @param {Date} start Timestamp of the first record + * @param {number} hours Number of records + * @param {(index: number) => object} [overrides] Per-record field overrides + * @returns {object[]} Weather records + */ +const buildRecords = (start, hours, overrides = () => ({})) => Array.from({ length: hours }, (_, index) => { + const date = new Date(start); + date.setHours(start.getHours() + index); + return { + timestamp: toIsoWithOffset(date), + temperature: 10 + index * 0.1, + relative_humidity: 70, + precipitation: 0.2, + precipitation_probability: 10, + wind_speed: 18, + wind_direction: 200, + icon: "cloudy", + ...overrides(index) + }; +}); + +/** + * Starts a provider and resolves with the first data delivered. + * @param {object} provider The provider instance + * @returns {Promise} The weather data + */ +const fetchOnce = async (provider) => { + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + await provider.initialize(); + provider.start(); + return dataPromise; +}; + +let server; +let requestedUrls; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + vi.useRealTimers(); +}); + +describe("BrightSkyProvider", () => { + let BrightSkyProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/brightsky"); + BrightSkyProvider = module.default; + }); + + /** + * Registers Bright Sky and geocoding mock handlers. + * @param {object} weatherResponse Body for /weather + * @param {object} [currentResponse] Body for /current_weather + */ + const mockApi = (weatherResponse, currentResponse = {}) => { + requestedUrls = []; + server.use( + http.get(GEOCODE_URL, () => HttpResponse.json({ city: "Berlin", principalSubdivisionCode: "DE-BE" })), + http.get(BRIGHTSKY_WEATHER_URL, ({ request }) => { + requestedUrls.push(new URL(request.url)); + return HttpResponse.json(weatherResponse); + }), + http.get(BRIGHTSKY_CURRENT_URL, ({ request }) => { + requestedUrls.push(new URL(request.url)); + return HttpResponse.json(currentResponse); + }) + ); + }; + + describe("Constructor & Configuration", () => { + it("should apply defaults and merge params", () => { + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "hourly" }); + expect(provider.config.apiBase).toBe("https://api.brightsky.dev"); + expect(provider.config.lat).toBe(48.83); + expect(provider.config.type).toBe("hourly"); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + + it("should call the error callback when coordinates are missing", async () => { + const provider = new BrightSkyProvider({ lat: null, lon: null }); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + await provider.initialize(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ translationKey: "MODULE_ERROR_UNSPECIFIED" })); + expect(provider.fetcher).toBeNull(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather and set the location name", async () => { + mockApi({}, { + weather: { + timestamp: toIsoWithOffset(new Date()), + temperature: 15.1, + relative_humidity: 83, + precipitation_10: 0, + precipitation_60: 0.4, + wind_speed_10: 3.2, + wind_speed_60: 7.2, + wind_direction_10: 50, + wind_direction_60: 10, + icon: "rain" + } + }); + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "current" }); + const result = await fetchOnce(provider); + + expect(provider.locationName).toBe("Berlin, DE-BE"); + expect(requestedUrls[0].pathname).toBe("/current_weather"); + expect(result.temperature).toBe(15.1); + expect(result.humidity).toBe(83); + expect(result.windSpeed).toBeCloseTo(2, 5); + expect(result.windFromDirection).toBe(10); + expect(result.precipitationAmount).toBe(0.4); + expect(result.precipitationUnits).toBe("mm"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(["day-rain", "night-alt-rain"]).toContain(result.weatherType); + expect(result.sunshineHours).toBeNull(); + }); + + it("should sum up today's sunshine when showSunshineHours is enabled", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 3, 14)); + mockApi( + { + weather: [ + ...buildRecords(new Date(2026, 9, 3, 0), 1, () => ({ sunshine: 60 })), + ...buildRecords(new Date(2026, 9, 3, 10), 3, () => ({ sunshine: 40 })), + ...buildRecords(new Date(2026, 9, 4, 0), 1, () => ({ sunshine: 30 })) + ] + }, + { weather: { timestamp: toIsoWithOffset(new Date(2026, 9, 3, 14)), temperature: 18, icon: "clear-day" } } + ); + + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "current", showSunshineHours: true }); + const result = await fetchOnce(provider); + + expect(result.sunshineHours).toBeCloseTo(2.5, 5); + }); + }); + + describe("Hourly Parsing", () => { + it("should skip past hours and convert units", async () => { + const start = new Date(); + start.setMinutes(0, 0, 0); + start.setHours(start.getHours() - 2); + mockApi({ weather: buildRecords(start, 6) }); + + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "hourly", maxEntries: 12 }); + const result = await fetchOnce(provider); + + expect(result).toHaveLength(3); + expect(result.every((hour) => hour.date > new Date())).toBe(true); + expect(result[0].windSpeed).toBeCloseTo(5, 5); + expect(result[0].precipitationProbability).toBe(10); + expect(result[0].precipitationUnits).toBe("mm"); + }); + + it("should request today's records from local midnight", async () => { + mockApi({ weather: [] }); + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "hourly", maxEntries: 24 }); + await fetchOnce(provider); + + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + expect(new Date(requestedUrls[0].searchParams.get("date")).getTime()).toBe(midnight.getTime()); + expect(new Date(requestedUrls[0].searchParams.get("last_date")).getTime()).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000); + }); + }); + + describe("Daily Forecast Parsing", () => { + it("should aggregate complete days and keep today even late in the evening", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const now = new Date(2026, 9, 3, 23, 30); + vi.setSystemTime(now); + const midnight = new Date(2026, 9, 3, 1); + mockApi({ + weather: buildRecords(midnight, 48, (index) => ({ + temperature: index < 24 ? 10 + index : 30 + index, + precipitation: index < 24 ? 0.5 : 0, + sunshine: index < 24 ? 0 : 30, + icon: index < 24 ? "rain" : "clear-day" + })) + }); + + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "forecast", maxNumberOfDays: 2 }); + const result = await fetchOnce(provider); + + expect(result).toHaveLength(2); + expect(result[0].date.getDate()).toBe(3); + expect(result[0].minTemperature).toBe(10); + expect(result[0].maxTemperature).toBe(33); + expect(result[0].precipitationAmount).toBeCloseTo(12, 5); + expect(result[0].weatherType).toBe("day-rain"); + expect(result[1].date.getDate()).toBe(4); + expect(result[1].weatherType).toBe("day-sunny"); + expect(result[0].sunshineHours).toBe(0); + expect(result[1].sunshineHours).toBe(12); + }); + + it("should assign the midnight record to the previous day", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 3, 8)); + mockApi({ + weather: [ + ...buildRecords(new Date(2026, 9, 3, 23), 1, () => ({ precipitation: 1 })), + ...buildRecords(new Date(2026, 9, 4, 0), 1, () => ({ precipitation: 2 })), + ...buildRecords(new Date(2026, 9, 4, 1), 1, () => ({ precipitation: 4 })) + ] + }); + + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "forecast", maxNumberOfDays: 2 }); + const result = await fetchOnce(provider); + + expect(result.map((day) => day.precipitationAmount)).toEqual([3, 4]); + }); + }); + + describe("Error Handling", () => { + it("should call the error callback on an invalid API response", async () => { + mockApi({ unexpected: true }); + const provider = new BrightSkyProvider({ lat: 48.83, lon: 9.07, type: "forecast" }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + }); +});