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..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,4 +42,5 @@ export const sensorData = pgTable("sensor_data", { humidity: real().notNull(), pressure: real().notNull(), altitude: real().notNull(), + time: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(), }); diff --git a/src/lib/server/mqtt-devices.js b/src/lib/server/mqtt-devices.js new file mode 100644 index 0000000..8445506 --- /dev/null +++ b/src/lib/server/mqtt-devices.js @@ -0,0 +1,42 @@ +// src/lib/server/mqtt-devices.js +// This module provides access to MQTT device data for other parts of the application + +// We'll import the maps directly from the MQTT server module + +let connectedDevices = new Map(); +let deviceSensorData = new Map(); + +// Export the maps so the MQTT server can set them +export { connectedDevices, deviceSensorData }; + +// Clean up offline devices (not seen in last 5 minutes) +function cleanupDevices() { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + let updated = false; + + for (const [deviceId, device] of connectedDevices.entries()) { + if (device.lastSeen < fiveMinutesAgo && device.status === "online") { + connectedDevices.set(deviceId, { ...device, status: "offline" }); + updated = true; + } + } + + return updated; +} + +// Export function to get current devices +export function getCurrentDevices() { + cleanupDevices(); + return Array.from(connectedDevices.values()).map((device) => { + const sensorData = deviceSensorData.get(device.id); + return { + ...device, + sensorData: sensorData || null, + }; + }); +} + +// Export function to get sensor data for a specific device +export function getDeviceSensorData(deviceId) { + return deviceSensorData.get(deviceId) || null; +} diff --git a/src/routes/(app)/[slug]/+page.server.ts b/src/routes/(app)/[slug]/+page.server.ts index 4488322..8ce7412 100644 --- a/src/routes/(app)/[slug]/+page.server.ts +++ b/src/routes/(app)/[slug]/+page.server.ts @@ -3,35 +3,74 @@ import * as table from "$lib/server/db/schema"; import { eq } from "drizzle-orm"; import type { PageServerLoad } from "./$types"; import { connect } from "mqtt"; +import { getDeviceSensorData } from "$lib/server/mqtt-devices.js"; export const load: PageServerLoad = async ({ params }) => { - { - const floor_cnt = await db.select({ floor: table.plans.floor, json: table.plans.plan }).from(table.plans).where(eq(table.plans.floor, params.slug)); - if (floor_cnt.length == 0) { - await db.insert(table.plans).values({ floor: params.slug, plan: { - "regions": [ - { "start": { "x": 100, "y": 100 }, "end": { "x": 400, "y": 100 } }, - { "start": { "x": 400, "y": 100 }, "end": { "x": 400, "y": 300 } }, - { "start": { "x": 400, "y": 300 }, "end": { "x": 100, "y": 300 } }, - { "start": { "x": 100, "y": 300 }, "end": { "x": 100, "y": 100 } } - ], - "doors": [ - { "location": { "x": 240, "y": 100 }, "width": 50, "rotation": 0 } - ], - "furnitures": [ - { - "minBound": { "x": 150, "y": 150 }, - "maxBound": { "x": 200, "y": 200 }, - "equipName": "Table", - "xPlacement": 150, - "yPlacement": 150, - "rotation": 0 - } - ] - }}); + // Convert slug to number for floor lookup + const floorNumber = Number(params.slug); + + // First check if we have a saved floor configuration in the floors table + const floorData = await db.select({ + floor: table.floors.floor, + url: table.floors.url + }).from(table.floors).where(eq(table.floors.floor, floorNumber)); + + if (floorData.length > 0 && floorData[0].url && floorData[0].url !== "/") { + try { + // Try to parse the saved configuration + const config = JSON.parse(floorData[0].url); + + // Add real sensor data to devices + if (config.devices) { + config.devices = config.devices.map(device => { + const sensorData = getDeviceSensorData(device.id); + return { + ...device, + sensorData: sensorData + }; + }); + } + + return { + slug: params.slug, + floorConfig: config, + hasConfig: true + }; + } catch (e) { + console.error("Error parsing floor configuration:", e); } - } + + // Fallback to the old canvas drawing system + const floor_cnt = await db.select({ floor: table.plans.floor, json: table.plans.plan }).from(table.plans).where(eq(table.plans.floor, params.slug)); + if (floor_cnt.length == 0) { + await db.insert(table.plans).values({ floor: params.slug, plan: { + "regions": [ + { "start": { "x": 100, "y": 100 }, "end": { "x": 400, "y": 100 } }, + { "start": { "x": 400, "y": 100 }, "end": { "x": 400, "y": 300 } }, + { "start": { "x": 400, "y": 300 }, "end": { "x": 100, "y": 300 } }, + { "start": { "x": 100, "y": 300 }, "end": { "x": 100, "y": 100 } } + ], + "doors": [ + { "location": { "x": 240, "y": 100 }, "width": 50, "rotation": 0 } + ], + "furnitures": [ + { + "minBound": { "x": 150, "y": 150 }, + "maxBound": { "x": 200, "y": 200 }, + "equipName": "Table", + "xPlacement": 150, + "yPlacement": 150, + "rotation": 0 + } + ] + }}); + } + const floor_ = await db.select({ floor: table.plans.floor, json: table.plans.plan }).from(table.plans).where(eq(table.plans.floor, params.slug)); - return { slug: params.slug, floor: floor_ }; + return { + slug: params.slug, + floor: floor_, + hasConfig: false + }; }; diff --git a/src/routes/(app)/[slug]/+page.svelte b/src/routes/(app)/[slug]/+page.svelte index 2f90051..6491725 100644 --- a/src/routes/(app)/[slug]/+page.svelte +++ b/src/routes/(app)/[slug]/+page.svelte @@ -1,26 +1,59 @@ -
-

