diff --git a/.env b/.env index 618ee49..f18c640 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -DEFAULT_LAT= -DEFAULT_LON= -DEFAULT_ALT= +LATITUDE= +LONGITUDE= +ALTITUDE= INFLUX_TOKEN= diff --git a/client/package-lock.json b/client/package-lock.json index 3209f54..b23333e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index c7b365b..78c9c79 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src/App.vue b/client/src/App.vue index 51bda8c..b04122c 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,6 +1,7 @@ +` diff --git a/sensors/main.py b/sensors/main.py index f8003eb..0dc6f62 100644 --- a/sensors/main.py +++ b/sensors/main.py @@ -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']) diff --git a/sensors/requirements.txt b/sensors/requirements.txt index 003167d..95db02b 100644 --- a/sensors/requirements.txt +++ b/sensors/requirements.txt @@ -3,6 +3,7 @@ python-dotenv smbus2 adafruit-circuitpython-ltr390 bme680 +psutil pyserial pynmea2 RPi.GPIO diff --git a/server/celestial.mjs b/server/celestial.mjs index 05bf7c0..940c7e6 100644 --- a/server/celestial.mjs +++ b/server/celestial.mjs @@ -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 } diff --git a/server/config.mjs b/server/config.mjs index 422b656..f23d6ac 100644 --- a/server/config.mjs +++ b/server/config.mjs @@ -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'), } } diff --git a/server/influx.mjs b/server/influx.mjs index 3b665d9..0ba5aad 100644 --- a/server/influx.mjs +++ b/server/influx.mjs @@ -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 } } diff --git a/server/openmeteo.mjs b/server/openmeteo.mjs index b1cd5f1..4081f8d 100644 --- a/server/openmeteo.mjs +++ b/server/openmeteo.mjs @@ -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', } diff --git a/server/server.mjs b/server/server.mjs index 9b6599a..5a1e2e6 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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}`)); diff --git a/server/spec.mjs b/server/spec.mjs index dbc4de4..5bfdb51 100644 --- a/server/spec.mjs +++ b/server/spec.mjs @@ -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 (°)' },