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 @@ + + + + + + + + 🚢 + {{ name.toUpperCase() }} + / {{ boat.mmsi }} + + × + + + + + + {{ boat.operator || boat.owner || '' }} + {{ boat.country || 'Unknown Country' }} + + + {{ boat.ship_type || '' }} + {{ boat.type || 'Unknown' }} + + + + + + + + + Speed + + {{ speedVal }} + {{ speed.label }} + + + + Course + + {{ boat.course != null ? boat.course.toFixed(0) : '-' }} + ° + + + + Heading + + {{ boat.heading != null ? boat.heading.toFixed(0) : '-' }} + ° + + + + Distance + + {{ boat.distance?.toFixed(1) ?? '-' }} + NMI + + + + + + + + + + + + {{ label }} + {{ val ?? '-' }} + + + + + + + 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 27b92f0..0000000 Binary files a/server/public/favicon.png and /dev/null differ 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 <