refactor part 1

This commit is contained in:
2026-06-23 22:17:01 -04:00
parent 214cf50908
commit d5bb7ff24f
18 changed files with 508 additions and 398 deletions

View File

@@ -190,24 +190,39 @@ export function getCelestialCurrent(lat, lon, date = new Date()) {
}
}
export function getCelestialHourly(lat, lon, hours) {
return hours.map(({ time }) => {
const d = new Date(time)
const pos = sunPosition(lat, lon, d)
export function getCelestialHourly(lat, lon, startISO, endISO) {
const start = new Date(startISO)
const end = new Date(endISO)
const results = []
for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 3600000)) {
const pos = sunPosition(lat, lon, d)
const phase = moonPhase(d)
return { time, ...pos, moon_illumination_pct: phase.moon_illumination_pct, moon_phase: phase.moon_phase }
})
results.push({
time: d.toISOString(),
...pos,
moon_illumination_pct: phase.moon_illumination_pct,
moon_phase: phase.moon_phase
})
}
return results
}
export function getCelestialDaily(lat, lon, days) {
return days.map(({ time }) => {
const d = new Date(time)
return {
time,
export function getCelestialDaily(lat, lon, startISO, endISO) {
const start = new Date(startISO)
const end = new Date(endISO)
const results = []
for (let d = new Date(start); d <= end; d = new Date(d.getTime() + 86400000)) {
results.push({
time: d.toISOString(),
...sunriseSunset(lat, lon, d),
...moonPhase(d),
...nextMoonEvents(d),
...moonriseMoonset(lat, lon, d),
}
})
})
}
return results
}

View File

@@ -14,8 +14,8 @@ export function cfg() {
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
DEFAULT_LAT: parseFloat(process.env.DEFAULT_LAT || '0'),
DEFAULT_LON: parseFloat(process.env.DEFAULT_LON || '0'),
DEFAULT_ALT: parseFloat(process.env.DEFAULT_ALT || '0'),
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
}
}

View File