Floor {data.slug}

- {mqttMessage} - +
+ +

Floor {data.slug}

+ + {#if data.hasConfig && data.floorConfig} + +
+ {#if data.floorConfig.image} +
+ Floor plan + + {#if data.floorConfig.devices} + {#each data.floorConfig.devices as device} +
+
+ + {device.name} +
+
+
+ + + {#if deviceSensorData.get(device.id)?.temperature !== undefined} + {deviceSensorData.get(device.id).temperature.toFixed(1)}°C + {:else if device.sensorData} + {device.sensorData.temperature.toFixed(1)}°C + {:else} + --°C + {/if} + +
+
+ + + {#if deviceSensorData.get(device.id)?.humidity !== undefined} + {deviceSensorData.get(device.id).humidity.toFixed(1)}% + {:else if device.sensorData} + {device.sensorData.humidity.toFixed(1)}% + {:else} + --% + {/if} + +
+
+ + + {#if deviceSensorData.get(device.id)?.pressure !== undefined} + {Math.round(deviceSensorData.get(device.id).pressure)}Pa + {:else if device.sensorData} + {Math.round(device.sensorData.pressure)}Pa + {:else} + --Pa + {/if} + +
+
+ + + {#if deviceSensorData.get(device.id)?.altitude !== undefined} + {deviceSensorData.get(device.id).altitude.toFixed(1)}m + {:else if device.sensorData} + {device.sensorData.altitude.toFixed(1)}m + {:else} + --m + {/if} + +
+
+
+ {/each} + {/if} +
+ +
+

MQTT Status: {mqttMessage}

+
+ {/if} +
+ {:else} + +
+ {mqttMessage} + +
+ {/if} + + Statistics + + +
+ Statistics for floor {data.slug} + Showing total data for the last 3 months +
+
+ +
+ + + {#snippet child({ props })} + + {/snippet} + + + + {#each sensorOptions as sensor} + + {sensor.label} + + {/each} + + + + + + + {selectedLabel} + + + Last 3 months + Last 30 days + Last 7 days + + + + +
+ + {#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} +
+
+
+ + +
+ + + {#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 new file mode 100644 index 0000000..fc68394 --- /dev/null +++ b/src/routes/(app)/[slug]/stats/+server.ts @@ -0,0 +1,28 @@ +import { db } from "$lib/server/db"; +import * as table from "$lib/server/db/schema" + +export const GET = async () => { + // 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: { + "Content-Type": "application/json" + } + }); +} diff --git a/src/routes/(app)/settings/+page.server.js b/src/routes/(app)/settings/+page.server.js index 365a6b8..1939028 100644 --- a/src/routes/(app)/settings/+page.server.js +++ b/src/routes/(app)/settings/+page.server.js @@ -1,10 +1,35 @@ import { db } from "$lib/server/db"; import * as table from "$lib/server/db/schema"; +import { getCurrentDevices } from "$lib/server/mqtt-devices.js"; import { fail } from "@sveltejs/kit"; import { eq } from "drizzle-orm"; export const load = async (event) => { - return {}; + // Fetch all available floors + const floors = await db + .select({ floor: table.floors.floor }) + .from(table.floors) + .orderBy(table.floors.floor); + + // Get real connected devices from MQTT + let devices = getCurrentDevices(); + + // If no real devices, provide fallback mock devices + if (devices.length === 0) { + devices = [ + { + id: "no-devices", + name: "No ESP8266 devices connected", + type: "esp8266", + status: "offline", + }, + ]; + } + + return { + floors: floors.map((f) => f.floor), + devices: devices, + }; }; export const actions = { @@ -41,4 +66,52 @@ export const actions = { await db.delete(table.floors).where(eq(table.floors.floor, n)); }, + savefloor: async (event) => { + const formData = await event.request.formData(); + const floorNumber = formData.get("floorNumber"); + const floorPlanImage = formData.get("floorPlanImage"); + const devices = formData.get("devices"); + + const n = Number(floorNumber); + if (isNaN(n)) return fail(400, { message: "Invalid floor number!" }); + + if (!floorPlanImage || !devices) { + return fail(400, { message: "Missing floor plan or device configuration!" }); + } + + 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 + .select({ floor: table.floors.floor }) + .from(table.floors) + .where(eq(table.floors.floor, n)); + + if (exists.length === 0) { + return fail(400, { message: `Floor ${n} does not exist! Please create it first.` }); + } + + // Update floor with configuration + // Note: In a real implementation, you would store this data properly + // For now, we'll just update the url field as a JSON string + const floorConfig = { + image: floorPlanImage, + devices: deviceData, + }; + + await db + .update(table.floors) + .set({ url: JSON.stringify(floorConfig) }) + .where(eq(table.floors.floor, n)); + + return { success: true, message: `Floor ${n} configuration saved successfully!` }; + } catch (error) { + console.error("Error saving floor configuration:", error); + return fail(500, { message: "Failed to save floor configuration!" }); + } + }, }; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 173578d..9b57069 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -1,5 +1,6 @@