Hourly cron weather report for Lublin (Open-Meteo) broadcast on mesh (#24)
CI / build + unit (push) Successful in 1m5s
CI / build + unit (push) Successful in 1m5s
This commit was merged in pull request #24.
This commit is contained in:
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshModule } from './mesh/mesh.module.js';
|
||||
import { WeatherModule } from './weather/weather.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [MeshModule],
|
||||
imports: [MeshModule, WeatherModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { MeshModule } from '../mesh/mesh.module.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), MeshModule],
|
||||
providers: [WeatherService],
|
||||
exports: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import { WeatherService } from './weather.service.js';
|
||||
|
||||
const CURRENT = {
|
||||
temperature_2m: 18.4,
|
||||
apparent_temperature: 16.9,
|
||||
relative_humidity_2m: 65.2,
|
||||
wind_speed_10m: 12.3,
|
||||
weather_code: 2,
|
||||
};
|
||||
|
||||
const EXPECTED_REPORT =
|
||||
'Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h';
|
||||
|
||||
describe('WeatherService formatReport', () => {
|
||||
it('formats a one-line report from the current-weather payload', () => {
|
||||
const svc = new WeatherService({} as MeshService);
|
||||
expect(svc.formatReport(CURRENT)).toBe(EXPECTED_REPORT);
|
||||
});
|
||||
|
||||
it('falls back to `code <n>` for unknown WMO codes', () => {
|
||||
const svc = new WeatherService({} as MeshService);
|
||||
expect(svc.formatReport({ ...CURRENT, weather_code: 99 })).toBe(
|
||||
EXPECTED_REPORT.replace('partly cloudy', 'code 99'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeatherService cron report', () => {
|
||||
it('fetches and broadcasts the formatted report on success', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
const fetchMock = vi.fn(async (..._args: unknown[]) => ({
|
||||
ok: true,
|
||||
json: async () => ({ current: CURRENT }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const svc = new WeatherService({ sendText } as unknown as MeshService);
|
||||
await svc.report();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = String(fetchMock.mock.calls[0][0]);
|
||||
expect(url.startsWith('https://api.open-meteo.com/v1/forecast?')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(url).toContain('latitude=51.2465');
|
||||
expect(url).toContain('longitude=22.5684');
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(BROADCAST_ADDR, EXPECTED_REPORT);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send and does not throw when the fetch fails', async () => {
|
||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const svc = new WeatherService({ sendText } as unknown as MeshService);
|
||||
await expect(svc.report()).resolves.toBeUndefined();
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
|
||||
/** Open-Meteo `current` block fields we use (free API, no key). */
|
||||
export interface CurrentWeather {
|
||||
temperature_2m: number;
|
||||
apparent_temperature: number;
|
||||
relative_humidity_2m: number;
|
||||
wind_speed_10m: number;
|
||||
weather_code: number;
|
||||
}
|
||||
|
||||
// WMO weather interpretation codes (the common ones); unknown → `code <n>`
|
||||
const WMO_CODES: Record<number, string> = {
|
||||
0: 'clear',
|
||||
1: 'mainly clear',
|
||||
2: 'partly cloudy',
|
||||
3: 'overcast',
|
||||
45: 'fog',
|
||||
48: 'freezing fog',
|
||||
51: 'light drizzle',
|
||||
53: 'drizzle',
|
||||
55: 'heavy drizzle',
|
||||
61: 'light rain',
|
||||
63: 'rain',
|
||||
65: 'heavy rain',
|
||||
71: 'light snow',
|
||||
73: 'snow',
|
||||
75: 'heavy snow',
|
||||
80: 'light rain showers',
|
||||
81: 'rain showers',
|
||||
82: 'violent rain showers',
|
||||
95: 'thunderstorm',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
private readonly logger = new Logger(WeatherService.name);
|
||||
// read at construction, like MeshService's env config
|
||||
private readonly lat = parseFloat(process.env.WEATHER_LAT ?? '51.2465');
|
||||
private readonly lon = parseFloat(process.env.WEATHER_LON ?? '22.5684');
|
||||
private readonly city = process.env.WEATHER_CITY ?? 'Lublin';
|
||||
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
/** Fetch current conditions from Open-Meteo; throws on HTTP/parse failure. */
|
||||
async fetchWeather(): Promise<CurrentWeather> {
|
||||
const url =
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${this.lat}` +
|
||||
`&longitude=${this.lon}` +
|
||||
`¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`open-meteo HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { current: CurrentWeather };
|
||||
return json.current;
|
||||
}
|
||||
|
||||
/** One short line, e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`. */
|
||||
formatReport(current: CurrentWeather): string {
|
||||
const desc =
|
||||
WMO_CODES[current.weather_code] ?? `code ${current.weather_code}`;
|
||||
return `${this.city} weather: ${Math.round(current.temperature_2m)}°C (feels ${Math.round(
|
||||
current.apparent_temperature,
|
||||
)}), ${desc}, humidity ${Math.round(current.relative_humidity_2m)}%, wind ${Math.round(
|
||||
current.wind_speed_10m,
|
||||
)} km/h`;
|
||||
}
|
||||
|
||||
/** Hourly cron: fetch → format → broadcast on the pinned send channel. Never throws — a failed report must not crash the app. */
|
||||
@Cron(process.env.WEATHER_CRON || '0 * * * *')
|
||||
async report(): Promise<void> {
|
||||
try {
|
||||
const current = await this.fetchWeather();
|
||||
await this.mesh.sendText(BROADCAST_ADDR, this.formatReport(current));
|
||||
} catch (err) {
|
||||
this.logger.warn(`[weather] report failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user