Compare commits

..
1 Commits
Author SHA1 Message Date
kamilkrzywda 261f7ed18c Hourly cron weather report for Lublin (Open-Meteo) broadcast on mesh (#24)
CI / build + unit (push) Successful in 1m5s
2026-09-25 09:48:03 +02:00
9 changed files with 237 additions and 4 deletions
+1
View File
@@ -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`. 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. - 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`.
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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 <n>`). 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 <n>` 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 ## v0.11.3 — 2026-09-25 07:28
- Added MESHTASTIC.md communication-rules file; referenced from AGENTS.md. - Added MESHTASTIC.md communication-rules file; referenced from AGENTS.md.
+8
View File
@@ -89,6 +89,14 @@ curl -X POST http://localhost:3000/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -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 ## Env vars
- `MESH_HOST` (default `192.168.1.4`) — bridge host - `MESH_HOST` (default `192.168.1.4`) — bridge host
+51 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.5.0", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.5.0", "version": "0.11.3",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.15.0", "@bufbuild/protobuf": "^2.15.0",
@@ -15,6 +15,7 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1", "@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/swagger": "^12.0.2", "@nestjs/swagger": "^12.0.2",
"@scalar/nestjs-api-reference": "^1.2.21", "@scalar/nestjs-api-reference": "^1.2.21",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -976,6 +977,22 @@
"@nestjs/core": "^12.0.0" "@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": { "node_modules/@nestjs/schematics": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.0.tgz",
@@ -1975,6 +1992,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -2815,6 +2838,23 @@
"url": "https://opencollective.com/express" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4218,6 +4258,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/magic-string": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.11.3", "version": "0.12.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -27,6 +27,7 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1", "@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/swagger": "^12.0.2", "@nestjs/swagger": "^12.0.2",
"@scalar/nestjs-api-reference": "^1.2.21", "@scalar/nestjs-api-reference": "^1.2.21",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
+2 -1
View File
@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MeshModule } from './mesh/mesh.module.js'; import { MeshModule } from './mesh/mesh.module.js';
import { WeatherModule } from './weather/weather.module.js';
@Module({ @Module({
imports: [MeshModule], imports: [MeshModule, WeatherModule],
}) })
export class AppModule {} export class AppModule {}
+11
View File
@@ -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 {}
+73
View File
@@ -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();
}
});
});
+81
View File
@@ -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}` +
`&current=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}`);
}
}
}