feat(weather): add periodic live updates (#4155)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod/v4";
|
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";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
@@ -9,45 +11,19 @@ const atLocationInput = z.object({
|
|||||||
latitude: z.number(),
|
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({
|
export const weatherRouter = createTRPCRouter({
|
||||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||||
const res = await fetchWithTimeout(
|
const handler = weatherRequestHandler.handler(input);
|
||||||
`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`,
|
return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
|
||||||
);
|
}),
|
||||||
const json: unknown = await res.json();
|
subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
|
||||||
const weather = await atLocationOutput.parseAsync(json);
|
return observable<Weather>((emit) => {
|
||||||
return {
|
const handler = weatherRequestHandler.handler(input);
|
||||||
current: weather.current_weather,
|
const unsubscribe = handler.subscribe((data) => {
|
||||||
daily: weather.daily.time.map((value, index) => {
|
emit.next(data);
|
||||||
return {
|
});
|
||||||
time: value,
|
|
||||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
return unsubscribe;
|
||||||
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],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
|||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||||
import { updateCheckerJob } from "./jobs/update-checker";
|
import { updateCheckerJob } from "./jobs/update-checker";
|
||||||
|
import { weatherJob } from "./jobs/weather";
|
||||||
import { createCronJobGroup } from "./lib";
|
import { createCronJobGroup } from "./lib";
|
||||||
|
|
||||||
export const jobGroup = createCronJobGroup({
|
export const jobGroup = createCronJobGroup({
|
||||||
@@ -48,6 +49,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
firewallVersion: firewallVersionJob,
|
firewallVersion: firewallVersionJob,
|
||||||
firewallInterfaces: firewallInterfacesJob,
|
firewallInterfaces: firewallInterfacesJob,
|
||||||
refreshNotifications: refreshNotificationsJob,
|
refreshNotifications: refreshNotificationsJob,
|
||||||
|
weather: weatherJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||||
|
|||||||
33
packages/cron-jobs/src/jobs/weather.ts
Normal file
33
packages/cron-jobs/src/jobs/weather.ts
Normal file
@@ -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<WidgetComponentProps<"weather">["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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"octokit": "^5.0.3",
|
"octokit": "^5.0.3",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"undici": "7.16.0"
|
"undici": "7.16.0",
|
||||||
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
70
packages/request-handler/src/weather.ts
Normal file
70
packages/request-handler/src/weather.ts
Normal file
@@ -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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
@@ -3318,6 +3318,9 @@
|
|||||||
},
|
},
|
||||||
"firewallInterfaces": {
|
"firewallInterfaces": {
|
||||||
"label": "Firewall Interfaces"
|
"label": "Firewall Interfaces"
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"label": "Weather"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -13,17 +13,20 @@ import type { WidgetComponentProps } from "../definition";
|
|||||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||||
|
|
||||||
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
|
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
|
||||||
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
const input = {
|
||||||
{
|
latitude: options.location.latitude,
|
||||||
latitude: options.location.latitude,
|
longitude: options.location.longitude,
|
||||||
longitude: options.location.longitude,
|
};
|
||||||
},
|
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(input, {
|
||||||
{
|
refetchOnMount: false,
|
||||||
refetchOnMount: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnReconnect: false,
|
});
|
||||||
},
|
|
||||||
);
|
const utils = clientApi.useUtils();
|
||||||
|
clientApi.widget.weather.subscribeAtLocation.useSubscription(input, {
|
||||||
|
onData: (data) => utils.widget.weather.atLocation.setData(input, data),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1904,6 +1904,9 @@ importers:
|
|||||||
undici:
|
undici:
|
||||||
specifier: 7.16.0
|
specifier: 7.16.0
|
||||||
version: 7.16.0
|
version: 7.16.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.1.11
|
||||||
|
version: 4.1.11
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user