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

6
.env
View File

@@ -1,4 +1,4 @@
DEFAULT_LAT=
DEFAULT_LON=
DEFAULT_ALT=
LATITUDE=
LONGITUDE=
ALTITUDE=
INFLUX_TOKEN=

View File

@@ -8,6 +8,8 @@
"name": "weather-station",
"version": "1.0.0",
"dependencies": {
"@ztimson/css-utils": "^1.3.5",
"@ztimson/utils": "^0.29.5",
"vue": "^3.5.38"
},
"devDependencies": {
@@ -22,6 +24,7 @@
"typescript": "~6.0.0",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.3",
"vue-tsc": "^3.3.5"
},
@@ -269,6 +272,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -278,6 +282,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -311,6 +316,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -475,6 +481,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -561,6 +568,7 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1372,6 +1380,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -1385,6 +1394,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.38",
@@ -1395,6 +1405,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -1412,6 +1423,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.38",
@@ -1472,6 +1484,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.38"
@@ -1481,6 +1494,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.38",
@@ -1491,6 +1505,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.38",
@@ -1503,6 +1518,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.38",
@@ -1516,6 +1532,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/tsconfig": {
@@ -1548,6 +1565,21 @@
"unzipit": "2.0.0"
}
},
"node_modules/@ztimson/css-utils": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@ztimson/css-utils/-/css-utils-1.3.5.tgz",
"integrity": "sha512-evQgelNCkol8CaD1ppWYQOWmGoku5pVz1LTx9WH0p/JtKSDjd1lEubKDsczh4qVlRJsKvyRD8f9TJcD98DuMYA==",
"license": "MIT"
},
"node_modules/@ztimson/utils": {
"version": "0.29.5",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.5.tgz",
"integrity": "sha512-8mUuhi//3agwrueR006emOvJu1JXxVEryJmD3nkEmK4yQ9qS24oZijdoaiLRid/6xc75j3Fk6YjmnYmjq61iPQ==",
"license": "MIT",
"dependencies": {
"var-persist": "^1.0.1"
}
},
"node_modules/alien-signals": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
@@ -1758,6 +1790,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1849,6 +1882,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -1881,6 +1915,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
@@ -2420,6 +2455,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2462,6 +2498,7 @@
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2664,6 +2701,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2696,6 +2734,7 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3316,6 +3355,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3398,7 +3438,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3473,6 +3513,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/var-persist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/var-persist/-/var-persist-1.0.1.tgz",
"integrity": "sha512-Zon+pwvEpb0dEQCVShoMQQWV1JbWi4P2knW3h2sfSZS3pLecgbFig76tMSHEECQuEQ3KfYhMXgRDDIybtHTyZw==",
"license": "MIT"
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
@@ -3683,6 +3729,7 @@
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.38",

View File

@@ -8,6 +8,8 @@
"build": "vue-tsc --build && vite build"
},
"dependencies": {
"@ztimson/css-utils": "^1.3.5",
"@ztimson/utils": "^0.29.5",
"vue": "^3.5.38"
},
"devDependencies": {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Dashboard from './views/Dashboard.vue'
import '@ztimson/css-utils/dist/css-utils.min.css';
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)

View File

