diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts index 35a1b1b63..bb1077569 100644 --- a/packages/api/src/router/widgets/weather.ts +++ b/packages/api/src/router/widgets/weather.ts @@ -1,6 +1,8 @@ +import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; -import { fetchWithTimeout } from "@homarr/common"; +import type { Weather } from "@homarr/request-handler/weather"; +import { weatherRequestHandler } from "@homarr/request-handler/weather"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -9,45 +11,19 @@ const atLocationInput = z.object({ latitude: z.number(), }); -const atLocationOutput = z.object({ - current_weather: z.object({ - weathercode: z.number(), - temperature: z.number(), - windspeed: z.number(), - }), - daily: z.object({ - time: z.array(z.string()), - weathercode: z.array(z.number()), - temperature_2m_max: z.array(z.number()), - temperature_2m_min: z.array(z.number()), - sunrise: z.array(z.string()), - sunset: z.array(z.string()), - wind_speed_10m_max: z.array(z.number()), - wind_gusts_10m_max: z.array(z.number()), - }), -}); - export const weatherRouter = createTRPCRouter({ atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => { - const res = await fetchWithTimeout( - `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`, - ); - const json: unknown = await res.json(); - const weather = await atLocationOutput.parseAsync(json); - return { - current: weather.current_weather, - daily: weather.daily.time.map((value, index) => { - return { - time: value, - weatherCode: weather.daily.weathercode[index] ?? 404, - maxTemp: weather.daily.temperature_2m_max[index], - minTemp: weather.daily.temperature_2m_min[index], - sunrise: weather.daily.sunrise[index], - sunset: weather.daily.sunset[index], - maxWindSpeed: weather.daily.wind_speed_10m_max[index], - maxWindGusts: weather.daily.wind_gusts_10m_max[index], - }; - }), - }; + const handler = weatherRequestHandler.handler(input); + return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data); + }), + subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => { + return observable((emit) => { + const handler = weatherRequestHandler.handler(input); + const unsubscribe = handler.subscribe((data) => { + emit.next(data); + }); + + return unsubscribe; + }); }), }); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index c087637e4..7d1bc2023 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -22,6 +22,7 @@ import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import { rssFeedsJob } from "./jobs/rss-feeds"; import { updateCheckerJob } from "./jobs/update-checker"; +import { weatherJob } from "./jobs/weather"; import { createCronJobGroup } from "./lib"; export const jobGroup = createCronJobGroup({ @@ -48,6 +49,7 @@ export const jobGroup = createCronJobGroup({ firewallVersion: firewallVersionJob, firewallInterfaces: firewallInterfacesJob, refreshNotifications: refreshNotificationsJob, + weather: weatherJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/weather.ts b/packages/cron-jobs/src/jobs/weather.ts new file mode 100644 index 000000000..20f96728a --- /dev/null +++ b/packages/cron-jobs/src/jobs/weather.ts @@ -0,0 +1,33 @@ +import SuperJSON from "superjson"; + +import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema"; +import { logger } from "@homarr/log"; +import { weatherRequestHandler } from "@homarr/request-handler/weather"; + +import type { WidgetComponentProps } from "../../../widgets"; +import { createCronJob } from "../lib"; + +export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallback(async () => { + const weatherItems = await db.query.items.findMany({ + where: eq(items.kind, "weather"), + }); + + const parsedItems = weatherItems.map((item) => ({ + id: item.id, + options: SuperJSON.parse["options"]>(item.options), + })); + + for (const item of parsedItems) { + try { + const innerHandler = weatherRequestHandler.handler({ + longitude: item.options.location.longitude, + latitude: item.options.location.latitude, + }); + await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + } catch (error) { + logger.error("Failed to update weather", { id: item.id, error }); + } + } +}); diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json index b4d91f02e..3274d1f43 100644 --- a/packages/request-handler/package.json +++ b/packages/request-handler/package.json @@ -32,7 +32,8 @@ "dayjs": "^1.11.18", "octokit": "^5.0.3", "superjson": "2.2.2", - "undici": "7.16.0" + "undici": "7.16.0", + "zod": "^4.1.11" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts new file mode 100644 index 000000000..faa97846c --- /dev/null +++ b/packages/request-handler/src/weather.ts @@ -0,0 +1,70 @@ +import dayjs from "dayjs"; +import { z } from "zod"; + +import { fetchWithTimeout } from "@homarr/common"; + +import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; + +export const weatherRequestHandler = createCachedWidgetRequestHandler({ + queryKey: "weatherAtLocation", + widgetKind: "weather", + async requestAsync(input: { latitude: number; longitude: number }) { + const res = await fetchWithTimeout( + `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`, + ); + const json: unknown = await res.json(); + const weather = await atLocationOutput.parseAsync(json); + return { + current: weather.current_weather, + daily: weather.daily.time.map((value, index) => { + return { + time: value, + weatherCode: weather.daily.weathercode[index] ?? 404, + maxTemp: weather.daily.temperature_2m_max[index], + minTemp: weather.daily.temperature_2m_min[index], + sunrise: weather.daily.sunrise[index], + sunset: weather.daily.sunset[index], + maxWindSpeed: weather.daily.wind_speed_10m_max[index], + maxWindGusts: weather.daily.wind_gusts_10m_max[index], + }; + }), + } satisfies Weather; + }, + cacheDuration: dayjs.duration(1, "minute"), +}); + +const atLocationOutput = z.object({ + current_weather: z.object({ + weathercode: z.number(), + temperature: z.number(), + windspeed: z.number(), + }), + daily: z.object({ + time: z.array(z.string()), + weathercode: z.array(z.number()), + temperature_2m_max: z.array(z.number()), + temperature_2m_min: z.array(z.number()), + sunrise: z.array(z.string()), + sunset: z.array(z.string()), + wind_speed_10m_max: z.array(z.number()), + wind_gusts_10m_max: z.array(z.number()), + }), +}); + +export interface Weather { + current: { + weathercode: number; + temperature: number; + windspeed: number; + }; + daily: { + time: string; + weatherCode: number; + maxTemp: number | undefined; + minTemp: number | undefined; + sunrise: string | undefined; + sunset: string | undefined; + maxWindSpeed: number | undefined; + maxWindGusts: number | undefined; + }[]; +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 71d2f630a..0fad66d35 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3318,6 +3318,9 @@ }, "firewallInterfaces": { "label": "Firewall Interfaces" + }, + "weather": { + "label": "Weather" } }, "interval": { diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 88df5cce5..300244bd0 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -13,17 +13,20 @@ import type { WidgetComponentProps } from "../definition"; import { WeatherDescription, WeatherIcon } from "./icon"; export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) { - const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery( - { - latitude: options.location.latitude, - longitude: options.location.longitude, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); + const input = { + latitude: options.location.latitude, + longitude: options.location.longitude, + }; + const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(input, { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const utils = clientApi.useUtils(); + clientApi.widget.weather.subscribeAtLocation.useSubscription(input, { + onData: (data) => utils.widget.weather.atLocation.setData(input, data), + }); return (