diff --git a/AGENTS.md b/AGENTS.md index 0b8d7ef..fb2ae49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,4 @@ 2. Host npm (10.9.4) has an arborist crash bug ("edgesOut") — if plain `npm` misbehaves on installs, use `npx npm@latest ...`. The lockfile must stay in sync with package.json: the Docker build runs `npm ci`. - MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages. +- Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a88a875..1ba1309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.12.0 — 2026-09-25 07:42 + +- New `src/weather` module: hourly cron-triggered weather report for Lublin, broadcast on the mesh (pinned send channel). `WeatherService.fetchWeather()` GETs Open-Meteo current conditions (`temperature_2m`, `apparent_temperature`, `relative_humidity_2m`, `wind_speed_10m`, `weather_code` — free API, no key) via global fetch; `formatReport()` renders one short line (e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`) with a WMO weather-code → description map (~19 common codes, unknown → `code `). The `@Cron` handler fetches, formats and broadcasts via `MeshService.sendText(BROADCAST_ADDR, report)`; any error logs `[weather] report failed: ...` and is swallowed — the cron never crashes the app. +- Env config with defaults (read at construction): `WEATHER_CRON` (`'0 * * * *'`), `WEATHER_LAT` (51.2465), `WEATHER_LON` (22.5684), `WEATHER_CITY` ("Lublin"). +- New dependency: `@nestjs/schedule` (ScheduleModule.forRoot() in WeatherModule, registered in AppModule). No new REST endpoints — scheduled job only; send path, persistence, redaction, MCP and webhook logic untouched. +- Tests: four new specs in `src/weather/weather.service.spec.ts` — exact formatted report from a sample payload; unknown WMO code → `code ` fallback; cron success path (stubbed fetch + MeshService mock) → `sendText(BROADCAST_ADDR, report)` called once with the Open-Meteo URL asserted; fetch failure → no send, no throw. All 54 pre-existing specs stay green (58 total). +- Docs: README "Automated weather reports" section, AGENTS.md bullet in "Other". + ## v0.11.3 — 2026-09-25 07:28 - Added MESHTASTIC.md communication-rules file; referenced from AGENTS.md. diff --git a/README.md b/README.md index ee69a93..c8de671 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ curl -X POST http://localhost:3000/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` +## Automated weather reports + +Hourly cron job (`src/weather`): fetches current conditions for Lublin from [Open-Meteo](https://open-meteo.com/) (free, no API key) and broadcasts a one-line report on the pinned send channel (index 3, `test`) — e.g. `Lublin weather: 18°C (feels 17), partly cloudy, humidity 65%, wind 12 km/h`. A failed fetch or send only logs a warn; it never crashes the app. + +- `WEATHER_CRON` (default `'0 * * * *'`) — cron expression for the report schedule +- `WEATHER_LAT` (default `51.2465`) / `WEATHER_LON` (default `22.5684`) — coordinates queried from Open-Meteo +- `WEATHER_CITY` (default `"Lublin"`) — city name in the report line + ## Env vars - `MESH_HOST` (default `192.168.1.4`) — bridge host diff --git a/package-lock.json b/package-lock.json index 6c23afa..037a44f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mesh-bot", - "version": "0.5.0", + "version": "0.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mesh-bot", - "version": "0.5.0", + "version": "0.11.3", "license": "UNLICENSED", "dependencies": { "@bufbuild/protobuf": "^2.15.0", @@ -15,6 +15,7 @@ "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^12.0.2", "@scalar/nestjs-api-reference": "^1.2.21", "reflect-metadata": "^0.2.2", @@ -976,6 +977,22 @@ "@nestjs/core": "^12.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz", + "integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0 || ^12.0.0", + "@nestjs/core": "^11.0.0 || ^12.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.0.tgz", @@ -1975,6 +1992,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", + "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2815,6 +2838,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4218,6 +4258,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", diff --git a/package.json b/package.json index a5c6c52..8271564 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.11.3", + "version": "0.12.0", "description": "", "author": "", "private": true, @@ -27,6 +27,7 @@ "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^12.0.2", "@scalar/nestjs-api-reference": "^1.2.21", "reflect-metadata": "^0.2.2", diff --git a/src/app.module.ts b/src/app.module.ts index e4c483b..063d97b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/weather/weather.module.ts b/src/weather/weather.module.ts new file mode 100644 index 0000000..150c080 --- /dev/null +++ b/src/weather/weather.module.ts @@ -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 {} diff --git a/src/weather/weather.service.spec.ts b/src/weather/weather.service.spec.ts new file mode 100644 index 0000000..2fd26bb --- /dev/null +++ b/src/weather/weather.service.spec.ts @@ -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 ` 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(); + } + }); +}); diff --git a/src/weather/weather.service.ts b/src/weather/weather.service.ts new file mode 100644 index 0000000..dea85dc --- /dev/null +++ b/src/weather/weather.service.ts @@ -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 ` +const WMO_CODES: Record = { + 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 { + 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 { + 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}`); + } + } +}