@@ -80,7 +80,7 @@ export async function queryHourly(start, end) {
export async function queryDaily(start, end) {
const { c } = getClient()
const measurements = ['environment', 'light', 'seismic', 'compass', 'ground', 'accumulation', 'lightning', 'gps']
const measurements = ['environment', 'light', 'seismic', 'compass', 'accumulation', 'lightning', 'gps']
const results = {}
for (const m of measurements) {
@@ -119,12 +119,12 @@ export async function queryDaily(start, end) {
for (const row of mins) {
const time = truncateToDay(row._time)
if (!results[time]) results[time] = { time }
if (row.env_temp_c != null) results[time].env_temp_min_c = row.env_temp_c
if (row.temperature != null) results[time].env_temp_min_c = row.temperature
}
for (const row of maxs) {
const time = truncateToDay(row._time)
if (!results[time]) results[time] = { time }
if (row.env_temp_c != null) results[time].env_temp_max_c = row.env_temp_c
if (row.temperature != null) results[time].env_temp_max_c = row.temperature
}
}
@@ -141,8 +141,8 @@ export async function getCoords() {
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`
const rows = await query(flux)
if (rows.length && rows[0].gps_lat && rows[0].gps_lon) {
return { lat: rows[0].gps_lat, lon: rows[0].gps_lon, alt: rows[0].gps_alt_m || c.DEFAULT_ALT }
if (rows.length && rows[0].latitude && rows[0].longitude) {
return { lat: rows[0].latitude, lon: rows[0].longitude, alt: rows[0].altitude || c.ALTITUDE }
}
return { lat: c.DEFAULT_LAT, lon: c.DEFAULT_LON, alt: c.DEFAULT_ALT }
return { lat: c.LATITUDE, lon: c.LONGITUDE, alt: c.ALTITUDE }
}

View File

@@ -146,14 +146,14 @@ function zipDaily(data, prefix = '') {
// Remap Open-Meteo field names to our prefixed convention
function remapWeatherFields(row) {
const map = {
temperature_2m: 'env_temp_c',
apparent_temperature: 'env_heat_index_c',
dewpoint_2m: 'env_dew_point_c',
relative_humidity_2m: 'env_humidity',
pressure_msl: 'env_pressure_slp',
cloudcover: 'light_cloud_pct',
uv_index: 'light_uvi',
visibility: 'light_visibility_km',
temperature_2m: 'temperature',
apparent_temperature: 'heat_index',
dewpoint_2m: 'dew_point',
relative_humidity_2m: 'humidity',
pressure_msl: 'pressure_slp',
cloudcover: 'clouds',
uv_index: 'uv_index',
visibility: 'visibility',
windspeed_10m: 'wind_speed_kmh',
winddirection_10m: 'wind_direction_deg',
windgusts_10m: 'wind_gusts_kmh',
@@ -174,9 +174,9 @@ function remapWeatherFields(row) {
windspeed_10m_max: 'wind_speed_max_kmh',
windgusts_10m_max: 'wind_gusts_max_kmh',
winddirection_10m_dominant: 'wind_direction_deg',
uv_index_max: 'light_uvi_max',
uv_index_max: 'uv_index_max',
snowfall_sum: 'forecast_snowfall_mm',
shortwave_radiation_sum: 'light_solar_wm2_sum',
shortwave_radiation_sum: 'solar_wm2_sum',
sunrise: 'sun_sunrise',
sunset: 'sun_sunset',
}

View File

@@ -1,151 +1,146 @@
import express from 'express'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { cfg } from './config.mjs'
import { queryCurrent, queryHourly, queryDaily, getCoords } from './influx.mjs'
import { getCelestialCurrent, getCelestialHourly, getCelestialDaily } from './celestial.mjs'
import { getSpaceWeather } from './space.mjs'
import { getOpenMeteo } from './openmeteo.mjs'
import { apiReference } from '@scalar/express-api-reference'
import { spec } from './spec.mjs'
import { existsSync } from 'fs'
import express from 'express';
import {resolve, dirname} from 'path';
import {fileURLToPath} from 'url';
import {cfg} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
import {getCelestialCurrent, getCelestialHourly, getCelestialDaily} from './celestial.mjs';
import {getSpaceWeather} from './space.mjs';
import {getOpenMeteo} from './openmeteo.mjs';
import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
import {existsSync} from 'fs';
import {getAirTraffic, getAirTrafficHistory, getShapes} from './airtraffic.mjs';
const app = express()
const DIR = dirname(fileURLToPath(import.meta.url))
const CLIENT_DIST = resolve(DIR, 'public')
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))
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)))
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))
if(!fields) return arr;
return arr.map(row => filterFields(row, fields));
}
function mergeRows(arrays) {
const map = {}
for (const arr of arrays) {
for (const row of arr) {
map[row.time] = { ...map[row.time], ...row }
}
}
return Object.values(map).sort((a, b) => a.time.localeCompare(b.time))
const map = {};
for(const arr of arrays) {
for(const row of arr) {
map[row.time] = {...map[row.time], ...row};
}
}
return Object.values(map).sort((a, b) => a.time.localeCompare(b.time));
}
// ── GET /api/data ─────────────────────────────────────────────────────────────
// ── Sensor Data ───────────────────────────────────────────────────────────────
app.get('/api/data', async (req, res) => {
const { fields } = req.query
const coords = await getCoords()
app.get('/api/current', async (req, res) => {
const {fields} = req.query;
const data = await queryCurrent();
res.json(filterFields(data, fields));
});
const [sensor, space, meteo] = await Promise.allSettled([
queryCurrent(),
getSpaceWeather(),
getOpenMeteo(coords.lat, coords.lon),
])
// ── Position ──────────────────────────────────────────────────────────────────
const celestial = getCelestialCurrent(coords.lat, coords.lon)
app.get('/api/position', async (req, res) => {
const {fields} = req.query;
const data = await getCoords();
res.json(filterFields(data, fields));
});
const data = {
gps_lat: coords.lat,
gps_lon: coords.lon,
gps_alt: coords.alt,
...(sensor.status === 'fulfilled' ? sensor.value : {}),
...celestial,
...(space.status === 'fulfilled' ? space.value : {}),
...(meteo.status === 'fulfilled' && meteo.value.current ? meteo.value.current : {}),
}
// ── Space ─────────────────────────────────────────────────────────────────────
res.json(filterFields(data, fields))
})
app.get('/api/space', async (req, res) => {
const {fields, mode} = req.query;
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
const end = req.query.end || new Date().toISOString();
const coords = await getCoords();
if(mode === 'hourly') {
const space = await getSpaceWeather();
Object.assign(space, getCelestialHourly(coords.lat, coords.lon, start, end));
res.json(filterFields(space, fields));
} else if(mode === 'daily') {
const space = await getSpaceWeather();
Object.assign(space, getCelestialDaily(coords.lat, coords.lon, start, end));
res.json(filterFields(space, fields));
} else {
const space = await getSpaceWeather();
Object.assign(space, getCelestialCurrent(coords.lat, coords.lon));
res.json(filterFields(space, fields));
}
});
// ── Daily History/Forecast ────────────────────────────────────────────────────
// ── GET /api/hourly ───────────────────────────────────────────────────────────
app.get('/api/hourly', async (req, res) => {
const { fields } = req.query
const start = req.query.start || new Date(new Date().setHours(0,0,0,0)).toISOString()
const end = req.query.end || new Date().toISOString()
const coords = await getCoords()
const {fields} = req.query;
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
const now = new Date().toISOString();
const end = req.query.end || new Date().toISOString();
const coords = await getCoords();
const [sensor, meteo] = await Promise.allSettled([
queryHourly(start, now),
getOpenMeteo(coords.lat, coords.lon, now, end),
]);
const history = sensor.status === 'fulfilled' ? sensor.value : [];
const forecast = meteo.status === 'fulfilled' ? meteo.value.hourly : [];
res.json(filterArr([...history, ...forecast], fields));
});
const [sensor, meteo, space] = await Promise.allSettled([
queryHourly(start, end),
getOpenMeteo(coords.lat, coords.lon, start, end),
getSpaceWeather(),
])
const sensorRows = sensor.status === 'fulfilled' ? sensor.value : []
const meteoHourly = meteo.status === 'fulfilled' ? meteo.value.hourly : []
const spaceData = space.status === 'fulfilled' ? space.value : {}
const seedRows = sensorRows.length ? sensorRows : meteoHourly
const celestial = getCelestialHourly(coords.lat, coords.lon, seedRows)
// Spread space weather into every hourly row
const result = mergeRows([sensorRows, meteoHourly, celestial])
.map(row => ({ gps_lat: coords.lat, gps_lon: coords.lon, gps_alt: coords.alt, ...spaceData, ...row }))
res.json(filterArr(result, fields))
})
// ── GET /api/daily ------------------------------------------------------------
// ── Hourly History/Forecast ───────────────────────────────────────────────────
app.get('/api/daily', async (req, res) => {
const { fields } = req.query
const start = req.query.start || new Date(new Date().setHours(0,0,0,0)).toISOString()
const end = req.query.end || new Date().toISOString()
const coords = await getCoords()
const {fields} = req.query;
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
const now = new Date().toISOString();
const end = req.query.end || new Date().toISOString();
const coords = await getCoords();
const [sensor, meteo] = await Promise.allSettled([
queryDaily(start, now),
getOpenMeteo(coords.lat, coords.lon, now, end),
]);
const history = sensor.status === 'fulfilled' ? sensor.value : [];
const forecast = meteo.status === 'fulfilled' ? meteo.value.daily : [];
res.json(filterArr([...history, ...forecast], fields));
});
const [sensor, meteo, space] = await Promise.allSettled([
queryDaily(start, end),
getOpenMeteo(coords.lat, coords.lon, start, end),
getSpaceWeather(),
])
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
const sensorRows = sensor.status === 'fulfilled' ? sensor.value : []
const meteoDaily = meteo.status === 'fulfilled' ? meteo.value.daily : []
const spaceData = space.status === 'fulfilled' ? space.value : {}
const seedRows = sensorRows.length ? sensorRows : meteoDaily
const celestial = getCelestialDaily(coords.lat, coords.lon, seedRows)
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));
app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)));
app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes()));
const result = mergeRows([sensorRows, meteoDaily, celestial])
.map(row => ({ location: coords,gps_lat: coords.lat, gps_lon: coords.lon, gps_alt: coords.alt, ...spaceData, ...row }))
// ── DOCS ──────────────────────────────────────────────────────────────────────
res.json(filterArr(result, fields))
})
// -- ADSB ----------------------------------------------------------------------
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()))
app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)))
app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes()))
// ── DOCS ----------------------------------------------------------------------
app.get('/openapi.json', (req, res) => res.json(spec))
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))
})
const {stringify} = await import('yaml');
res.setHeader('Content-Type', 'text/yaml');
res.send(stringify(spec));
});
// Scalar UI
app.use('/docs', apiReference({ spec: { url: '/openapi.json' }, theme: 'default' }))
// ── Website ───────────────────────────────────────────────────────────────────
// ── SPA Redirect --------------------------------------------------------------
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')
})
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');
});
// ── Start ─────────────────────────────────────────────────────────────────────
const c = cfg()
app.listen(c.PORT, () => console.log(`🌦 Weather API — http://localhost:${c.PORT}`))
const c = cfg();
app.listen(c.PORT, () => console.log(`🌦 Weather API — http://localhost:${c.PORT}`));

View File

@@ -7,6 +7,19 @@ export const spec = {
},
servers: [{ url: 'http://localhost:3000' }],
paths: {
'/api/position': {
get: {
summary: 'Station position',
description: 'Latest GPS position and info',
parameters: [ ],
responses: {
200: {
description: 'Last known position',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
}
}
}
},
'/api/data': {
get: {
summary: 'Current conditions',
@@ -66,41 +79,49 @@ export const spec = {
},
components: {
schemas: {
Position: {
type: 'object',
properties: {
lat: { type: 'number', description: 'Latitude' },
lon: { type: 'number', description: 'Longitude' },
alt: { type: 'number', description: 'Altitude' },
}
},
DataRow: {
type: 'object',
properties: {
time: { type: 'string', description: 'ISO8601 timestamp or date' },
// Environment
env_temp_c: { type: 'number', description: 'Temperature (°C)' },
temperature: { type: 'number', description: 'Temperature (°C)' },
env_temp_f: { type: 'number', description: 'Temperature (°F)' },
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
env_humidity: { type: 'number', description: 'Relative humidity (%)' },
env_dew_point_c: { type: 'number', description: 'Dew point (°C)' },
env_heat_index_c: { type: 'number', description: 'Feels like / heat index (°C)' },
env_pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
env_pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
env_pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
env_pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
env_abs_humidity: { type: 'number', description: 'Absolute humidity (g/m³)' },
env_vpd_kpa: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
env_gas_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
env_aqi_score: { type: 'number', description: 'Air quality index score (0100)' },
env_aqi_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
env_frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
humidity: { type: 'number', description: 'Relative humidity (%)' },
dew_point: { type: 'number', description: 'Dew point (°C)' },
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
air_quality: { type: 'number', description: 'Air quality index score (0100)' },
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
// Light
light_lux: { type: 'number', description: 'Illuminance (lux)' },
light_uvi: { type: 'number', description: 'UV index' },
light_solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
light_uv_dose_mj: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
lux: { type: 'number', description: 'Illuminance (lux)' },
uv_index: { type: 'number', description: 'UV index' },
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
light_cloud_pct: { type: 'number', description: 'Estimated cloud cover (%)' },
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
light_daylight_hours: { type: 'number', description: 'Hours above daylight threshold today' },
light_visibility_km: { type: 'number', description: 'Estimated visibility (km)' },
light_uvi_max: { type: 'number', description: 'Daily max UV index' },
light_solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
visibility: { type: 'number', description: 'Estimated visibility (km)' },
uv_index_max: { type: 'number', description: 'Daily max UV index' },
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
// Wind (Open-Meteo)
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
@@ -129,22 +150,22 @@ export const spec = {
compass_y: { type: 'number' },
compass_z: { type: 'number' },
// Ground / Accumulation
ground_distance_cm: { type: 'number', description: 'LIDAR ground distance (cm)' },
ground_lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
ground_calibrated_baseline_cm: { type: 'number', description: 'Calibrated baseline distance (cm)' },
ground_accumulation_depth_cm: { type: 'number', description: 'Snow/flood depth (cm)' },
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
ground_calibration: { type: 'number', description: 'Calibrated baseline distance (cm)' },
accumulation: { type: 'number', description: 'Snow/flood depth (cm)' },
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
// Lightning
lightning_distance_km: { type: 'number', description: 'Lightning strike distance (km)' },
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
lightning_energy: { type: 'number', description: 'Lightning energy' },
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
lightning_storm_trend: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (07)' },
// GPS
gps_lat: { type: 'number', description: 'Latitude (°)' },
gps_lon: { type: 'number', description: 'Longitude (°)' },
gps_alt_m: { type: 'number', description: 'Altitude (m)' },
latitude: { type: 'number', description: 'Latitude (°)' },
longitude: { type: 'number', description: 'Longitude (°)' },
altitude: { type: 'number', description: 'Altitude (m)' },
gps_satellites: { type: 'number', description: 'Satellites in view' },
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
gps_heading: { type: 'number', description: 'GPS heading (°)' },