refactor part 1
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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 (0–100)' },
|
||||
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 (0–100)' },
|
||||
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 (0–7)' },
|
||||
// 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 (°)' },
|
||||
|
||||
Reference in New Issue
Block a user