From 74ddb37446bddb01dbaadd7872cec14f9ccdfb7c Mon Sep 17 00:00:00 2001 From: ztimson Date: Fri, 26 Jun 2026 21:52:27 -0400 Subject: [PATCH] Switched from influx to victoria metrics (more light weight for pi) --- .env | 1 - client/src/components/GraphModal.vue | 12 +- client/src/components/Ship.vue | 362 +++++++++++++++++++++++++++ client/src/components/Sun.vue | 2 +- client/src/services/ais.ts | 159 ++++-------- sensors/main.py | 59 ++--- sensors/requirements.txt | 2 +- server/package-lock.json | 62 +++-- server/package.json | 1 - server/public/favicon.png | Bin 39924 -> 0 bytes server/src/influx.mjs | 193 ++++++-------- setup.sh | 97 +++++-- 12 files changed, 620 insertions(+), 330 deletions(-) create mode 100644 client/src/components/Ship.vue delete mode 100644 server/public/favicon.png diff --git a/.env b/.env index 4e02cdf..afb2e0b 100644 --- a/.env +++ b/.env @@ -1,4 +1,3 @@ -INFLUX_TOKEN= ALTITUDE= LATITUDE= LONGITUDE= diff --git a/client/src/components/GraphModal.vue b/client/src/components/GraphModal.vue index f6dc8b7..162b4c7 100644 --- a/client/src/components/GraphModal.vue +++ b/client/src/components/GraphModal.vue @@ -1,5 +1,5 @@ + + + + diff --git a/client/src/components/Sun.vue b/client/src/components/Sun.vue index 1c66708..b2ec6cf 100644 --- a/client/src/components/Sun.vue +++ b/client/src/components/Sun.vue @@ -45,7 +45,7 @@ const arc = computed(() => { const { x: cx, y: cy } = pts[Math.min(splitIdx, 200)]; const traveledPts: any = pts.slice(0, splitIdx + 1); - const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') + const filledPath = traveledPts.map((p: any, i: any) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') + ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}` + ` L${x1},${horizonY} Z`; diff --git a/client/src/services/ais.ts b/client/src/services/ais.ts index 9c643b9..77f3776 100644 --- a/client/src/services/ais.ts +++ b/client/src/services/ais.ts @@ -1,30 +1,33 @@ -import {BASE} from '@/services/api.ts'; +import { BASE } from '@/services/api.ts' +import { createApp, ref } from 'vue' import Map from 'ol/Map' import { Feature } from 'ol' import Point from 'ol/geom/Point' import { fromLonLat } from 'ol/proj' -import { Style, Icon, Text, Fill, Stroke } from 'ol/style' +import { Style, Icon } from 'ol/style' import { Vector as VectorLayer } from 'ol/layer' import { Vector as VectorSource } from 'ol/source' import { adjustedInterval } from '@ztimson/utils' +import ShipPopup from '@/components/Ship.vue' const API = BASE + '/api' +let highestZIndex = 1000 + const SHIP_COLORS: Record = { - 'Base Station': '#00aaff', - 'Class A': '#00ff00', - 'Class B': '#8450ea', - 'SAR Aircraft': '#ff6600', - 'Aid to Navigation': '#ffff00', - 'Class B CS': '#8450ea', - 'Sart/Epirb/MOB': '#ff0000', - 'Unknown': '#fad106', + 'Base Station': '#00aaff', + 'Class A': '#00ff00', + 'Class B': '#8450ea', + 'SAR Aircraft': '#ff0000', + 'AtoN': '#00aaff', + 'Class B/CS': '#8450ea', + 'Sart/Epirb/MOB': '#00aaff', + 'Unknown': '#fad106', } function buildBoatIcon(boat: any): string { const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'] - - if (boat.type === 'Base Station') { + if (['Base Station', 'AtoN'].includes(boat.type)) { return `data:image/svg+xml,${encodeURIComponent(` @@ -35,10 +38,9 @@ function buildBoatIcon(boat: any): string { `)}` } - return `data:image/svg+xml,${encodeURIComponent(` - + @@ -47,77 +49,25 @@ function buildBoatIcon(boat: any): string { `)}` } -function buildPopupHtml(boat: any): string { - const name = boat.name?.trim() || boat.callsign?.trim() || `MMSI: ${boat.mmsi}` - const rows: [string, any][] = [ - ['Type', boat.type], - ['MMSI', boat.mmsi], - ['Country', boat.country], - ['Callsign', boat.callsign || '-'], - ['IMO', boat.imo || '-'], - ['Ship Type', boat.ship_type ?? '-'], - ['Speed', boat.speed != null ? `${boat.speed} kts` : '-'], - ['Heading', boat.heading != null ? `${boat.heading}°` : '-'], - ['Course', boat.course != null ? `${boat.course}°` : '-'], - ['Lat / Lon', `${boat.lat?.toFixed(5)}, ${boat.lon?.toFixed(5)}`], - ['Distance', `${boat.distance?.toFixed(2)} nmi`], - ['Bearing', `${boat.bearing}°`], - ['Destination', boat.destination || '-'], - ['ETA', boat.eta || '-'], - ['Draught', boat.draught != null ? `${boat.draught}m` : '-'], - ['RSSI', `${boat.rssi?.toFixed(1)} dB`], - ['Drift', `${boat.drift?.toFixed(2)} ppm`], - ['Msg Types', boat.msg_type?.join(', ') || '-'], - ['Channels', boat.channels || '-'], - ['Sources', boat.sources], - ['Receiver', boat.receiver], - ['Count', boat.count], - ['Last Signal', boat.last_signal != null ? `${boat.last_signal}s ago` : '-'], - ['In Range', boat.in_range], - ] - - return ` -
-
-

