Switched from influx to victoria metrics (more light weight for pi)
This commit is contained in:
1
.env
1
.env
@@ -1,4 +1,3 @@
|
|||||||
INFLUX_TOKEN=
|
|
||||||
ALTITUDE=
|
ALTITUDE=
|
||||||
LATITUDE=
|
LATITUDE=
|
||||||
LONGITUDE=
|
LONGITUDE=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch} from 'vue';
|
import {computed, nextTick, ref, watch} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {api} from '../services/api';
|
||||||
|
|
||||||
const props = defineProps<{metricKey: string | null}>();
|
const props = defineProps<{metricKey: string | null}>();
|
||||||
@@ -49,8 +49,9 @@ async function fetchData() {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([() => props.metricKey, mode], () => {
|
watch([() => props.metricKey, mode], async () => {
|
||||||
setDefaults();
|
setDefaults();
|
||||||
|
await nextTick();
|
||||||
fetchData();
|
fetchData();
|
||||||
}, {immediate: true});
|
}, {immediate: true});
|
||||||
|
|
||||||
@@ -201,9 +202,11 @@ svg { width: 100%; overflow: visible; cursor: crosshair; }
|
|||||||
<button class="close-btn" @click="emit('close')">✕</button>
|
<button class="close-btn" @click="emit('close')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="controls gap-0">
|
||||||
|
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</button>
|
||||||
|
<button :class="{active: mode === 'daily'}" style="border-radius: 0 6px 6px 0" @click="mode = 'daily'">Daily</button>
|
||||||
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly</button>
|
|
||||||
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily</button>
|
|
||||||
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
|
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
|
||||||
<span style="font-size:11px;color:var(--text-muted)">to</span>
|
<span style="font-size:11px;color:var(--text-muted)">to</span>
|
||||||
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
|
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
|
||||||
@@ -212,7 +215,6 @@ svg { width: 100%; overflow: visible; cursor: crosshair; }
|
|||||||
|
|
||||||
<div v-if="chart.dot" class="current-val">
|
<div v-if="chart.dot" class="current-val">
|
||||||
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
|
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
|
||||||
<span class="val-time">{{ new Date(chart.dot.t).toLocaleString() }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
|
|||||||
362
client/src/components/Ship.vue
Normal file
362
client/src/components/Ship.vue
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||||
|
}
|
||||||
|
const UNIT_KEY = 'ais_units'
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||||
|
}
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
function cycleSpeed() {
|
||||||
|
const keys = Object.keys(SPEED_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||||
|
}
|
||||||
|
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||||
|
|
||||||
|
// ── Derived data ──────────────────────────────────────────────────────────────
|
||||||
|
const name = computed(() => props.boat.name?.trim() || props.boat.callsign?.trim() || `MMSI: ${props.boat.mmsi}`)
|
||||||
|
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||||
|
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||||
|
const rows = computed(() => [
|
||||||
|
['MMSI', props.boat.mmsi],
|
||||||
|
['Country', props.boat.country],
|
||||||
|
['Callsign', props.boat.callsign || '-'],
|
||||||
|
['IMO', props.boat.imo || '-'],
|
||||||
|
['Ship Type', props.boat.ship_type ?? '-'],
|
||||||
|
['Lat / Lon', `${props.boat.lat?.toFixed(5)}, ${props.boat.lon?.toFixed(5)}`],
|
||||||
|
['Distance', props.boat.distance != null ? `${props.boat.distance.toFixed(2)} nmi` : '-'],
|
||||||
|
['Bearing', props.boat.bearing != null ? `${props.boat.bearing}°` : '-'],
|
||||||
|
['Destination', props.boat.destination || '-'],
|
||||||
|
['ETA', props.boat.eta || '-'],
|
||||||
|
['Draught', props.boat.draught != null ? `${props.boat.draught}m` : '-'],
|
||||||
|
['RSSI', props.boat.rssi != null ? `${props.boat.rssi.toFixed(1)} dB` : '-'],
|
||||||
|
['Channels', props.boat.channels || '-'],
|
||||||
|
['Last Signal', props.boat.last_signal != null ? `${props.boat.last_signal}s ago` : '-'],
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Compass HUD ───────────────────────────────────────────────────────────────
|
||||||
|
const compass = computed(() => {
|
||||||
|
const hdg = heading.value
|
||||||
|
const spd = props.boat.speed ?? 0
|
||||||
|
const course = props.boat.course ?? hdg
|
||||||
|
const bearing = props.boat.bearing ?? 0
|
||||||
|
|
||||||
|
// Compass tape: show ±60° window
|
||||||
|
const tapeTicks: string[] = []
|
||||||
|
for (let offset = -60; offset <= 60; offset += 5) {
|
||||||
|
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||||
|
const x = 100 + (offset / 60) * 90
|
||||||
|
const major = offset % 30 === 0
|
||||||
|
const mid = offset % 15 === 0
|
||||||
|
|
||||||
|
if (major) {
|
||||||
|
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||||
|
?? String(deg).padStart(3, '0')
|
||||||
|
tapeTicks.push(`
|
||||||
|
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
|
||||||
|
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
|
||||||
|
`)
|
||||||
|
} else if (mid) {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
|
||||||
|
} else {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth/rudder gauge (visual only, based on course vs heading difference)
|
||||||
|
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||||
|
const rudX = 100 + (rudder / 30) * 40
|
||||||
|
|
||||||
|
// Speed arc (0–30 kts range)
|
||||||
|
const spdPct = Math.min(spd / 30, 1)
|
||||||
|
const arcEnd = 160 + spdPct * 140 // degrees on a 160–300° arc
|
||||||
|
function polarToXY(cx: number, cy: number, r: number, deg: number) {
|
||||||
|
const rad = (deg - 90) * Math.PI / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||||
|
const s = polarToXY(cx, cy, r, startDeg)
|
||||||
|
const e = polarToXY(cx, cy, r, endDeg)
|
||||||
|
const lg = endDeg - startDeg > 180 ? 1 : 0
|
||||||
|
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${lg} 1 ${e.x} ${e.y}`
|
||||||
|
}
|
||||||
|
const spdArcPath = describeArc(100, 145, 52, 160, Math.max(161, arcEnd))
|
||||||
|
|
||||||
|
// Course indicator needle angle (relative to up = heading)
|
||||||
|
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||||
|
const courseRad = courseRel * Math.PI / 180
|
||||||
|
const cNeedleX = 100 + 44 * Math.sin(courseRad)
|
||||||
|
const cNeedleY = 145 - 44 * Math.cos(courseRad)
|
||||||
|
|
||||||
|
// Bearing indicator
|
||||||
|
const bearRel = ((bearing - hdg + 540) % 360) - 180
|
||||||
|
const bearRad = bearRel * Math.PI / 180
|
||||||
|
const bNeedleX = 100 + 52 * Math.sin(bearRad)
|
||||||
|
const bNeedleY = 145 - 52 * Math.cos(bearRad)
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="200" viewBox="0 0 200 200" style="background:#000;border-radius:4px">
|
||||||
|
<!-- Compass tape -->
|
||||||
|
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||||
|
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||||
|
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||||
|
${tapeTicks.join('')}
|
||||||
|
<!-- Centre lubber line -->
|
||||||
|
<polygon points="100,12 96,20 104,20" fill="#ff0"/>
|
||||||
|
|
||||||
|
<!-- Heading readout -->
|
||||||
|
<rect x="72" y="60" width="56" height="20" fill="rgba(0,0,0,0.9)" stroke="#0ff" stroke-width="1" rx="2"/>
|
||||||
|
<text x="100" y="75" text-anchor="middle" fill="#0ff" font-size="13" font-weight="bold">${String(Math.round(hdg)).padStart(3, '0')}°</text>
|
||||||
|
|
||||||
|
<!-- Rose ring -->
|
||||||
|
<circle cx="100" cy="145" r="55" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||||
|
<circle cx="100" cy="145" r="40" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Ring tick marks -->
|
||||||
|
${Array.from({ length: 36 }, (_, i) => {
|
||||||
|
const a = i * 10
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const r1 = a % 30 === 0 ? 44 : 50
|
||||||
|
const sw = a % 30 === 0 ? 2 : 1
|
||||||
|
const x1 = 100 + r1 * Math.cos(rad)
|
||||||
|
const y1 = 145 + r1 * Math.sin(rad)
|
||||||
|
const x2 = 100 + 55 * Math.cos(rad)
|
||||||
|
const y2 = 145 + 55 * Math.sin(rad)
|
||||||
|
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||||
|
}).join('')}
|
||||||
|
|
||||||
|
<!-- Cardinal labels -->
|
||||||
|
${(['N','E','S','W'] as const).map((lbl, i) => {
|
||||||
|
const a = i * 90
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const x = 100 + 47 * Math.cos(rad)
|
||||||
|
const y = 145 + 47 * Math.sin(rad) + 4
|
||||||
|
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="9" font-weight="bold" opacity="0.7">${lbl}</text>`
|
||||||
|
}).join('')}
|
||||||
|
|
||||||
|
<!-- Course needle (green) -->
|
||||||
|
<line x1="100" y1="145" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||||
|
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||||
|
|
||||||
|
<!-- Bearing needle (yellow dashed) -->
|
||||||
|
<line x1="100" y1="145" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||||
|
|
||||||
|
<!-- Ship symbol -->
|
||||||
|
<polygon points="100,138 96,152 100,149 104,152" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Speed arc -->
|
||||||
|
<path d="${spdArcPath}" fill="none" stroke="#0f0" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="${describeArc(100, 145, 52, 160, 300)}" fill="none" stroke="rgba(0,255,0,0.15)" stroke-width="4"/>
|
||||||
|
|
||||||
|
<!-- Speed readout -->
|
||||||
|
<text x="100" y="190" text-anchor="middle" fill="#0f0" font-size="11" font-weight="bold">${speedVal.value} ${speed.value.label}</text>
|
||||||
|
|
||||||
|
<!-- Rudder bar -->
|
||||||
|
<rect x="60" y="175" width="80" height="6" fill="rgba(0,40,60,0.9)" rx="3"/>
|
||||||
|
<line x1="100" y1="173" x2="100" y2="183" stroke="#0ff" stroke-width="1" opacity="0.5"/>
|
||||||
|
<rect x="${rudX - 4}" y="174" width="8" height="8" fill="#ff0" rx="1"/>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<line x1="12" y1="163" x2="24" y2="163" stroke="#0f0" stroke-width="2"/>
|
||||||
|
<text x="27" y="167" fill="#888" font-size="9">COG</text>
|
||||||
|
<line x1="12" y1="173" x2="24" y2="173" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<text x="27" y="177" fill="#888" font-size="9">BRG</text>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="sp-name">
|
||||||
|
🚢
|
||||||
|
<span>{{ name.toUpperCase() }}</span>
|
||||||
|
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||||
|
</h3>
|
||||||
|
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="sp-meta">
|
||||||
|
<div class="flex-c">
|
||||||
|
<span>{{ boat.operator || boat.owner || '' }}</span>
|
||||||
|
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c align-x-end">
|
||||||
|
<span>{{ boat.ship_type || '' }}</span>
|
||||||
|
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="sp-body">
|
||||||
|
<!-- Gauges -->
|
||||||
|
<div class="sp-gauges">
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Speed</div>
|
||||||
|
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||||
|
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||||
|
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Course</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '-' }}</span>
|
||||||
|
<span class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Heading</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '-' }}</span>
|
||||||
|
<span class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Distance</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ boat.distance?.toFixed(1) ?? '-' }}</span>
|
||||||
|
<span class="sp-gauge-unit">NMI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compass HUD -->
|
||||||
|
<div class="sp-compass" v-html="compass"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details table -->
|
||||||
|
<div class="sp-details">
|
||||||
|
<div v-for="[label, val] in rows" :key="label" class="sp-detail-row">
|
||||||
|
<span class="sp-detail-label">{{ label }}</span>
|
||||||
|
<span class="sp-detail-val">{{ val ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ship-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,10,20,0.92);
|
||||||
|
border: 1px solid rgba(0,200,255,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(0,200,255,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
background: rgba(0,20,40,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-name {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.sp-mmsi { color: white; }
|
||||||
|
|
||||||
|
.sp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid rgba(0,200,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.sp-close:hover { background: rgba(255,255,255,0.25); }
|
||||||
|
|
||||||
|
.sp-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(0,200,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sp-gauge-wrap {}
|
||||||
|
|
||||||
|
.sp-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauge {
|
||||||
|
padding: 5px 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sp-gauge:hover { border-color: #0f0; }
|
||||||
|
.sp-gauge-val { font-size: 18px; font-weight: bold; color: #0ff; }
|
||||||
|
.sp-gauge-unit { font-size: 10px; color: #0ff; margin-bottom: 2px; }
|
||||||
|
|
||||||
|
.sp-compass { flex: 1; }
|
||||||
|
|
||||||
|
.sp-details {
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 5px 16px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sp-detail-row { display: flex; flex-direction: column; }
|
||||||
|
.sp-detail-label { color: #888; font-size: 9px; font-weight: bold; }
|
||||||
|
.sp-detail-val { color: #0ff; font-size: 12px; }
|
||||||
|
</style>
|
||||||
@@ -45,7 +45,7 @@ const arc = computed(() => {
|
|||||||
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
|
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
|
||||||
|
|
||||||
const traveledPts: any = pts.slice(0, splitIdx + 1);
|
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${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}`
|
||||||
+ ` L${x1},${horizonY} Z`;
|
+ ` L${x1},${horizonY} Z`;
|
||||||
|
|
||||||
|
|||||||
@@ -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 Map from 'ol/Map'
|
||||||
import { Feature } from 'ol'
|
import { Feature } from 'ol'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
import { fromLonLat } from 'ol/proj'
|
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 VectorLayer } from 'ol/layer'
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import { Vector as VectorSource } from 'ol/source'
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import { adjustedInterval } from '@ztimson/utils'
|
||||||
|
import ShipPopup from '@/components/Ship.vue'
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api'
|
||||||
|
|
||||||
|
let highestZIndex = 1000
|
||||||
|
|
||||||
const SHIP_COLORS: Record<string, string> = {
|
const SHIP_COLORS: Record<string, string> = {
|
||||||
'Base Station': '#00aaff',
|
'Base Station': '#00aaff',
|
||||||
'Class A': '#00ff00',
|
'Class A': '#00ff00',
|
||||||
'Class B': '#8450ea',
|
'Class B': '#8450ea',
|
||||||
'SAR Aircraft': '#ff6600',
|
'SAR Aircraft': '#ff0000',
|
||||||
'Aid to Navigation': '#ffff00',
|
'AtoN': '#00aaff',
|
||||||
'Class B CS': '#8450ea',
|
'Class B/CS': '#8450ea',
|
||||||
'Sart/Epirb/MOB': '#ff0000',
|
'Sart/Epirb/MOB': '#00aaff',
|
||||||
'Unknown': '#fad106',
|
'Unknown': '#fad106',
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBoatIcon(boat: any): string {
|
function buildBoatIcon(boat: any): string {
|
||||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
||||||
|
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||||
if (boat.type === 'Base Station') {
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||||
@@ -35,10 +38,9 @@ function buildBoatIcon(boat: any): string {
|
|||||||
</svg>
|
</svg>
|
||||||
`)}`
|
`)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||||
<polygon points="12,0 24,28 12,22 0,28" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||||
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
||||||
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
||||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||||
@@ -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 `
|
|
||||||
<div style="font-family:monospace;color:#ccc;min-width:280px">
|
|
||||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
|
||||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">🚢 ${name}</h3>
|
|
||||||
<div style="font-size:11px;color:#888;margin-top:4px">${boat.type || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:12px 16px;display:grid;grid-template-columns:1fr 1fr;gap:6px 16px">
|
|
||||||
${rows.map(([label, val]) => `
|
|
||||||
<div>
|
|
||||||
<div style="color:#888;font-size:10px;font-weight:bold">${label}</div>
|
|
||||||
<div style="color:#0f0;font-size:13px">${val ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AISLayer {
|
export class AISLayer {
|
||||||
private map: Map
|
private map: Map
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>
|
||||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
private popups: any = {}
|
||||||
private data: any[] = []
|
private data: any[] = []
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null
|
||||||
visible = false
|
visible = false
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) { this.map = map }
|
||||||
|
|
||||||
// ── Public ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return
|
||||||
this.visible = true
|
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)
|
this.map.addLayer(this.layer)
|
||||||
|
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._attachClick()
|
this._attachClick()
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
@@ -128,16 +78,12 @@ export class AISLayer {
|
|||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
this.visible = false
|
this.visible = false
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||||
|
|
||||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
||||||
this.map.removeLayer(this.layer)
|
this.map.removeLayer(this.layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
||||||
}
|
}
|
||||||
@@ -145,17 +91,16 @@ export class AISLayer {
|
|||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!
|
||||||
source.clear()
|
source.clear()
|
||||||
|
|
||||||
for (const boat of this.data) {
|
for (const boat of this.data) {
|
||||||
if (boat.lat == null || boat.lon == null) continue
|
if (boat.lat == null || boat.lon == null) continue
|
||||||
const coord = fromLonLat([boat.lon, boat.lat])
|
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('mmsi', boat.mmsi)
|
||||||
f.set('boatData', boat)
|
f.set('boatData', boat)
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: buildBoatIcon(boat),
|
src: buildBoatIcon(boat),
|
||||||
scale: 1,
|
scale: 0.8,
|
||||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
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) {
|
private _openPopup(boat: any) {
|
||||||
const mmsi = String(boat.mmsi)
|
const mmsi = String(boat.mmsi)
|
||||||
if (this.popups[mmsi]) return
|
if (this.popups[mmsi]) return
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const boatRef = ref(boat)
|
||||||
el.style.cssText = `
|
const posRef = ref(this._calcPopupPos(boat))
|
||||||
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 close = document.createElement('button')
|
const container = document.createElement('div')
|
||||||
close.innerHTML = '✕'
|
document.body.appendChild(container)
|
||||||
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.innerHTML = buildPopupHtml(boat)
|
const app = createApp(ShipPopup, {
|
||||||
el.appendChild(close)
|
boat: boatRef.value,
|
||||||
document.body.appendChild(el)
|
position: posRef.value,
|
||||||
this._positionPopup(el, boat)
|
onClose: () => this._closePopup(mmsi),
|
||||||
|
onBringToFront: () => {
|
||||||
|
highestZIndex++
|
||||||
|
container.style.zIndex = String(highestZIndex)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.popups[mmsi] = { el, destroy: () => el.remove() }
|
app.mount(container)
|
||||||
}
|
this.popups[mmsi] = {
|
||||||
|
boatRef,
|
||||||
private _positionPopup(el: HTMLDivElement, boat: any) {
|
posRef,
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
unmount: () => { app.unmount(); container.remove() },
|
||||||
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`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(mmsi: string) {
|
private _closePopup(mmsi: string) {
|
||||||
const popup = this.popups[mmsi]
|
const popup = this.popups[mmsi]
|
||||||
if (popup) { popup.destroy(); delete this.popups[mmsi] }
|
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const mmsi of Object.keys(this.popups)) {
|
for (const mmsi of Object.keys(this.popups)) {
|
||||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||||
if (!boat) { this._closePopup(mmsi); continue }
|
if (!boat) { this._closePopup(mmsi); continue }
|
||||||
const { el } = <any>this.popups[mmsi]
|
this.popups[mmsi].boatRef.value = boat
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
|
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 datetime import datetime, timezone
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from influxdb_client import InfluxDBClient, Point
|
|
||||||
from influxdb_client.client.write_api import SYNCHRONOUS
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -16,9 +14,7 @@ def cfg():
|
|||||||
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
||||||
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
||||||
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
||||||
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
|
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8428'),
|
||||||
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
|
|
||||||
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
|
|
||||||
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
|
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
|
||||||
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
||||||
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
||||||
@@ -49,31 +45,17 @@ def cfg():
|
|||||||
'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')),
|
'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_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
|
|
||||||
|
|
||||||
def write(c, measurement, fields, tags={}):
|
def write(c, measurement, fields, tags={}):
|
||||||
try:
|
tag_str = ',' + ','.join(f"{k}={v}" for k, v in tags.items() if v) if tags else ''
|
||||||
p = Point(measurement).time(datetime.now(timezone.utc))
|
field_str = ','.join(
|
||||||
for k, v in tags.items(): p = p.tag(k, v)
|
f"{k}={'true' if v is True else 'false' if v is False else v}"
|
||||||
for k, v in fields.items():
|
for k, v in fields.items() if v is not None
|
||||||
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)
|
if not field_str: return
|
||||||
except Exception as e:
|
line = f"{measurement}{tag_str} {field_str}"
|
||||||
print(f"[InfluxDB] {measurement}: {e}")
|
requests.post(f"{c['INFLUX_URL']}/write", data=line, params={'db': c['INFLUX_BUCKET']}, timeout=2)
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -325,7 +307,6 @@ def get_expected_lux(c, solar_el):
|
|||||||
return round(baseline, 2)
|
return round(baseline, 2)
|
||||||
|
|
||||||
def classify_clouds(c, lux, solar_el):
|
def classify_clouds(c, lux, solar_el):
|
||||||
# Resize window if config changed
|
|
||||||
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
|
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
|
||||||
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
|
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
|
||||||
_cloud_lux_window.append(lux)
|
_cloud_lux_window.append(lux)
|
||||||
@@ -345,11 +326,11 @@ def classify_clouds(c, lux, solar_el):
|
|||||||
|
|
||||||
vt = c['CLOUD_VOLATILITY_THRESH']
|
vt = c['CLOUD_VOLATILITY_THRESH']
|
||||||
if volatility is not None and volatility > vt and c['CLOUD_CLEAR_MAX'] < cloud_pct < c['CLOUD_CLOUDY_MAX']:
|
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']:
|
elif cloud_pct < c['CLOUD_CLEAR_MAX']:
|
||||||
label = 'Clear'
|
label = 'Clear'
|
||||||
elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']:
|
elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']:
|
||||||
label = 'Mostly Clear'
|
label = 'Mostly_Clear'
|
||||||
elif cloud_pct < c['CLOUD_CLOUDY_MAX']:
|
elif cloud_pct < c['CLOUD_CLOUDY_MAX']:
|
||||||
label = 'Cloudy'
|
label = 'Cloudy'
|
||||||
else:
|
else:
|
||||||
@@ -370,7 +351,7 @@ def uv_index_label(uvi):
|
|||||||
if uvi < 3: return 'Low'
|
if uvi < 3: return 'Low'
|
||||||
elif uvi < 6: return 'Moderate'
|
elif uvi < 6: return 'Moderate'
|
||||||
elif uvi < 8: return 'High'
|
elif uvi < 8: return 'High'
|
||||||
elif uvi < 11: return 'Very High'
|
elif uvi < 11: return 'Very_High'
|
||||||
else: return 'Extreme'
|
else: return 'Extreme'
|
||||||
|
|
||||||
def read_ltr(c, sensor, solar_el, humidity=None):
|
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,
|
'daylight': daylight_hours,
|
||||||
'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None,
|
'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None,
|
||||||
}, tags={
|
}, tags={
|
||||||
'clouds_label': cloud_label or '',
|
'clouds_label': cloud_label or '',
|
||||||
'uv_index_label': uv_index_label(uvi),
|
'uv_index_label': uv_index_label(uvi),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -572,18 +553,12 @@ def solar_elevation(lat, lon):
|
|||||||
# ── Rain ──────────────────────────────────────────────────────────────────────
|
# ── Rain ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_rain(c):
|
def read_rain(c):
|
||||||
try:
|
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
|
||||||
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Rain] {e}")
|
|
||||||
|
|
||||||
# ── Wind ──────────────────────────────────────────────────────────────────────
|
# ── Wind ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_wind(c):
|
def read_wind(c):
|
||||||
try:
|
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
||||||
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Wind] {e}")
|
|
||||||
|
|
||||||
# ── System ────────────────────────────────────────────────────────────────────
|
# ── System ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
influxdb-client
|
requests
|
||||||
python-dotenv
|
python-dotenv
|
||||||
smbus2
|
smbus2
|
||||||
adafruit-circuitpython-ltr390
|
adafruit-circuitpython-ltr390
|
||||||
|
|||||||
62
server/package-lock.json
generated
62
server/package-lock.json
generated
@@ -8,7 +8,6 @@
|
|||||||
"name": "weather-station",
|
"name": "weather-station",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
|
||||||
"@scalar/express-api-reference": "^0.10.4",
|
"@scalar/express-api-reference": "^0.10.4",
|
||||||
"@ztimson/utils": "^0.29.5",
|
"@ztimson/utils": "^0.29.5",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
@@ -18,20 +17,14 @@
|
|||||||
"yaml": "^2.9.0"
|
"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": {
|
"node_modules/@scalar/client-side-rendering": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
|
||||||
"integrity": "sha512-IQC39aQQXKsZXOqQw0LNiZ/gQ1WxHpF+WLTo/hurn0S/FmNHlBGUAhFyt1BeDSUYxics7ixgzQatKJDggafBTw==",
|
"integrity": "sha512-wvnyBtNdYob6CdmpFqLSQoGM5q5Ra8bXyq713T/u8Nq1NXbULk1znz/LyR78u8g++0G8bkxnVzhvOf7s9FwHFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/schemas": "0.5.0",
|
"@scalar/schemas": "0.7.0",
|
||||||
"@scalar/types": "0.14.0",
|
"@scalar/types": "0.16.0",
|
||||||
"@scalar/validation": "0.6.0"
|
"@scalar/validation": "0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -39,33 +32,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/express-api-reference": {
|
"node_modules/@scalar/express-api-reference": {
|
||||||
"version": "0.10.4",
|
"version": "0.10.6",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.6.tgz",
|
||||||
"integrity": "sha512-/FkuP+B9ppWiQbf0Bhygx8CwoLXiEGFgS7OK14FM/JH0SeAaGfQiacUgPEpxJYI2qnqFHkrys8YH2jl400wDfg==",
|
"integrity": "sha512-5gXV4Cv6EA8/hUo1e3ZMHKo5w5D45QTCzsA2Qvrf4OEe141iKdtSErYYxaZ/c2RIVidRUohDr3WC2HN+HRDosw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/client-side-rendering": "0.2.4"
|
"@scalar/client-side-rendering": "0.2.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/helpers": {
|
"node_modules/@scalar/helpers": {
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.0.tgz",
|
||||||
"integrity": "sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==",
|
"integrity": "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/schemas": {
|
"node_modules/@scalar/schemas": {
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.0.tgz",
|
||||||
"integrity": "sha512-aV0PWqFpQft9a6d4Z6HOTHKDaA+K4AquxSPfBDI1c5f6lMe5l3zViww/iBQA/Yw+V1+Y2iwSVzhuxaktC1UiMg==",
|
"integrity": "sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/helpers": "0.8.2",
|
"@scalar/helpers": "0.9.0",
|
||||||
"@scalar/validation": "0.6.0"
|
"@scalar/validation": "0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -73,12 +66,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/types": {
|
"node_modules/@scalar/types": {
|
||||||
"version": "0.14.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.0.tgz",
|
||||||
"integrity": "sha512-aS1FvHbKhBC51mWqXmhkPe6lmhCd9+u//DQ1YTiCWOz4/fXAdWfwHvW9UZ7AzzcGwGiCo9diE81xV+UvSI1qIg==",
|
"integrity": "sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/helpers": "0.8.2",
|
"@scalar/helpers": "0.9.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"type-fest": "^5.3.1",
|
"type-fest": "^5.3.1",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
@@ -838,9 +831,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "5.1.15",
|
"version": "5.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||||
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
|
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -981,12 +974,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"start": "node src/server.mjs"
|
"start": "node src/server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
|
||||||
"@scalar/express-api-reference": "^0.10.4",
|
"@scalar/express-api-reference": "^0.10.4",
|
||||||
"@ztimson/utils": "^0.29.5",
|
"@ztimson/utils": "^0.29.5",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
@@ -1,152 +1,107 @@
|
|||||||
import {InfluxDB} from '@influxdata/influxdb-client';
|
|
||||||
import {cfg} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
|
|
||||||
const MEASUREMENTS = ['accumulation', 'environment', 'light', 'lightning', 'rain', 'seismic', 'wind'];
|
const MEASUREMENTS = ['accumulation', 'environment', 'light', 'lightning', 'rain', 'seismic', 'wind'];
|
||||||
|
|
||||||
let client, currentUrl, currentToken;
|
async function query(c, q) {
|
||||||
|
const url = new URL('/api/v1/query_range', c.INFLUX_URL);
|
||||||
function getClient() {
|
url.searchParams.set('query', q);
|
||||||
const c = cfg();
|
url.searchParams.set('step', '60s');
|
||||||
if(c.INFLUX_URL !== currentUrl || c.INFLUX_TOKEN !== currentToken) {
|
const res = await fetch(url);
|
||||||
client = new InfluxDB({url: c.INFLUX_URL, token: c.INFLUX_TOKEN});
|
const json = await res.json();
|
||||||
currentUrl = c.INFLUX_URL;
|
return json.data?.result || [];
|
||||||
currentToken = c.INFLUX_TOKEN;
|
|
||||||
}
|
|
||||||
return {client, c};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function query(flux) {
|
async function queryInstant(c, q) {
|
||||||
const {client, c} = getClient();
|
const url = new URL('/api/v1/query', c.INFLUX_URL);
|
||||||
const api = client.getQueryApi(c.INFLUX_ORG);
|
url.searchParams.set('query', q);
|
||||||
const rows = [];
|
const res = await fetch(url);
|
||||||
return new Promise((resolve, reject) => {
|
const json = await res.json();
|
||||||
api.queryRows(flux, {
|
return json.data?.result || [];
|
||||||
next(row, meta) { rows.push(meta.toObject(row)); },
|
|
||||||
error(err) { reject(err); },
|
|
||||||
complete() { resolve(rows); },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateToHour(iso) {
|
function metricToFields(results) {
|
||||||
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) {
|
|
||||||
const out = {};
|
const out = {};
|
||||||
for(const [k, v] of Object.entries(row)) {
|
for (const r of results) {
|
||||||
if(!k.startsWith('_') && k !== 'result' && k !== 'table') out[k] = v;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryCurrent() {
|
export async function queryCurrent() {
|
||||||
const {c} = getClient();
|
const c = cfg();
|
||||||
const flux = `
|
const now = Math.floor(Date.now() / 1000);
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
const out = {time: new Date()};
|
||||||
|> range(start: -1h)
|
|
||||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
for (const m of MEASUREMENTS) {
|
||||||
|> last()
|
const results = await queryInstant(c, `{measurement="${m}"}`);
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
Object.assign(out, metricToFields(results));
|
||||||
`;
|
|
||||||
const rows = await query(flux);
|
|
||||||
const result = {time: new Date()};
|
|
||||||
for(const row of rows) {
|
|
||||||
Object.assign(result, extractRow(row));
|
|
||||||
}
|
}
|
||||||
return result;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryHourly(start, end) {
|
export async function queryHourly(start, end) {
|
||||||
const {c} = getClient();
|
const c = cfg();
|
||||||
const flux = `
|
const s = Math.floor(start.getTime() / 1000);
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
const e = Math.floor(end.getTime() / 1000);
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
const byTime = {};
|
||||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
|
||||||
|> group(columns: ["_field"])
|
for (const m of MEASUREMENTS) {
|
||||||
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
const results = await query(c, `avg_over_time({measurement="${m}"}[1h])`);
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
results.forEach(r => {
|
||||||
`;
|
const field = r.metric.__name__;
|
||||||
const rows = await query(flux);
|
r.values.forEach(([ts, val]) => {
|
||||||
return rows.map(row => ({
|
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
|
||||||
time: truncateToHour(row._time),
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
...extractRow(row),
|
byTime[time][field] = parseFloat(val);
|
||||||
}));
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryDaily(start, end) {
|
export async function queryDaily(start, end) {
|
||||||
const {c} = getClient();
|
const c = cfg();
|
||||||
const measurements = ['accumulation', 'environment', 'light', 'lightning', 'seismic'];
|
const byTime = {};
|
||||||
const results = {};
|
|
||||||
|
|
||||||
for(const m of measurements) {
|
for (const m of ['accumulation', 'environment', 'light', 'lightning', 'seismic']) {
|
||||||
const [means, mins, maxs] = await Promise.all([
|
const [means, mins, maxs] = await Promise.all([
|
||||||
query(`
|
query(c, `avg_over_time({measurement="${m}"}[1d])`),
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
query(c, `min_over_time({measurement="${m}"}[1d])`),
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
query(c, `max_over_time({measurement="${m}"}[1d])`),
|
||||||
|> 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")
|
|
||||||
`),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for(const row of means) {
|
means.forEach(r => {
|
||||||
const time = truncateToDay(row._time);
|
r.values.forEach(([ts, val]) => {
|
||||||
if(!results[time]) results[time] = {time};
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
Object.assign(results[time], extractRow(row));
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
}
|
byTime[time][r.metric.__name__] = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for(const row of mins) {
|
const applyMinMax = (results, suffix) => results.forEach(r => {
|
||||||
const time = truncateToDay(row._time);
|
if (r.metric.__name__ !== 'temperature') return;
|
||||||
if(!results[time]) results[time] = {time};
|
r.values.forEach(([ts, val]) => {
|
||||||
if(row.temperature != null) results[time].env_temp_min_c = row.temperature;
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
}
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
for(const row of maxs) {
|
byTime[time][suffix] = parseFloat(val);
|
||||||
const time = truncateToDay(row._time);
|
});
|
||||||
if(!results[time]) results[time] = {time};
|
});
|
||||||
if(row.temperature != null) results[time].env_temp_max_c = row.temperature;
|
|
||||||
}
|
applyMinMax(mins, 'env_temp_min_c');
|
||||||
|
applyMinMax(maxs, 'env_temp_max_c');
|
||||||
}
|
}
|
||||||
|
return Object.values(byTime).sort((a, b) => a.time.localeCompare(b.time));
|
||||||
return Object.values(results).sort((a, b) => a.time.localeCompare(b.time));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCoords() {
|
export async function getCoords() {
|
||||||
const {c} = getClient();
|
const c = cfg();
|
||||||
const flux = `
|
const results = await queryInstant(c, `{measurement="gps"}`);
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
const fields = metricToFields(results);
|
||||||
|> range(start: -24h)
|
|
||||||
|> filter(fn: (r) => r._measurement == "gps")
|
if (fields.latitude && fields.longitude) {
|
||||||
|> last()
|
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||||
|> 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};
|
|
||||||
}
|
}
|
||||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||||
}
|
}
|
||||||
|
|||||||
97
setup.sh
97
setup.sh
@@ -3,22 +3,48 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
USER_NAME="$(whoami)"
|
USER_NAME="$(whoami)"
|
||||||
|
VM_VERSION="1.99.0"
|
||||||
|
VM_DIR="/opt/victoriametrics"
|
||||||
|
|
||||||
echo "🔧 Setting up Weather Station..."
|
echo "🔧 Setting up Weather Station..."
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v influxd &> /dev/null; then
|
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||||
echo "📦 Installing InfluxDB..."
|
echo "📦 Installing VictoriaMetrics..."
|
||||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
sudo mkdir -p $VM_DIR/data
|
||||||
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
|
wget -qO /tmp/vm.tar.gz \
|
||||||
sudo systemctl enable --now influxdb
|
"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 <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=VictoriaMetrics
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=$VM_DIR/victoria-metrics-prod \
|
||||||
|
-storageDataPath=$VM_DIR/data \
|
||||||
|
-httpListenAddr=:8428 \
|
||||||
|
-retentionPeriod=12
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now victoriametrics
|
||||||
else
|
else
|
||||||
echo "✅ InfluxDB already installed, skipping..."
|
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "📦 Installing Node.js..."
|
echo "📦 Installing Node.js..."
|
||||||
@@ -28,7 +54,47 @@ else
|
|||||||
echo "✅ Node.js already installed, skipping..."
|
echo "✅ Node.js already installed, skipping..."
|
||||||
fi
|
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 <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Weather Station API
|
||||||
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=$SCRIPT_DIR/server
|
||||||
|
ExecStart=npm run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=$USER_NAME
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now weather-sensors
|
||||||
|
else
|
||||||
|
echo "✅ Server service already installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Sensors / Python venv ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
||||||
echo "🐍 Setting up Python environment..."
|
echo "🐍 Setting up Python environment..."
|
||||||
@@ -40,14 +106,12 @@ else
|
|||||||
echo "✅ Python environment already set up, skipping..."
|
echo "✅ Python environment already set up, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
||||||
echo "⚙️ Installing systemd service..."
|
echo "⚙️ Installing sensor service..."
|
||||||
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Weather Station Sensors
|
Description=Weather Station Sensors
|
||||||
After=network.target
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||||
@@ -62,10 +126,9 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now weather-sensors
|
sudo systemctl enable --now weather-sensors
|
||||||
else
|
else
|
||||||
echo "✅ Systemd service already installed, skipping..."
|
echo "✅ Sensor service already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ All done!"
|
echo "✅ All done!"
|
||||||
echo " InfluxDB UI: http://localhost:8086"
|
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user