Files
weather-station/server/src/server.mjs
2026-06-25 10:21:55 -04:00

205 lines
8.8 KiB
JavaScript

import express from 'express';
import {resolve, dirname} from 'path';
import {fileURLToPath} from 'url';
import {cfg, localDateStr} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
import {getSpaceWeather} from './space.mjs';
import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
import {existsSync} from 'fs';
import {getAIS} from './ais.mjs';
import {getADSB, getAirTrafficHistory, getShapes, initAircraftDb} from './adsb.mjs';
import {getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
// import {Aurora} from './aurora.mjs';
// ── Uncaught error handlers ───────────────────────────────────────────────────
process.on('unhandledRejection', (reason, promise) => {
console.error('[unhandledRejection]', promise, '\nReason:', reason)
})
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err)
})
const app = express();
const DIR = dirname(fileURLToPath(import.meta.url));
const CLIENT_DIST = resolve(DIR, '../public');
app.use(express.json());
app.use('/icons', express.static(resolve(DIR, '../public', 'icons')));
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
});
app.use(express.static(CLIENT_DIST));
function filterFields(obj, fields) {
if(!fields) return obj;
const keys = new Set(fields.split(',').map(f => f.trim()));
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)));
}
function filterArr(arr, fields) {
if(!fields) return arr;
return arr.map(row => filterFields(row, fields));
}
// ── Current ───────────────────────────────────────────────────────────────────
app.get('/api/current', async (req, res) => {
const { fields } = req.query
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
const space = getCelestialCurrent(coords.latitude, coords.longitude)
const condition = getWeatherCondition(Object.assign(sensors, space))
const forecast = await getForecast();
const merged = {
...(forecast?.summary ? {
precipitation_chance: forecast.summary.precipitation_chance,
temperature_max: forecast.summary.temperature_max,
temperature_min: forecast.summary.temperature_min,
uv_index_max: forecast.summary.uv_index,
} : {}),
...condition,
...sensors,
...space,
}
res.json(filterFields(merged, fields))
})
// ── Hourly ────────────────────────────────────────────────────────────────────
app.get('/api/hourly', async (req, res) => {
const { fields } = req.query
const now = new Date()
const plus24 = new Date(now.getTime() + 24 * 3600 * 1000)
const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0, 0, 0, 0))
const end = req.query.end ? new Date(req.query.end) : plus24
const coords = await getCoords()
const historyEnd = new Date(Math.min(now, end))
historyEnd.setMinutes(0, 0, 0)
const [historyResult] = await Promise.allSettled([
start < now ? queryHourly(start, historyEnd) : Promise.resolve([])
])
const history = historyResult.status === 'fulfilled' ? historyResult.value : []
const physics = end > now
? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) })
: []
let meteo = []
if (end > plus24) {
const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
}
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...physics, ...meteo])
res.json(filterArr(hourly, fields))
})
// ── Daily ─────────────────────────────────────────────────────────────────────
app.get('/api/daily', async (req, res) => {
const { fields } = req.query
const now = new Date()
const todayStart = new Date(now)
todayStart.setHours(0, 0, 0, 0)
const next = new Date(todayStart)
next.setDate(todayStart.getDate() + 1)
const start = req.query.start ? new Date(req.query.start) : todayStart
const end = req.query.end ? new Date(req.query.end) : next
const coords = await getCoords()
const todayStr = localDateStr(now)
const usePhysics = lastForecast.summary && start <= now && end > now
const [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
const todaySlot = usePhysics ? [lastForecast.summary] : []
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
res.json(filterArr(daily, fields))
})
// ── Position ──────────────────────────────────────────────────────────────────
app.get('/api/position', async (req, res) => {
const {fields} = req.query;
const data = await getCoords();
res.json(filterFields(data, fields));
});
// ── Space ─────────────────────────────────────────────────────────────────────
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
app.get('/api/space', async (req, res) => {
const {fields} = req.query;
res.json(filterFields(await getSpaceWeather(), fields));
});
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
app.get('/api/adsb/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)));
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
// ── DOCS ──────────────────────────────────────────────────────────────────────
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
app.get('/openapi.json', (req, res) => res.json(spec));
app.get('/openapi.yaml', async (req, res) => {
const {stringify} = await import('yaml');
res.setHeader('Content-Type', 'text/yaml');
res.send(stringify(spec));
});
// ── Website ───────────────────────────────────────────────────────────────────
app.get('*', (req, res) => {
const index = resolve(CLIENT_DIST, 'index.html');
if(existsSync(index)) res.sendFile(index);
else res.status(404).send('Client not built yet — run npm run build in /client');
});
// ── Errors ────────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` })
})
app.use((err, req, res, next) => {
console.error(`[ERROR] ${req.method} ${req.path}\n${err.stack}`)
res.status(500).json({ error: err.message, stack: err.stack })
})
// ── Start ─────────────────────────────────────────────────────────────────────
(async () => {
const c = cfg();
setTimeout(getForecast, 1)
setInterval(getForecast, forecastTTL)
await initAircraftDb();
app.listen(c.PORT, () => {
console.log(`🌦 Weather API — http://localhost:${c.PORT}`)
});
})()