🚢 ${name}

-
${boat.type || 'Unknown'}
-
-
- ${rows.map(([label, val]) => ` -
-
${label}
-
${val ?? '-'}
-
- `).join('')} -
-
- ` -} - export class AISLayer { private map: Map private layer!: VectorLayer - private popups: Record void }> = {} - private data: any[] = [] + private popups: any = {} + private data: any[] = [] private clickHandler: ((e: any) => void) | null = null - private refreshTimer: any = null + private refreshTimer: any = null visible = false constructor(map: Map) { this.map = map } - // ── Public ──────────────────────────────────────────────────────────────── - async show() { if (this.visible) return this.visible = true - - this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) + this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) this.map.addLayer(this.layer) - await this._fetch() this._draw() this._attachClick() - this.refreshTimer = adjustedInterval(async () => { await this._fetch() this._draw() @@ -128,16 +78,12 @@ export class AISLayer { hide() { if (!this.visible) return this.visible = false - if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null } if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null } - for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi) this.map.removeLayer(this.layer) } - // ── Private ─────────────────────────────────────────────────────────────── - private async _fetch() { this.data = await fetch(`${API}/ais`).then(r => r.json()) || [] } @@ -145,17 +91,16 @@ export class AISLayer { private _draw() { const source = this.layer.getSource()! source.clear() - for (const boat of this.data) { if (boat.lat == null || boat.lon == null) continue const coord = fromLonLat([boat.lon, boat.lat]) - const f = new Feature({ geometry: new Point(coord) }) + const f = new Feature({ geometry: new Point(coord) }) f.set('mmsi', boat.mmsi) f.set('boatData', boat) f.setStyle(new Style({ image: new Icon({ src: buildBoatIcon(boat), - scale: 1, + scale: 0.8, rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180), anchor: [0.5, 0.5], }), @@ -164,55 +109,51 @@ export class AISLayer { } } + private _calcPopupPos(boat: any): { x: number; y: number } { + const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat])) + if (!pixel) return { x: 10, y: 60 } + const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect() + return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 } + } + private _openPopup(boat: any) { const mmsi = String(boat.mmsi) if (this.popups[mmsi]) return - const el = document.createElement('div') - el.style.cssText = ` - position:absolute; z-index:9999; pointer-events:auto; - background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3); - border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden; - ` + const boatRef = ref(boat) + const posRef = ref(this._calcPopupPos(boat)) - const close = document.createElement('button') - close.innerHTML = '✕' - close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1' - close.onclick = () => this._closePopup(mmsi) + const container = document.createElement('div') + document.body.appendChild(container) - el.innerHTML = buildPopupHtml(boat) - el.appendChild(close) - document.body.appendChild(el) - this._positionPopup(el, boat) + const app = createApp(ShipPopup, { + boat: boatRef.value, + position: posRef.value, + onClose: () => this._closePopup(mmsi), + onBringToFront: () => { + highestZIndex++ + container.style.zIndex = String(highestZIndex) + }, + }) - this.popups[mmsi] = { el, destroy: () => el.remove() } - } - - private _positionPopup(el: HTMLDivElement, boat: any) { - const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat])) - if (!pixel) return - const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect() - el.style.left = `${rect.left + pixel[0] + 16}px` - el.style.top = `${rect.top + pixel[1] - 16}px` + app.mount(container) + this.popups[mmsi] = { + boatRef, + posRef, + unmount: () => { app.unmount(); container.remove() }, + } } private _closePopup(mmsi: string) { const popup = this.popups[mmsi] - if (popup) { popup.destroy(); delete this.popups[mmsi] } + if (popup) { popup.unmount(); delete this.popups[mmsi] } } private _refreshPopups() { for (const mmsi of Object.keys(this.popups)) { const boat = this.data.find(b => String(b.mmsi) === mmsi) if (!boat) { this._closePopup(mmsi); continue } - const { el } = this.popups[mmsi] - el.innerHTML = buildPopupHtml(boat) - const close = document.createElement('button') - close.innerHTML = '✕' - close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1' - close.onclick = () => this._closePopup(mmsi) - el.appendChild(close) - this._positionPopup(el, boat) + this.popups[mmsi].boatRef.value = boat } } diff --git a/sensors/main.py b/sensors/main.py index bb38b76..71261bf 100644 --- a/sensors/main.py +++ b/sensors/main.py @@ -1,9 +1,7 @@ import time, math, board, smbus2, serial, psutil, pynmea2, bme680 -import adafruit_ltr390, threading, os, statistics +import adafruit_ltr390, threading, os, statistics, requests from datetime import datetime, timezone from collections import deque -from influxdb_client import InfluxDBClient, Point -from influxdb_client.client.write_api import SYNCHRONOUS from dotenv import load_dotenv from pathlib import Path @@ -16,9 +14,7 @@ def cfg(): '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_URL': os.getenv('INFLUX_URL', 'http://localhost:8428'), 'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'), 'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'), 'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')), @@ -49,31 +45,17 @@ def cfg(): 'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')), } -# ── InfluxDB ────────────────────────────────────────────────────────────────── - -_influx_client = None -_influx_writer = None -_influx_url = None -_influx_token = None - -def get_writer(c): - global _influx_client, _influx_writer, _influx_url, _influx_token - if c['INFLUX_URL'] != _influx_url or c['INFLUX_TOKEN'] != _influx_token: - if _influx_client: _influx_client.close() - _influx_client = InfluxDBClient(url=c['INFLUX_URL'], token=c['INFLUX_TOKEN'], org=c['INFLUX_ORG']) - _influx_writer = _influx_client.write_api(write_options=SYNCHRONOUS) - _influx_url, _influx_token = c['INFLUX_URL'], c['INFLUX_TOKEN'] - return _influx_writer +# ── VictoriaMetrics ─────────────────────────────────────────────────────────── def write(c, measurement, fields, tags={}): - try: - p = Point(measurement).time(datetime.now(timezone.utc)) - for k, v in tags.items(): p = p.tag(k, v) - for k, v in fields.items(): - if v is not None: p = p.field(k, float(v) if isinstance(v, (int, float)) else v) - get_writer(c).write(bucket=c['INFLUX_BUCKET'], org=c['INFLUX_ORG'], record=p) - except Exception as e: - print(f"[InfluxDB] {measurement}: {e}") + tag_str = ',' + ','.join(f"{k}={v}" for k, v in tags.items() if v) if tags else '' + field_str = ','.join( + f"{k}={'true' if v is True else 'false' if v is False else v}" + for k, v in fields.items() if v is not None + ) + if not field_str: return + line = f"{measurement}{tag_str} {field_str}" + requests.post(f"{c['INFLUX_URL']}/write", data=line, params={'db': c['INFLUX_BUCKET']}, timeout=2) # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -325,7 +307,6 @@ def get_expected_lux(c, solar_el): return round(baseline, 2) def classify_clouds(c, lux, solar_el): - # Resize window if config changed if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']: _cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW']) _cloud_lux_window.append(lux) @@ -345,11 +326,11 @@ def classify_clouds(c, lux, solar_el): vt = c['CLOUD_VOLATILITY_THRESH'] if volatility is not None and volatility > vt and c['CLOUD_CLEAR_MAX'] < cloud_pct < c['CLOUD_CLOUDY_MAX']: - label = 'Partly Cloudy' + label = 'Partly_Cloudy' elif cloud_pct < c['CLOUD_CLEAR_MAX']: label = 'Clear' elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']: - label = 'Mostly Clear' + label = 'Mostly_Clear' elif cloud_pct < c['CLOUD_CLOUDY_MAX']: label = 'Cloudy' else: @@ -370,7 +351,7 @@ def uv_index_label(uvi): if uvi < 3: return 'Low' elif uvi < 6: return 'Moderate' elif uvi < 8: return 'High' - elif uvi < 11: return 'Very High' + elif uvi < 11: return 'Very_High' else: return 'Extreme' def read_ltr(c, sensor, solar_el, humidity=None): @@ -426,7 +407,7 @@ def read_ltr(c, sensor, solar_el, humidity=None): 'daylight': daylight_hours, 'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None, }, tags={ - 'clouds_label': cloud_label or '', + 'clouds_label': cloud_label or '', 'uv_index_label': uv_index_label(uvi), }) @@ -572,18 +553,12 @@ def solar_elevation(lat, lon): # ── Rain ────────────────────────────────────────────────────────────────────── def read_rain(c): - try: - write(c, 'rain', {'precipitation': 0}, tags={'raining': False}) - except Exception as e: - print(f"[Rain] {e}") + write(c, 'rain', {'precipitation': 0}, tags={'raining': False}) # ── Wind ────────────────────────────────────────────────────────────────────── def read_wind(c): - try: - write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0}) - except Exception as e: - print(f"[Wind] {e}") + write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0}) # ── System ──────────────────────────────────────────────────────────────────── diff --git a/sensors/requirements.txt b/sensors/requirements.txt index 95db02b..b374fe6 100644 --- a/sensors/requirements.txt +++ b/sensors/requirements.txt @@ -1,4 +1,4 @@ -influxdb-client +requests python-dotenv smbus2 adafruit-circuitpython-ltr390 diff --git a/server/package-lock.json b/server/package-lock.json index e7e1433..98e1afb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,7 +8,6 @@ "name": "weather-station", "version": "1.0.0", "dependencies": { - "@influxdata/influxdb-client": "^1.33.2", "@scalar/express-api-reference": "^0.10.4", "@ztimson/utils": "^0.29.5", "adm-zip": "^0.5.17", @@ -18,20 +17,14 @@ "yaml": "^2.9.0" } }, - "node_modules/@influxdata/influxdb-client": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", - "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", - "license": "MIT" - }, "node_modules/@scalar/client-side-rendering": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.4.tgz", - "integrity": "sha512-IQC39aQQXKsZXOqQw0LNiZ/gQ1WxHpF+WLTo/hurn0S/FmNHlBGUAhFyt1BeDSUYxics7ixgzQatKJDggafBTw==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz", + "integrity": "sha512-wvnyBtNdYob6CdmpFqLSQoGM5q5Ra8bXyq713T/u8Nq1NXbULk1znz/LyR78u8g++0G8bkxnVzhvOf7s9FwHFw==", "license": "MIT", "dependencies": { - "@scalar/schemas": "0.5.0", - "@scalar/types": "0.14.0", + "@scalar/schemas": "0.7.0", + "@scalar/types": "0.16.0", "@scalar/validation": "0.6.0" }, "engines": { @@ -39,33 +32,33 @@ } }, "node_modules/@scalar/express-api-reference": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.4.tgz", - "integrity": "sha512-/FkuP+B9ppWiQbf0Bhygx8CwoLXiEGFgS7OK14FM/JH0SeAaGfQiacUgPEpxJYI2qnqFHkrys8YH2jl400wDfg==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.6.tgz", + "integrity": "sha512-5gXV4Cv6EA8/hUo1e3ZMHKo5w5D45QTCzsA2Qvrf4OEe141iKdtSErYYxaZ/c2RIVidRUohDr3WC2HN+HRDosw==", "license": "MIT", "dependencies": { - "@scalar/client-side-rendering": "0.2.4" + "@scalar/client-side-rendering": "0.2.6" }, "engines": { "node": ">=22" } }, "node_modules/@scalar/helpers": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.2.tgz", - "integrity": "sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.0.tgz", + "integrity": "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==", "license": "MIT", "engines": { "node": ">=22" } }, "node_modules/@scalar/schemas": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.5.0.tgz", - "integrity": "sha512-aV0PWqFpQft9a6d4Z6HOTHKDaA+K4AquxSPfBDI1c5f6lMe5l3zViww/iBQA/Yw+V1+Y2iwSVzhuxaktC1UiMg==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.0.tgz", + "integrity": "sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", + "@scalar/helpers": "0.9.0", "@scalar/validation": "0.6.0" }, "engines": { @@ -73,12 +66,12 @@ } }, "node_modules/@scalar/types": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.14.0.tgz", - "integrity": "sha512-aS1FvHbKhBC51mWqXmhkPe6lmhCd9+u//DQ1YTiCWOz4/fXAdWfwHvW9UZ7AzzcGwGiCo9diE81xV+UvSI1qIg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.0.tgz", + "integrity": "sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==", "license": "MIT", "dependencies": { - "@scalar/helpers": "0.8.2", + "@scalar/helpers": "0.9.0", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" @@ -838,9 +831,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "5.1.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz", - "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -981,12 +974,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" diff --git a/server/package.json b/server/package.json index 16a7911..75e9ada 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,6 @@ "start": "node src/server.mjs" }, "dependencies": { - "@influxdata/influxdb-client": "^1.33.2", "@scalar/express-api-reference": "^0.10.4", "@ztimson/utils": "^0.29.5", "adm-zip": "^0.5.17", diff --git a/server/public/favicon.png b/server/public/favicon.png deleted file mode 100644 index 27b92f0aef729a588c8cb56cf575181f841ade06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39924 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 OcImt{c+seCAN_yq#F4`Q diff --git a/server/src/influx.mjs b/server/src/influx.mjs index cdf5f43..afb4ef4 100644 --- a/server/src/influx.mjs +++ b/server/src/influx.mjs @@ -1,152 +1,107 @@ -import {InfluxDB} from '@influxdata/influxdb-client'; import {cfg} from './config.mjs'; const MEASUREMENTS = ['accumulation', 'environment', 'light', 'lightning', 'rain', 'seismic', 'wind']; -let client, currentUrl, currentToken; - -function getClient() { - const c = cfg(); - if(c.INFLUX_URL !== currentUrl || c.INFLUX_TOKEN !== currentToken) { - client = new InfluxDB({url: c.INFLUX_URL, token: c.INFLUX_TOKEN}); - currentUrl = c.INFLUX_URL; - currentToken = c.INFLUX_TOKEN; - } - return {client, c}; +async function query(c, q) { + const url = new URL('/api/v1/query_range', c.INFLUX_URL); + url.searchParams.set('query', q); + url.searchParams.set('step', '60s'); + const res = await fetch(url); + const json = await res.json(); + return json.data?.result || []; } -async function query(flux) { - const {client, c} = getClient(); - const api = client.getQueryApi(c.INFLUX_ORG); - const rows = []; - return new Promise((resolve, reject) => { - api.queryRows(flux, { - next(row, meta) { rows.push(meta.toObject(row)); }, - error(err) { reject(err); }, - complete() { resolve(rows); }, - }); - }); +async function queryInstant(c, q) { + const url = new URL('/api/v1/query', c.INFLUX_URL); + url.searchParams.set('query', q); + const res = await fetch(url); + const json = await res.json(); + return json.data?.result || []; } -function truncateToHour(iso) { - const d = new Date(iso); - d.setMinutes(0, 0, 0); - const pad = n => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; -} - -function truncateToDay(iso) { - return new Date(iso).toISOString().slice(0, 10); -} - -function extractRow(row) { +function metricToFields(results) { const out = {}; - for(const [k, v] of Object.entries(row)) { - if(!k.startsWith('_') && k !== 'result' && k !== 'table') out[k] = v; + for (const r of results) { + const field = r.metric.__name__ || r.metric.field; + const val = r.value?.[1] ?? r.values?.at(-1)?.[1]; + if (val !== undefined) out[field] = parseFloat(val); } return out; } export async function queryCurrent() { - const {c} = getClient(); - const flux = ` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: -1h) - |> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')}) - |> last() - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `; - const rows = await query(flux); - const result = {time: new Date()}; - for(const row of rows) { - Object.assign(result, extractRow(row)); + const c = cfg(); + const now = Math.floor(Date.now() / 1000); + const out = {time: new Date()}; + + for (const m of MEASUREMENTS) { + const results = await queryInstant(c, `{measurement="${m}"}`); + Object.assign(out, metricToFields(results)); } - return result; + return out; } export async function queryHourly(start, end) { - const {c} = getClient(); - const flux = ` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) - |> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')}) - |> group(columns: ["_field"]) - |> aggregateWindow(every: 1h, fn: mean, createEmpty: false) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `; - const rows = await query(flux); - return rows.map(row => ({ - time: truncateToHour(row._time), - ...extractRow(row), - })); + const c = cfg(); + const s = Math.floor(start.getTime() / 1000); + const e = Math.floor(end.getTime() / 1000); + const byTime = {}; + + for (const m of MEASUREMENTS) { + const results = await query(c, `avg_over_time({measurement="${m}"}[1h])`); + results.forEach(r => { + const field = r.metric.__name__; + r.values.forEach(([ts, val]) => { + const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00'; + if (!byTime[time]) byTime[time] = {time}; + byTime[time][field] = parseFloat(val); + }); + }); + } + return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time)); } export async function queryDaily(start, end) { - const {c} = getClient(); - const measurements = ['accumulation', 'environment', 'light', 'lightning', 'seismic']; - const results = {}; + const c = cfg(); + const byTime = {}; - for(const m of measurements) { + for (const m of ['accumulation', 'environment', 'light', 'lightning', 'seismic']) { const [means, mins, maxs] = await Promise.all([ - query(` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) - |> filter(fn: (r) => r._measurement == "${m}") - |> group(columns: ["_field"]) - |> aggregateWindow(every: 1d, fn: mean, createEmpty: false) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `), - query(` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) - |> filter(fn: (r) => r._measurement == "${m}") - |> group(columns: ["_field"]) - |> aggregateWindow(every: 1d, fn: min, createEmpty: false) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `), - query(` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: ${start.toISOString()}, stop: ${end.toISOString()}) - |> filter(fn: (r) => r._measurement == "${m}") - |> group(columns: ["_field"]) - |> aggregateWindow(every: 1d, fn: max, createEmpty: false) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `), + query(c, `avg_over_time({measurement="${m}"}[1d])`), + query(c, `min_over_time({measurement="${m}"}[1d])`), + query(c, `max_over_time({measurement="${m}"}[1d])`), ]); - for(const row of means) { - const time = truncateToDay(row._time); - if(!results[time]) results[time] = {time}; - Object.assign(results[time], extractRow(row)); - } + means.forEach(r => { + r.values.forEach(([ts, val]) => { + const time = new Date(ts * 1000).toISOString().slice(0, 10); + if (!byTime[time]) byTime[time] = {time}; + byTime[time][r.metric.__name__] = parseFloat(val); + }); + }); - for(const row of mins) { - const time = truncateToDay(row._time); - if(!results[time]) results[time] = {time}; - 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.temperature != null) results[time].env_temp_max_c = row.temperature; - } + const applyMinMax = (results, suffix) => results.forEach(r => { + if (r.metric.__name__ !== 'temperature') return; + r.values.forEach(([ts, val]) => { + const time = new Date(ts * 1000).toISOString().slice(0, 10); + if (!byTime[time]) byTime[time] = {time}; + byTime[time][suffix] = parseFloat(val); + }); + }); + + applyMinMax(mins, 'env_temp_min_c'); + applyMinMax(maxs, 'env_temp_max_c'); } - - return Object.values(results).sort((a, b) => a.time.localeCompare(b.time)); + return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time)); } export async function getCoords() { - const {c} = getClient(); - const flux = ` - from(bucket: "${c.INFLUX_BUCKET}") - |> range(start: -24h) - |> filter(fn: (r) => r._measurement == "gps") - |> last() - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - `; - const rows = await query(flux); - if(rows.length && rows[0].latitude && rows[0].longitude) { - return {latitude: rows[0].latitude, longitude: rows[0].longitude, altitude: rows[0].altitude || c.ALTITUDE}; + const c = cfg(); + const results = await queryInstant(c, `{measurement="gps"}`); + const fields = metricToFields(results); + + if (fields.latitude && fields.longitude) { + return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE}; } return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE}; } diff --git a/setup.sh b/setup.sh index ff6f85e..3058573 100755 --- a/setup.sh +++ b/setup.sh @@ -3,22 +3,48 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_NAME="$(whoami)" +VM_VERSION="1.99.0" +VM_DIR="/opt/victoriametrics" echo "🔧 Setting up Weather Station..." -# ── InfluxDB ────────────────────────────────────────────────────────────────── +# ── VictoriaMetrics ─────────────────────────────────────────────────────────── -if ! command -v influxd &> /dev/null; then - echo "📦 Installing InfluxDB..." - curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null - echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list - sudo apt update && sudo apt install -y influxdb2 - sudo systemctl enable --now influxdb +if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then + echo "📦 Installing VictoriaMetrics..." + sudo mkdir -p $VM_DIR/data + + wget -qO /tmp/vm.tar.gz \ + "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v${VM_VERSION}/victoria-metrics-arm-v7-v${VM_VERSION}.tar.gz" + sudo tar xf /tmp/vm.tar.gz -C $VM_DIR + sudo chmod +x $VM_DIR/victoria-metrics-prod + rm /tmp/vm.tar.gz + + sudo tee /etc/systemd/system/victoriametrics.service > /dev/null < /dev/null; then echo "📦 Installing Node.js..." @@ -28,7 +54,47 @@ else echo "✅ Node.js already installed, skipping..." fi -# ── Python venv ─────────────────────────────────────────────────────────────── +if [ ! -d "$SCRIPT_DIR/server/public/index.html" ]; then + echo "🌐 Building Client..." + cd "$SCRIPT_DIR/client" + npm ci + npx vite build +else + echo "✅ Client installed, skipping..." +fi + +if [ ! -d "$SCRIPT_DIR/server/node_modules" ]; then + echo "🌐 Installing Server..." + cd "$SCRIPT_DIR/server" + npm ci +else + echo "✅ Server installed, skipping..." +fi + +if ! systemctl is-enabled --quiet weather-station 2>/dev/null; then + echo "⚙️ Installing server service..." + sudo tee /etc/systemd/system/weather-station.service > /dev/null </dev/null; then - echo "⚙️ Installing systemd service..." + echo "⚙️ Installing sensor service..." sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <