From fae128bc1bbb12c47416eefe394850246391d04d Mon Sep 17 00:00:00 2001 From: David Senoner Date: Thu, 12 Jun 2025 21:08:08 +0200 Subject: [PATCH 1/4] first step to show graph of sensors --- package.json | 2 + pnpm-lock.yaml | 6 + src/lib/server/db/schema.js | 1 + src/routes/(app)/[slug]/+page.svelte | 201 +++++++++++++++++++++++ src/routes/(app)/[slug]/stats/+server.ts | 13 ++ src/routes/mqtt/+server.js | 2 +- 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/routes/(app)/[slug]/stats/+server.ts diff --git a/package.json b/package.json index 75383b6..fb9ac4e 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,8 @@ "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "drizzle-orm": "^0.43.1", "mqtt": "^5.13.1", "postgres": "^3.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7918f60..c8adcf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@oslojs/encoding': specifier: ^1.1.0 version: 1.1.0 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 drizzle-orm: specifier: ^0.43.1 version: 0.43.1(postgres@3.4.5) diff --git a/src/lib/server/db/schema.js b/src/lib/server/db/schema.js index 0ae02ed..9e7680e 100644 --- a/src/lib/server/db/schema.js +++ b/src/lib/server/db/schema.js @@ -42,4 +42,5 @@ export const sensorData = pgTable("sensor_data", { humidity: real().notNull(), pressure: real().notNull(), altitude: real().notNull(), + time: timestamp().notNull().defaultNow(), }); diff --git a/src/routes/(app)/[slug]/+page.svelte b/src/routes/(app)/[slug]/+page.svelte index 3aa8411..e31f282 100644 --- a/src/routes/(app)/[slug]/+page.svelte +++ b/src/routes/(app)/[slug]/+page.svelte @@ -1,6 +1,10 @@
+

Floor {data.slug}