@@ -1,29 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataRow } from '../services/api'
import {computed, onMounted, ref} from 'vue';
import {api, BASE, type DataRow} from '../services/api';
const props = defineProps<{ data: DataRow }>()
const d = computed(() => props.data)
const data = ref<any>({});
const precipitation = '';
const humidity = computed(() => data.value.humidity || '—');
const label = computed(() => data.value.forecast_weather_label || '');
const fmt = (v: unknown, dec = 1, unit = '') => v != null ? `${(v as number).toFixed(dec)}${unit}` : '—'
const time = (v: unknown) => v ? new Date(v as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—'
onMounted(async () => {
data.value = await api.current();
console.log(data.value);
});
const high = computed(() => fmt(d.value.env_temp_max_c, 1, '°C'))
const low = computed(() => fmt(d.value.env_temp_min_c, 1, '°C'))
const sunrise = computed(() => time(d.value.sun_sunrise))
const sunset = computed(() => time(d.value.sun_sunset))
const ghMornEnd = computed(() => time(d.value.sun_golden_hour_morning_end))
const ghEveStart = computed(() => time(d.value.sun_golden_hour_evening_start))
const dayLen = computed(() => d.value.sun_day_length_hours != null ? `${(d.value.sun_day_length_hours as number).toFixed(2)}h` : '—')
const uviMax = computed(() => fmt(d.value.light_uvi_max, 1))
const precipProb = computed(() => d.value.forecast_precipitation_probability != null ? `${d.value.forecast_precipitation_probability}%` : '—')
const precipMm = computed(() => fmt(d.value.forecast_precipitation_mm, 1, 'mm'))
const precipHrs = computed(() => d.value.precipitation_hours != null ? `${d.value.precipitation_hours}h` : null)
const windMax = computed(() => fmt(d.value.wind_speed_max_kmh, 1, ' km/h'))
const gustMax = computed(() => fmt(d.value.wind_gusts_max_kmh, 1, ' km/h'))
const icon = computed(() => d.value.forecast_weather_icon as string | null)
const label = computed(() => d.value.forecast_weather_label as string | null)
const solarSum = computed(() => d.value.light_solar_wm2_sum != null ? `${(d.value.light_solar_wm2_sum as number).toFixed(1)} W/m²` : null)
</script>
<style scoped lang="scss">
@@ -127,13 +115,16 @@ const solarSum = computed(() => d.value.light_solar_wm2_sum != null ? `${(d.va
<template>
<div class="today-card">
<div class="today-header">
<img v-if="icon" :src="icon" class="today-icon" alt="weather" />
<div class="today-title">
<div class="today-date">Today · {{ new Date().toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric' }) }}</div>
<div class="today-label">{{ label }}</div>
<div class="hi-lo">
<div class="flex-r align-items-center justify-between px-3">
<div class="flex-c justify-center align-items-center">
<div class="today-label">{{ data.label }}</div>
<img v-if="data.icon" :src="BASE + data.icon" class="today-icon" alt="weather" />
</div>
<div class="flex-r align-items-center gap-3">
<div class="fs-6">
0°C
</div>
<div class="flex-c fg-muted">
<span class="hi"> {{ high }}</span>
<span class="lo"> {{ low }}</span>
</div>
@@ -142,27 +133,13 @@ const solarSum = computed(() => d.value.light_solar_wm2_sum != null ? `${(d.va
<div class="divider" />
<!-- Sun -->
<div class="section-label"> Sun</div>
<div class="stat-grid">
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
<div class="stat"><span class="stat-label">Day Length</span><span class="stat-value">{{ dayLen }}</span></div>
<div class="stat"><span class="stat-label">UV Max</span><span class="stat-value">{{ uviMax }}</span></div>
<div class="stat"><span class="stat-label">Golden AM</span><span class="stat-value">{{ ghMornEnd }}</span></div>
<div class="stat"><span class="stat-label">Golden PM</span><span class="stat-value">{{ ghEveStart }}</span></div>
<div v-if="solarSum" class="stat"><span class="stat-label">Solar</span><span class="stat-value">{{ solarSum }}</span></div>
</div>
<div class="divider" />
<!-- Precip & Wind -->
<div class="section-label">🌧 Precipitation & Wind</div>
<div class="stat-grid">
<div class="stat"><span class="stat-label">Chance</span><span class="stat-value">{{ precipProb }}</span></div>
<div class="stat"><span class="stat-label">Amount</span><span class="stat-value">{{ precipMm }}</span><span v-if="precipHrs" class="stat-sub">over {{ precipHrs }}</span></div>
<div class="stat"><span class="stat-label">Wind Max</span><span class="stat-value">{{ windMax }}</span></div>
<div class="stat"><span class="stat-label">Gust Max</span><span class="stat-value">{{ gustMax }}</span></div>
<div class="stat"><span class="stat-label">Precipitation</span><span class="stat-value">{{ precipProb }}</span></div>
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span><span v-if="precipHrs" class="stat-sub">over {{ precipHrs }}</span></div>
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ windMax }}</span></div>
<div class="stat"><span class="stat-label">Clouds</span><span class="stat-value">{{ windMax }}</span></div>
<div class="stat"><span class="stat-label">Air Quality</span><span class="stat-value">{{ windMax }}</span></div>
<div class="stat"><span class="stat-label">UV index</span><span class="stat-value">{{ windMax }}</span></div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataRow } from '../services/api'
import {BASE, type DataRow} from '../services/api';
const props = defineProps<{ days: DataRow[] }>()
@@ -57,7 +57,7 @@ const items = computed(() => props.days.slice(0, 7).map(d => ({
<div class="strip">
<div v-for="item in items" :key="item.date" class="day">
<div class="day-name">{{ item.date }}</div>
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
<img v-if="item.icon" :src="BASE + item.icon" class="day-icon" :alt="item.label" />
<div class="day-label">{{ item.label }}</div>
<div class="day-temps">
<span>{{ item.high }}</span>

View File

@@ -177,8 +177,8 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
onMounted(async () => {
await fetchRadarFrames()
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
stationLayer = buildStationLayer(lat, lon, props.dark)
@@ -221,16 +221,16 @@ onUnmounted(() => {
watch(() => props.dark, dark => {
map.getLayers().setAt(0, buildBaseLayer(dark))
map.removeLayer(stationLayer)
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
stationLayer = buildStationLayer(lat, lon, dark)
map.addLayer(stationLayer)
})
let positioned = false
watch(() => current.value, () => {
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
if (!positioned) {
positioned = true
map.getView().animate({ center: fromLonLat([lon, lat]), duration: 500 })

View File

@@ -1,15 +1,7 @@
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23:3000' : ''
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : ''
export type DataRow = Record<string, number | string | null>
export async function fetchDaily(start?: string, end?: string): Promise<DataRow[]> {
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
const res = await fetch(`/api/daily?${params}`)
return res.json()
}
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
const url = new URL(`${BASE}${path}`, window.location.origin)
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))

View File

@@ -9,26 +9,26 @@ export interface MetricMeta {
export const METRICS: Record<string, MetricMeta> = {
// Environment
env_temp_c: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
temperature: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
env_temp_f: { label: 'Temperature', unit: '°F', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
env_humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
env_dew_point_c: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
env_heat_index_c: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
env_pressure_hpa: { label: 'Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
env_pressure_slp: { label: 'Sea Level Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
env_abs_humidity: { label: 'Absolute Humidity', unit: ' g/m³', icon: '💦', group: 'Environment', precision: 2, color: '#67e8f9' },
env_vpd_kpa: { label: 'Vapour Pressure Deficit', unit: ' kPa', icon: '🌿', group: 'Environment', precision: 2, color: '#4ade80' },
env_aqi_score: { label: 'Air Quality', unit: '/100', icon: '🏭', group: 'Environment', precision: 0, color: '#34d399' },
env_gas_ohms: { label: 'Gas Resistance', unit: ' Ω', icon: '⚗️', group: 'Environment', precision: 0, color: '#fbbf24' },
humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
dew_point: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
heat_index: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
pressure_hpa: { label: 'Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
pressure_slp: { label: 'Sea Level Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
humidity_abs: { label: 'Absolute Humidity', unit: ' g/m³', icon: '💦', group: 'Environment', precision: 2, color: '#67e8f9' },
vapor_pressure_deficit: { label: 'Vapour Pressure Deficit', unit: ' kPa', icon: '🌿', group: 'Environment', precision: 2, color: '#4ade80' },
air_quality: { label: 'Air Quality', unit: '/100', icon: '🏭', group: 'Environment', precision: 0, color: '#34d399' },
air_quality_ohms: { label: 'Gas Resistance', unit: ' Ω', icon: '⚗️', group: 'Environment', precision: 0, color: '#fbbf24' },
// Light
light_lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
light_uvi: { label: 'UV Index', unit: '', icon: '🕶️', group: 'Light', precision: 1, color: '#f472b6' },
light_solar_wm2: { label: 'Solar Irradiance', unit: ' W/m²', icon: '⚡', group: 'Light', precision: 1, color: '#fcd34d' },
light_uv_dose_mj: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' },
lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
uv_index: { label: 'UV Index', unit: '', icon: '🕶️', group: 'Light', precision: 1, color: '#f472b6' },
solar_wm2: { label: 'Solar Irradiance', unit: ' W/m²', icon: '⚡', group: 'Light', precision: 1, color: '#fcd34d' },
uv_dose: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' },
light_burn_time_min: { label: 'Burn Time', unit: ' min', icon: '🔥', group: 'Light', precision: 0, color: '#fb7185' },
light_cloud_pct: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
clouds: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
light_dli: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
light_visibility_km: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
visibility: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
// Wind
wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
wind_gusts_kmh: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
@@ -41,11 +41,11 @@ export const METRICS: Record<string, MetricMeta> = {
// Compass
compass_heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' },
// Ground
ground_distance_cm: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
ground_accumulation_depth_cm: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
ground_lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
ground_distance: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
accumulation: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
// Lightning
lightning_distance_km: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
lightning_distance: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
lightning_strikes_per_hour: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
// Moon
moon_illumination_pct: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' },

View File

@@ -1,23 +1,36 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { current, hourly, daily, fetchAll, loading } from '../services/weather'
import { METRICS, GROUPS } from '../services/units'
import MapView from '../components/MapView.vue'
import CurrentWeather from '../components/CurrentWeather.vue'
import ForecastStrip from '../components/ForecastStrip.vue'
import MetricCard from '../components/MetricCard.vue'
import GraphModal from '../components/GraphModal.vue'
`<script setup lang="ts">
import {formatDate} from '@ztimson/utils';
import {onMounted, onUnmounted, ref, computed} from 'vue';
import {current, hourly, daily, fetchAll, loading} from '../services/weather';
import {METRICS, GROUPS} from '../services/units';
import MapView from '../components/MapView.vue';
import CurrentWeather from '../components/CurrentWeather.vue';
import ForecastStrip from '../components/ForecastStrip.vue';
import MetricCard from '../components/MetricCard.vue';
import GraphModal from '../components/GraphModal.vue';
const props = defineProps<{ dark: boolean }>()
const selectedMetric = ref<string | null>(null)
let interval: ReturnType<typeof setInterval>
const day = ref();
const date = ref();
const time = ref();
const props = defineProps<{dark: boolean}>();
const selectedMetric = ref<string | null>(null);
let interval: ReturnType<typeof setInterval>;
let clock: ReturnType<typeof setInterval>;
onMounted(async () => {
await fetchAll()
interval = setInterval(fetchAll, 60 * 1000)
})
clock = setInterval(() => {
day.value = formatDate('dddd');
date.value = formatDate('MMMM D');
time.value = formatDate('h:mm A');
}, 1000);
await fetchAll();
interval = setInterval(fetchAll, 60 * 1000);
});
onUnmounted(() => clearInterval(interval))
onUnmounted(() => {
clearInterval(clock);
clearInterval(interval);
});
const groupedMetrics = computed(() => {
return GROUPS.map(group => ({
@@ -25,8 +38,8 @@ const groupedMetrics = computed(() => {
keys: Object.entries(METRICS)
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
.map(([key]) => key)
})).filter(g => g.keys.length > 0)
})
})).filter(g => g.keys.length > 0);
});
</script>
<style scoped lang="scss">
@@ -56,8 +69,14 @@ const groupedMetrics = computed(() => {
overflow-y: auto;
border-left: 1px solid var(--border);
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
}
.group-label {
@@ -78,7 +97,7 @@ const groupedMetrics = computed(() => {
min-width: 0;
> * {
flex: 1 1 140px; // grow, shrink, min width before wrapping
flex: 1 1 140px; // grow, shrink, min width before wrapping
min-width: 0;
}
}
@@ -87,21 +106,27 @@ const groupedMetrics = computed(() => {
height: 2px;
background: var(--accent);
position: fixed;
top: 0; left: 0; right: 0;
top: 0;
left: 0;
right: 0;
z-index: 9999;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@media (max-width: 768px) {
.dashboard {
flex-direction: column;
overflow-y: auto;
overflow-x: hidden; // ← added
overflow-x: hidden; // ← added
height: 100dvh;
}
@@ -119,46 +144,61 @@ const groupedMetrics = computed(() => {
border-left: none;
border-top: 1px solid var(--border);
overflow-y: visible;
overflow-x: hidden; // ← added
overflow-x: hidden; // ← added
-webkit-overflow-scrolling: touch;
box-sizing: border-box; // ← added
box-sizing: border-box; // ← added
&::before { display: none; }
&::-webkit-scrollbar { display: none; }
&::before {
display: none;
}
&::-webkit-scrollbar {
display: none;
}
}
}
</style>
<template>
<div class="dashboard">
<div v-if="loading" class="loading-bar" />
<div class="dashboard">
<div v-if="loading" class="loading-bar"/>
<div class="map-col">
<MapView :dark="props.dark" />
</div>
<div class="map-col">
<MapView :dark="props.dark"/>
</div>
<div class="panel-col">
<CurrentWeather :data="current" />
<ForecastStrip :days="daily" />
<div class="panel-col">
<div class="flex-r align-items-center gap-3">
<div class="pe-3" style="border-right: 2px solid var(--border)">
<h2 class="fs-6 m-0" style="line-height: 1em">{{time}}</h2>
</div>
<div class="flex-c">
<h1 class="m-0 fs-2" style="line-height: 1em">{{day}}</h1>
<h2 class="m-0 fs-1 fg-muted" style="line-height: 1em">{{date}}</h2>
</div>
</div>
<CurrentWeather />
<ForecastStrip :days="daily"/>
<template v-for="g in groupedMetrics" :key="g.group">
<div class="group-label">{{ g.group }}</div>
<div class="metric-grid">
<MetricCard
v-for="key in g.keys"
:key="key"
:metric-key="key"
:data="current"
@click="selectedMetric = key"
/>
</div>
</template>
</div>
<template v-for="g in groupedMetrics" :key="g.group">
<div class="group-label">{{ g.group }}</div>
<div class="metric-grid">
<MetricCard
v-for="key in g.keys"
:key="key"
:metric-key="key"
:data="current"
@click="selectedMetric = key"
/>
</div>
</template>
</div>
<GraphModal
:metric-key="selectedMetric"
:current-data="current"
@close="selectedMetric = null"
/>
</div>
<GraphModal
:metric-key="selectedMetric"
:current-data="current"
@close="selectedMetric = null"
/>
</div>
</template>
`

View File

@@ -1,4 +1,4 @@
import time, math, board, smbus2, serial, pynmea2, bme680
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
import adafruit_ltr390, threading, os
from datetime import datetime, timezone
from collections import deque
@@ -13,15 +13,15 @@ def cfg():
load_dotenv(BASE_DIR / '.env', override=True)
load_dotenv(BASE_DIR / '.env.local', override=True)
return {
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
'DEFAULT_LAT': float(os.getenv('DEFAULT_LAT', '0.0')),
'DEFAULT_LON': float(os.getenv('DEFAULT_LON', '0.0')),
'DEFAULT_ALT': float(os.getenv('DEFAULT_ALT', '0.0')),
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
'PRESSURE_TREND_WINDOW':int(os.getenv('PRESSURE_TREND_WINDOW', '60')),
'LUNA_CAL_SAMPLES': int( os.getenv('LUNA_CAL_SAMPLES', '20')),
@@ -151,10 +151,10 @@ def get_pressure_trend(window):
def read_bme(c, sensor, alt_m=None):
null = {
'env_temp_c': None, 'env_temp_f': None, 'env_humidity': None,
'env_dew_point_c': None, 'env_pressure_hpa': None, 'env_pressure_slp': None,
'env_pressure_rate': None, 'env_abs_humidity': None, 'env_vpd_kpa': None,
'env_heat_index_c': None, 'env_gas_ohms': None, 'env_aqi_score': None,
'temperature': None, 'humidity': None, 'humidity_abs': None,
'dew_point': None, 'pressure_hpa': None, 'pressure_slp': None,
'pressure_rate': None, 'vapor_pressure_deficit': None,
'heat_index': None, 'air_quality_ohms': None, 'air_quality': None,
}
if not sensor:
write(c, 'environment', null)
@@ -168,22 +168,21 @@ def read_bme(c, sensor, alt_m=None):
p_rate, p_label = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
dp = round(t - (100-rh)/5.0, 2)
write(c, 'environment', {
'env_temp_c': round(t, 2),
'env_temp_f': round(t*9/5+32, 2),
'env_humidity': round(rh, 2),
'env_dew_point_c': dp,
'env_pressure_hpa': round(p, 2),
'env_pressure_slp': sea_level_pressure(p, alt_m),
'env_pressure_rate': p_rate,
'env_abs_humidity': absolute_humidity(t, rh),
'env_vpd_kpa': vpd(t, rh),
'env_heat_index_c': heat_index(t, rh),
'env_gas_ohms': gas,
'env_aqi_score': aqi_score,
'temperature': round(t, 2),
'humidity': round(rh, 2),
'dew_point': dp,
'pressure_hpa': round(p, 2),
'pressure_slp': sea_level_pressure(p, alt_m),
'pressure_rate': p_rate,
'humidity_abs': absolute_humidity(t, rh),
'vapor_pressure_deficit': vpd(t, rh),
'heat_index': heat_index(t, rh),
'air_quality_ohms': gas,
'air_quality': aqi_score,
}, tags={
'env_aqi_label': aqi_label or '',
'env_pressure_trend': p_label or '',
'env_frost_risk': frost_risk(t, dp),
'air_quality_label': aqi_label or '',
'pressure_trend': p_label or '',
'frost_risk': frost_risk(t, dp),
})
return t, rh
except Exception as e:
@@ -226,14 +225,14 @@ def mpu_loop():
ax = s16((d[0] << 8)|d[1]) / 16384.0 - o[0]
ay = s16((d[2] << 8)|d[3]) / 16384.0 - o[1]
az = s16((d[4] << 8)|d[5]) / 16384.0 - o[2]
mag = math.sqrt(ax**2 + ay**2 + az**2)
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2)
with _mpu_lock:
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
_mpu_peak = {
'seismic_ax': round(ax, 3),
'seismic_ay': round(ay, 3),
'seismic_az': round(az, 3),
'seismic_magnitude': round(mag, 4),
'seismic_x': round(ax, 3),
'seismic_y': round(ay, 3),
'seismic_z': round(az, 3),
'magnitude': round(mag, 4),
}
time.sleep(0.01)
@@ -247,7 +246,7 @@ def flush_mpu(c):
# ── QMC5883L ──────────────────────────────────────────────────────────────────
def read_compass(c):
null = {'compass_x': None, 'compass_y': None, 'compass_z': None, 'compass_heading': None}
null = {'x': None, 'y': None, 'z': None, 'heading': None}
if not bus: write(c, 'compass', null); return
try:
addr = 0x1E
@@ -261,7 +260,7 @@ def read_compass(c):
if heading < 0: heading += 360
write(c, 'compass', {
'compass_x': x, 'compass_y': y, 'compass_z': z,
'compass_heading': round(heading, 1),
'heading': round(heading, 1),
})
except Exception as e:
print(f"[QMC5883L] {e}")
@@ -289,10 +288,9 @@ def read_ltr(c, sensor, solar_el, humidity=None):
global dli_lux_acc, dli_date, last_lux_time, daylight_start
null = {
'light_lux': None, 'light_uvi': None, 'light_solar_wm2': None,
'light_uv_dose_mj': None, 'light_burn_time_min': None,
'light_cloud_pct': None, 'light_dli': None,
'light_daylight_hours': None, 'light_visibility_km': None,
'lux': None, 'uv_index': None, 'solar_wm2': None,
'uv_dose': None, 'clouds': None, 'light_dli': None,
'daylight': None, 'visibility': None,
}
if not sensor: write(c, 'light', null); return
@@ -342,16 +340,15 @@ def read_ltr(c, sensor, solar_el, humidity=None):
)
write(c, 'light', {
'light_lux': lux,
'light_uvi': uvi,
'light_solar_wm2': round(lux / 120, 2),
'light_uv_dose_mj': round(uv_dose_mj, 2),
'light_burn_time_min': burn_min,
'light_cloud_pct': cloud_pct,
'lux': lux,
'uv_index': uvi,
'solar_wm2': round(lux / 120, 2),
'uv_dose': round(uv_dose_mj, 2),
'clouds': cloud_pct,
'light_dli': dli,
'light_daylight_hours':daylight_hours,
'light_visibility_km': visibility_estimate(lux, humidity) if humidity else None,
}, tags={'light_cloud_label': cloud_label or ''})
'daylight':daylight_hours,
'visibility': visibility_estimate(lux, humidity) if humidity else None,
}, tags={'clouds_label': cloud_label or ''})
# ── TF-Luna ───────────────────────────────────────────────────────────────────
@@ -376,17 +373,17 @@ def calibrate_luna(c):
print(" ❌ TF-Luna: calibration failed")
def read_luna(c, temp_c):
null = {'ground_distance_cm': None, 'ground_lidar_strength': None}
if not bus: write(c, 'ground', null); return
null = {'ground_distance': None, 'lidar_strength': None}
if not bus: write(c, 'accumulation', null); return
try:
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
if not d: write(c, 'ground', null); return
dist = d[0]|(d[1]<<8)
strength = d[2]|(d[3]<<8)
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']:
write(c, 'ground', null); return
write(c, 'accumulation', null); return
fields = {'ground_distance_cm': dist, 'ground_lidar_strength': strength}
fields = {'ground_distance': dist, 'lidar_strength': strength}
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
t = temp_c
@@ -396,14 +393,14 @@ def read_luna(c, temp_c):
'slush' if t is not None and t > c['SLUSH_TEMP_THRESHOLD'] else
'snow' if strength > c['SNOW_STRENGTH_MIN'] else 'ice'
)
fields['ground_calibrated_baseline_cm'] = luna_baseline_cm
fields['ground_accumulation_depth_cm'] = depth
write(c, 'accumulation', fields, tags={'ground_accumulation_type': kind})
fields['ground_calibration'] = luna_baseline_cm
fields['accumulation'] = depth
write(c, 'accumulation', fields, tags={'accumulation_type': kind})
else:
write(c, 'ground', fields)
write(c, 'accumulation', fields)
except Exception as e:
print(f"[TF-Luna] {e}")
write(c, 'ground', null)
write(c, 'accumulation', null)
# ── AS3935 ───────────────────────────────────────────────────────────────────
@@ -450,12 +447,12 @@ def read_as3935(c):
if avg_late < avg_early - 2: trend = 'Approaching'
elif avg_late > avg_early + 2: trend = 'Retreating'
write(c, 'lightning', {
'lightning_distance_km': dist,
'lightning_distance': dist,
'lightning_energy': energy,
'lightning_detector_sensitivity': c['AS3935_NOISE_FLOOR'],
'lightning_false_positive': 1 if interrupt == 0x04 else 0,
'lightning_strikes_per_hour': strike_rate,
}, tags={'lightning_storm_trend': trend})
}, tags={'storm_direction': trend})
except Exception as e:
print(f"[AS3935] {e}")
@@ -471,25 +468,16 @@ def read_gps(c):
if 'GGA' in line:
msg = pynmea2.parse(line)
gps_cache.update({
'gps_lat': msg.latitude,
'gps_lon': msg.longitude,
'gps_alt_m': msg.altitude,
'latitude': msg.latitude,
'longitude': msg.longitude,
'altitude': msg.altitude,
'gps_satellites': int(msg.num_sats),
})
elif 'VTG' in line:
msg = pynmea2.parse(line)
gps_cache['gps_speed_kmh'] = msg.spd_over_grnd_kmph
elif 'RMC' in line:
msg = pynmea2.parse(line)
gps_cache['gps_heading'] = msg.true_course
if gps_cache:
write(c, 'gps', {k: v for k, v in gps_cache.items() if v is not None})
except Exception as e:
print(f"[GPS] {e}")
write(c, 'gps', {
'gps_lat': None, 'gps_lon': None, 'gps_alt_m': None,
'gps_satellites': None, 'gps_speed_kmh': None, 'gps_heading': None,
})
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None,'gps_satellites': None})
return gps_cache
def solar_elevation(lat, lon):
@@ -504,6 +492,36 @@ def solar_elevation(lat, lon):
))
return round(el, 2)
# ── System ────────────────────────────────────────────────────────────────────
def read_system(c):
try:
# CPU temp (Linux hwmon/thermal)
cpu_temp = None
temps = psutil.sensors_temperatures()
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
if key in temps:
cpu_temp = round(temps[key][0].current, 1)
break
cpu_freq = psutil.cpu_freq()
vm = psutil.virtual_memory()
disk = psutil.disk_usage('/')
write(c, 'system', {
'cpu_temp': cpu_temp,
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
'mem_used_mb': round(vm.used / 1024**2, 1),
'mem_total_mb': round(vm.total / 1024**2, 1),
'mem_percent': vm.percent,
'disk_used_gb': round(disk.used / 1024**3, 2),
'disk_total_gb': round(disk.total / 1024**3, 2),
'disk_percent': disk.percent,
})
except Exception as e:
print(f"[System] {e}")
# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
@@ -534,9 +552,9 @@ if __name__ == '__main__':
c = cfg()
gps = read_gps(c)
lat = gps.get('gps_lat') or c['DEFAULT_LAT']
lon = gps.get('gps_lon') or c['DEFAULT_LON']
alt = gps.get('gps_alt_m') or c['DEFAULT_ALT']
lat = gps.get('latitude') or c['LATITUDE']
lon = gps.get('longitude') or c['LONGITUDE']
alt = gps.get('altitude') or c['ALTITUDE']
sol = solar_elevation(lat, lon) if lat and lon else None
result = read_bme(c, bme_sensor, alt_m=alt)
@@ -548,5 +566,6 @@ if __name__ == '__main__':
read_ltr(c, ltr_sensor, sol, humidity=last_rh)
read_luna(c, last_temp)
read_as3935(c)
read_system(c)
time.sleep(c['LOOP_INTERVAL'])

View File

@@ -3,6 +3,7 @@ python-dotenv
smbus2
adafruit-circuitpython-ltr390
bme680
psutil
pyserial
pynmea2
RPi.GPIO

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 (°)' },