{#if data.hasConfig && data.floorConfig} @@ -209,4 +287,127 @@
{/if} + + Statistics + + + + + {#snippet child({ props })} + + {/snippet} + + + {#each table as column} + (column.visible = !value)} + > + {column.id} + + {/each} + + +
+ Data for floor {data.slug} + Showing total data for the last 3 months +
+ + + {selectedLabel} + + + Last 3 months + Last 30 days + Last 7 days + + +
+ + + { + return v.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }, + }, + + yAxis: { format: () => "" }, + }} + > + {#snippet marks({ series, getAreaProps })} + + + + + + + + + + + + {#each series as s, i (s.key)} + + {/each} + + {/snippet} + {#snippet tooltip()} + { + return v.toLocaleDateString("en-US", { + month: "long", + }); + }} + indicator="line" + /> + {/snippet} + + + + +
+
diff --git a/src/routes/(app)/[slug]/stats/+server.ts b/src/routes/(app)/[slug]/stats/+server.ts new file mode 100644 index 0000000..6356340 --- /dev/null +++ b/src/routes/(app)/[slug]/stats/+server.ts @@ -0,0 +1,13 @@ +import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema" + +export const GET = async () => { + const data = await db.select({ sensor: table.sensorData.sensor }).from(table.sensorData); + console.log(data); + + return new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json" + } + }); +} diff --git a/src/routes/mqtt/+server.js b/src/routes/mqtt/+server.js index d705f2a..be1d000 100644 --- a/src/routes/mqtt/+server.js +++ b/src/routes/mqtt/+server.js @@ -1,6 +1,6 @@ // src/routes/mqtt/+server.js import { connectedDevices, deviceSensorData, getCurrentDevices } from "$lib/server/mqtt-devices.js"; -import * as mqtt from "mqtt"; +import mqtt from "mqtt"; import { writable } from "svelte/store"; // A Svelte store to hold the latest MQTT message. From e29c6db90800968fbcacf62507da0234f6275424 Mon Sep 17 00:00:00 2001 From: David Senoner Date: Thu, 12 Jun 2025 22:46:07 +0200 Subject: [PATCH 2/4] save data if sensor is on some floor --- src/lib/server/db/schema.js | 4 ++-- src/routes/(app)/[slug]/+page.svelte | 15 ++++----------- src/routes/(app)/[slug]/stats/+server.ts | 2 +- src/routes/(app)/settings/+page.server.js | 3 +++ src/routes/mqtt/+server.js | 16 +++++++++++++++- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/lib/server/db/schema.js b/src/lib/server/db/schema.js index 9e7680e..e048caa 100644 --- a/src/lib/server/db/schema.js +++ b/src/lib/server/db/schema.js @@ -34,7 +34,7 @@ export const sensors = pgTable("sensors", { }); export const sensorData = pgTable("sensor_data", { - uuid: uuid().primaryKey(), + uuid: uuid().primaryKey().defaultRandom(), sensor: text("sensor") .references(() => sensors.id) .notNull(), @@ -42,5 +42,5 @@ export const sensorData = pgTable("sensor_data", { humidity: real().notNull(), pressure: real().notNull(), altitude: real().notNull(), - time: timestamp().notNull().defaultNow(), + time: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(), }); diff --git a/src/routes/(app)/[slug]/+page.svelte b/src/routes/(app)/[slug]/+page.svelte index e31f282..5be7cd3 100644 --- a/src/routes/(app)/[slug]/+page.svelte +++ b/src/routes/(app)/[slug]/+page.svelte @@ -136,16 +136,7 @@ import { cubicInOut } from "svelte/easing"; import { ChevronDownIcon, Columns2 } from "@lucide/svelte"; - let chartData = $state([ - { date: new Date("2024-04-01"), desktop: 222, mobile: 150 }, - { date: new Date("2024-04-02"), desktop: 97, mobile: 180 }, - { date: new Date("2024-04-03"), desktop: 167, mobile: 120 }, - { date: new Date("2024-04-04"), desktop: 242, mobile: 260 }, - { date: new Date("2024-06-27"), desktop: 448, mobile: 490 }, - { date: new Date("2024-06-28"), desktop: 149, mobile: 200 }, - { date: new Date("2024-06-29"), desktop: 103, mobile: 160 }, - { date: new Date("2024-06-30"), desktop: 446, mobile: 400 }, - ]); + let chartData = $state([]); let timeRange = $state("90d"); @@ -194,7 +185,9 @@ const number = await response.json(); console.log(number); - chartData = []; + chartData = number.map((obj) => { + return { date: new Date(obj.date), desktop: obj.temp, mobile: obj.pressure }; + }); } }); diff --git a/src/routes/(app)/[slug]/stats/+server.ts b/src/routes/(app)/[slug]/stats/+server.ts index 6356340..a719178 100644 --- a/src/routes/(app)/[slug]/stats/+server.ts +++ b/src/routes/(app)/[slug]/stats/+server.ts @@ -2,7 +2,7 @@ import { db } from "$lib/server/db"; import * as table from "$lib/server/db/schema" export const GET = async () => { - const data = await db.select({ sensor: table.sensorData.sensor }).from(table.sensorData); + const data = await db.select({ date: table.sensorData.time, temp: table.sensorData.temperature, pressure: table.sensorData.pressure }).from(table.sensorData); console.log(data); return new Response(JSON.stringify(data), { diff --git a/src/routes/(app)/settings/+page.server.js b/src/routes/(app)/settings/+page.server.js index e7bdcb0..1939028 100644 --- a/src/routes/(app)/settings/+page.server.js +++ b/src/routes/(app)/settings/+page.server.js @@ -81,6 +81,9 @@ export const actions = { try { const deviceData = JSON.parse(devices); + deviceData.forEach(async (dev) => { + await db.insert(table.sensors).values({ id: dev.id, user: event.locals.session.userId }); + }); // Check if floor exists const exists = await db diff --git a/src/routes/mqtt/+server.js b/src/routes/mqtt/+server.js index be1d000..75ae0aa 100644 --- a/src/routes/mqtt/+server.js +++ b/src/routes/mqtt/+server.js @@ -1,5 +1,8 @@ // src/routes/mqtt/+server.js +import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema"; import { connectedDevices, deviceSensorData, getCurrentDevices } from "$lib/server/mqtt-devices.js"; +import { eq } from "drizzle-orm"; import mqtt from "mqtt"; import { writable } from "svelte/store"; @@ -114,7 +117,7 @@ function connectMqtt() { }); }); - client.on("message", (topic, message) => { + client.on("message", async (topic, message) => { const payload = message.toString(); console.log(`Received message from topic "${topic}": ${payload}`); latestMessage.set(payload); // Update the Svelte store @@ -130,6 +133,17 @@ function connectMqtt() { if (sensorData) { console.log(`Parsed sensor data:`, sensorData); updateDevice(deviceId, sensorData); + const devices = await db.select().from(table.sensors).where(eq(table.sensors.id, deviceId)); + if (devices.length == 1) + await db + .insert(table.sensorData) + .values({ + sensor: deviceId, + temperature: sensorData.temperature, + humidity: sensorData.humidity, + altitude: sensorData.altitude, + pressure: sensorData.pressure, + }); } else { // Still update device as online even if data parsing failed updateDevice(deviceId); From 1841746f6a40abe70dfe908dd6357c1fc2af258b Mon Sep 17 00:00:00 2001 From: David Senoner Date: Fri, 13 Jun 2025 09:04:05 +0200 Subject: [PATCH 3/4] show all info on graph --- src/routes/(app)/[slug]/+page.svelte | 96 +++++++++++++++--------- src/routes/(app)/[slug]/stats/+server.ts | 2 +- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/routes/(app)/[slug]/+page.svelte b/src/routes/(app)/[slug]/+page.svelte index 5be7cd3..63b3cbe 100644 --- a/src/routes/(app)/[slug]/+page.svelte +++ b/src/routes/(app)/[slug]/+page.svelte @@ -1,6 +1,6 @@ @@ -316,7 +335,7 @@ {/if} Statistics - +
Statistics for floor {data.slug} @@ -326,27 +345,29 @@
- + {#snippet child({ props })} {/snippet} - {#each table as column} - - {column.key} - - {/each} + + {#each sensorOptions as sensor} + + {sensor.label} + + {/each} + - + {selectedLabel} @@ -362,71 +383,215 @@
- - { - return v.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - }, - }, + {#if isLoading} +
+
+
+

Loading statistics...

+
+
+ {:else if hasError} +
+
+

Error loading statistics

+

Please try again later

+
+
+ {:else if chartData.length === 0} +
+
+

No data available

+

+ Statistics will appear once sensor data is collected +

+
+
+ {:else} + + + {selectedSensorConfig.label} + + +
+ {#if filteredData.length > 0} + +
+
+
Latest
+
+ {filteredData[filteredData.length - 1]?.[selectedSensor]?.toFixed(1)} + {#if selectedSensor === "temperature"}°C + {:else if selectedSensor === "humidity"}% + {:else if selectedSensor === "pressure"}kPa + {:else if selectedSensor === "altitude"}m + {/if} +
+
+
+
Average
+
+ {( + filteredData.reduce((sum, item) => sum + item[selectedSensor], 0) / + filteredData.length + ).toFixed(1)} + {#if selectedSensor === "temperature"}°C + {:else if selectedSensor === "humidity"}% + {:else if selectedSensor === "pressure"}kPa + {:else if selectedSensor === "altitude"}m + {/if} +
+
+
+
Range
+
+ {Math.min(...filteredData.map((d) => d[selectedSensor])).toFixed(1)} - {Math.max( + ...filteredData.map((d) => d[selectedSensor]), + ).toFixed(1)} + {#if selectedSensor === "temperature"}°C + {:else if selectedSensor === "humidity"}% + {:else if selectedSensor === "pressure"}kPa + {:else if selectedSensor === "altitude"}m + {/if} +
+
+
- yAxis: { format: () => "" }, - }} - > - {#snippet marks({ series, getAreaProps })} - - - - - - - - - - - - {#each series as s, i (s.key)} - - {/each} - - {/snippet} - {#snippet tooltip()} - { - return v.toLocaleDateString("en-US", { - month: "long", - }); - }} - indicator="line" - /> - {/snippet} - - + +
+ + + {#if filteredData.length > 1} + {@const minValue = Math.min(...filteredData.map((d) => d[selectedSensor]))} + {@const maxValue = Math.max(...filteredData.map((d) => d[selectedSensor]))} + {@const valueRange = maxValue - minValue || 1} + {@const padding = 30} + {@const chartWidth = 800 - padding * 2} + {@const chartHeight = 400 - padding * 2} + + + + + + + + + + + {maxValue.toFixed(1)} + {((minValue + maxValue) / 2).toFixed(1)} + {minValue.toFixed(1)} + + + {#if filteredData.length > 0} + {@const firstDate = filteredData[0].date} + {@const lastDate = filteredData[filteredData.length - 1].date} + {@const midIndex = Math.floor(filteredData.length / 2)} + {@const midDate = filteredData[midIndex].date} + + + {@const formatDate = (date) => { + if (timeRange === "7d") { + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + } else if (timeRange === "30d") { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } else { + return date.toLocaleDateString("en-US", { month: "short" }); + } + }} + + {formatDate(firstDate)} + {formatDate(midDate)} + {formatDate(lastDate)} + {/if} + + + { + const x = padding + (i / (filteredData.length - 1)) * chartWidth; + const y = + padding + + chartHeight - + ((d[selectedSensor] - minValue) / valueRange) * chartHeight; + return `${x},${y}`; + }) + .join(" ")} + /> + {/if} + +
+ +
+ Data from {filteredData[0]?.date?.toLocaleDateString()} to {filteredData[ + filteredData.length - 1 + ]?.date?.toLocaleDateString()} +
+ {:else} +
+
+

No data available

+

+ No data found for the selected time period +

+
+
+ {/if} +
+
+
+ {/if} diff --git a/src/routes/(app)/[slug]/stats/+server.ts b/src/routes/(app)/[slug]/stats/+server.ts index 32fa2db..fc68394 100644 --- a/src/routes/(app)/[slug]/stats/+server.ts +++ b/src/routes/(app)/[slug]/stats/+server.ts @@ -2,8 +2,23 @@ import { db } from "$lib/server/db"; import * as table from "$lib/server/db/schema" export const GET = async () => { - const data = await db.select({ altitude: table.sensorData.altitude, humidity: table.sensorData.humidity, pressure: table.sensorData.pressure, temperature: table.sensorData.temperature, date: table.sensorData.time, }).from(table.sensorData); - console.log(data); + // Get all sensor data (like the original working version) + const rawData = await db.select({ + altitude: table.sensorData.altitude, + humidity: table.sensorData.humidity, + pressure: table.sensorData.pressure, + temperature: table.sensorData.temperature, + date: table.sensorData.time, + }).from(table.sensorData); + + // Scale pressure values to be in a similar range as other sensors + // Divide by 1000 to convert from Pa to kPa (more reasonable scale) + const data = rawData.map(item => ({ + ...item, + pressure: Math.round((item.pressure / 1000) * 10) / 10 // Convert to kPa with 1 decimal place + })); + + console.log(`Returning ${data.length} data points`); return new Response(JSON.stringify(data), { headers: {