Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Yan Zhou
ca805be243 update the data and chart 2025-06-13 21:14:27 +02:00
1841746f6a
show all info on graph 2025-06-13 09:04:05 +02:00
e29c6db908
save data if sensor is on some floor 2025-06-12 22:50:22 +02:00
fae128bc1b
first step to show graph of sensors 2025-06-12 21:08:08 +02:00
Yan Zhou
91ca53880b added get device and update the values live 2025-06-12 20:36:14 +02:00
Yan Zhou
5d689e6005 add floor plan 2025-06-12 18:53:02 +02:00
10 changed files with 1094 additions and 65 deletions

View file

@ -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",

6
pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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(),
});

View file

@ -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;
}

View file

@ -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
};
};

View file

@ -1,26 +1,59 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Thermometer, Droplets, Gauge, Mountain, Cpu, DownloadIcon } from "@lucide/svelte";
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
const { data } = $props();
let mqttMessage = $state("Waiting for MQTT message...");
let eventSource;
let deviceSensorData = $state(new Map());
let refreshInterval;
onMount(() => {
// Connect to the SSE endpoint
console.log("Attempting to connect to MQTT SSE...");
eventSource = new EventSource("/mqtt");
eventSource.onopen = () => {
console.log("SSE connection opened.");
console.log("SSE connection opened successfully!");
};
eventSource.onmessage = (event) => {
if (eventSource.readyState !== EventSource.OPEN) {
console.log("Floor page: Received message but connection is not open, ignoring");
return;
}
try {
const data = JSON.parse(event.data);
mqttMessage = data.message;
console.log("Received SSE message:", mqttMessage);
const messageData = JSON.parse(event.data);
console.log("Floor page received SSE data:", messageData);
// Handle device updates
if (messageData.devices) {
console.log("Received devices:", messageData.devices);
const newData = new Map();
messageData.devices.forEach((device) => {
console.log(`Device ${device.id} has sensor data:`, device.sensorData);
if (device.sensorData) {
newData.set(device.id, device.sensorData);
}
});
deviceSensorData = newData;
console.log("Updated deviceSensorData:", deviceSensorData);
}
// Handle MQTT message if present
if (messageData.message) {
mqttMessage = messageData.message;
} else {
mqttMessage = `Last update: ${messageData.timestamp}`;
}
} catch (e) {
console.error("Error parsing SSE message:", e);
console.error("Floor page: Error parsing SSE message:", e, "Raw data:", event.data);
}
};
@ -31,13 +64,16 @@
});
onDestroy(() => {
if (eventSource) {
console.log("Closing SSE connection.");
eventSource.close(); // Clean up the connection when the component is destroyed
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
console.log("Floor page: Closing SSE connection");
eventSource.close();
}
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
let canvasEl: HTMLCanvasElement;
let canvasEl = $state<HTMLCanvasElement>();
let ctx;
function drawFloorplan(data) {
@ -81,14 +117,483 @@
ctx.stroke();
});
}
onMount(() => {
ctx = canvasEl.getContext("2d");
drawFloorplan(data.floor[0].json);
if (!data.hasConfig && data.floor?.[0]?.json) {
ctx = canvasEl.getContext("2d");
drawFloorplan(data.floor[0].json);
}
});
import * as Chart from "$lib/components/ui/chart/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { scaleUtc } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { curveNatural } from "d3-shape";
import ChartContainer from "$lib/components/ui/chart/chart-container.svelte";
import { cubicInOut } from "svelte/easing";
import { ChevronDownIcon, Columns2 } from "@lucide/svelte";
let chartData = $state([]);
let timeRange = $state("90d");
const selectedLabel = $derived.by(() => {
switch (timeRange) {
case "90d":
return "Last 3 months";
case "30d":
return "Last 30 days";
case "7d":
return "Last 7 days";
default:
return "Last 3 months";
}
});
const filteredData = $derived(
chartData.filter((item) => {
const now = new Date();
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const cutoffDate = new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
return item.date >= cutoffDate;
}),
);
const chartConfig = {
temperature: { label: "Temperature (°C)", color: "var(--chart-1)" },
humidity: { label: "Humidity (%)", color: "var(--chart-2)" },
altitude: { label: "Altitude (m)", color: "var(--chart-3)" },
pressure: { label: "Pressure (kPa)", color: "var(--chart-4)" },
} satisfies Chart.ChartConfig;
let selectedSensor = $state("temperature");
const sensorOptions = [
{
key: "temperature",
label: chartConfig.temperature.label,
color: chartConfig.temperature.color,
},
{
key: "humidity",
label: chartConfig.humidity.label,
color: chartConfig.humidity.color,
},
{
key: "pressure",
label: chartConfig.pressure.label,
color: chartConfig.pressure.color,
},
{
key: "altitude",
label: chartConfig.altitude.label,
color: chartConfig.altitude.color,
},
];
const selectedSensorConfig = $derived(
sensorOptions.find((s) => s.key === selectedSensor) || sensorOptions[0],
);
let isLoading = $state(false);
let hasError = $state(false);
let onOpenChange = $state(async (open: boolean) => {
console.log("onOpenChange called with:", open);
if (open) {
console.log("Starting to load statistics...");
isLoading = true;
hasError = false;
try {
console.log(`Fetching stats for floor: ${data.slug}`);
const response = await fetch(`/${data.slug}/stats`);
console.log("Stats response status:", response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
console.log("Raw stats data:", rawData);
if (!rawData || rawData.length === 0) {
console.log("No data available for statistics");
chartData = [];
} else {
chartData = rawData.map((obj: any) => {
return { ...obj, date: new Date(obj.date) };
});
console.log("Processed chart data:", chartData);
}
} catch (error) {
console.error("Error loading statistics:", error);
hasError = true;
chartData = [];
} finally {
isLoading = false;
}
}
});
</script>
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
<h1 class="text-center text-4xl font-bold">Floor {data.slug}</h1>
<span>{mqttMessage}</span>
<canvas bind:this={canvasEl} class="obj-contain" width="800%" height="600%"></canvas>
<div class="container mx-auto p-6">
<!-- <div style="display: flex; justify-content: center; align-items: center; height: 100%;"> -->
<h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1>
{#if data.hasConfig && data.floorConfig}
<!-- Display saved floor plan configuration -->
<div class="relative mx-auto max-w-4xl">
{#if data.floorConfig.image}
<div class="relative overflow-hidden rounded-lg border bg-gray-50">
<img src={data.floorConfig.image} alt="Floor plan" class="h-auto w-full" />
{#if data.floorConfig.devices}
{#each data.floorConfig.devices as device}
<div
class="absolute -translate-x-1/2 -translate-y-1/2 transform rounded-lg border-2 border-blue-500 bg-white p-3 shadow-lg"
style="left: {device.x}%; top: {device.y}%;"
>
<div class="flex items-center gap-2">
<Cpu class="h-5 w-5 text-blue-600" />
<span class="text-xs font-medium">{device.name}</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-1">
<div class="flex items-center gap-1">
<Thermometer class="h-3 w-3 text-orange-500" />
<span class="text-xs">
{#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}
</span>
</div>
<div class="flex items-center gap-1">
<Droplets class="h-3 w-3 text-blue-500" />
<span class="text-xs">
{#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}
</span>
</div>
<div class="flex items-center gap-1">
<Gauge class="h-3 w-3 text-green-500" />
<span class="text-xs">
{#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}
</span>
</div>
<div class="flex items-center gap-1">
<Mountain class="h-3 w-3 text-purple-500" />
<span class="text-xs">
{#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}
</span>
</div>
</div>
</div>
{/each}
{/if}
</div>
<div class="mt-4 rounded-lg bg-gray-100 p-4">
<p class="text-sm text-gray-600">MQTT Status: {mqttMessage}</p>
</div>
{/if}
</div>
{:else}
<!-- Fallback to canvas drawing -->
<div class="flex flex-col items-center">
<span class="mb-4">{mqttMessage}</span>
<canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas>
</div>
{/if}
<Dialog.Root bind:onOpenChange>
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger>
<Dialog.Content class="sm:max-h-[90%] sm:max-w-[90%]">
<Dialog.Header>
<div class="grid flex-1 gap-1 text-center sm:text-left">
<Card.Title>Statistics for floor {data.slug}</Card.Title>
<Card.Description>Showing total data for the last 3 months</Card.Description>
</div>
</Dialog.Header>
<div class="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="rounded-lg">
{#snippet child({ props })}
<Button variant="outline" size="sm" {...props}>
<Columns2 />
<span class="hidden lg:inline">{selectedSensorConfig.label}</span>
<span class="lg:hidden">{selectedSensorConfig.label}</span>
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-56">
<DropdownMenu.RadioGroup bind:value={selectedSensor}>
{#each sensorOptions as sensor}
<DropdownMenu.RadioItem value={sensor.key} class="capitalize">
{sensor.label}
</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Select.Root type="single" bind:value={timeRange}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a value">
{selectedLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
<Select.Item value="90d" class="rounded-lg">Last 3 months</Select.Item>
<Select.Item value="30d" class="rounded-lg">Last 30 days</Select.Item>
<Select.Item value="7d" class="rounded-lg">Last 7 days</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm">
<DownloadIcon />
<span class="hidden lg:inline">Export Data</span>
</Button>
</div>
{#if isLoading}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"
></div>
<p class="text-muted-foreground">Loading statistics...</p>
</div>
</div>
{:else if hasError}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-red-600">Error loading statistics</p>
<p class="text-muted-foreground text-sm">Please try again later</p>
</div>
</div>
{:else if chartData.length === 0}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-lg font-semibold">No data available</p>
<p class="text-muted-foreground text-sm">
Statistics will appear once sensor data is collected
</p>
</div>
</div>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="text-xl">{selectedSensorConfig.label}</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
{#if filteredData.length > 0}
<!-- Statistics Summary -->
<div class="grid grid-cols-3 gap-4 rounded border p-4 text-center">
<div>
<div class="text-muted-foreground text-sm font-medium">Latest</div>
<div class="text-lg font-semibold">
{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}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Average</div>
<div class="text-lg font-semibold">
{(
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}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Range</div>
<div class="text-lg font-semibold">
{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}
</div>
</div>
</div>
<!-- Simple SVG Line Chart -->
<div class="rounded border bg-white p-4">
<svg viewBox="0 0 800 400" class="h-80 w-full">
<!-- Chart area -->
{#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}
<!-- Background grid -->
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path
d="M 50 0 L 0 0 0 40"
fill="none"
stroke="#f0f0f0"
stroke-width="1"
/>
</pattern>
</defs>
<rect
x={padding}
y={padding}
width={chartWidth}
height={chartHeight}
fill="url(#grid)"
/>
<!-- Y-axis labels -->
<text x="25" y={padding + 5} class="fill-gray-600 text-xs" text-anchor="end"
>{maxValue.toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight / 2 + 5}
class="fill-gray-600 text-xs"
text-anchor="end">{((minValue + maxValue) / 2).toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight + 5}
class="fill-gray-600 text-xs"
text-anchor="end">{minValue.toFixed(1)}</text
>
<!-- X-axis labels -->
{#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}
<!-- Format dates based on time range -->
{@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" });
}
}}
<text
x={padding}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="start">{formatDate(firstDate)}</text
>
<text
x={padding + chartWidth / 2}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="middle">{formatDate(midDate)}</text
>
<text
x={padding + chartWidth}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="end">{formatDate(lastDate)}</text
>
{/if}
<!-- Data line -->
<polyline
fill="none"
stroke="#3b82f6"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
points={filteredData
.map((d, i) => {
const x = padding + (i / (filteredData.length - 1)) * chartWidth;
const y =
padding +
chartHeight -
((d[selectedSensor] - minValue) / valueRange) * chartHeight;
return `${x},${y}`;
})
.join(" ")}
/>
{/if}
</svg>
</div>
<div class="text-muted-foreground text-center text-xs">
Data from {filteredData[0]?.date?.toLocaleDateString()} to {filteredData[
filteredData.length - 1
]?.date?.toLocaleDateString()}
</div>
{:else}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-lg font-semibold">No data available</p>
<p class="text-muted-foreground text-sm">
No data found for the selected time period
</p>
</div>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
<Dialog.Footer></Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>

View file

@ -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"
}
});
}

View file

@ -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!" });
}
},
};

View file

@ -1,5 +1,6 @@
<script>
import { enhance } from "$app/forms";
import { onMount, onDestroy } from "svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import Label from "$lib/components/ui/label/label.svelte";
@ -10,19 +11,83 @@
import CardTitle from "$lib/components/ui/card/card-title.svelte";
import { Thermometer, Droplets, Gauge, Mountain, Upload, Cpu } from "@lucide/svelte";
const { form } = $props();
const { form, data } = $props();
let floorPlanImage = $state(null);
let availableDevices = $state([
{ id: "esp8266-1", name: "ESP8266 #1", type: "esp8266", status: "online" },
{ id: "esp8266-2", name: "ESP8266 #2", type: "esp8266", status: "online" },
{ id: "esp8266-3", name: "ESP8266 #3", type: "esp8266", status: "offline" },
{ id: "esp8266-4", name: "ESP8266 #4", type: "esp8266", status: "online" },
]);
let availableDevices = $state(data.devices || []);
let deviceSensorData = $state(new Map());
let eventSource;
let placedDevices = $state([]);
let draggedDevice = $state(null);
let floorPlanRef = $state(null);
let selectedFloorNumber = $state("");
let saveMessage = $state(null);
$effect(() => {
if (data.floors && data.floors.length > 0 && !selectedFloorNumber) {
selectedFloorNumber = data.floors[0].toString();
}
});
$effect(() => {
// Update devices when data changes
if (data.devices) {
availableDevices = data.devices;
}
});
// Set up real-time updates via SSE
onMount(() => {
console.log("Settings: Attempting to connect to MQTT SSE...");
eventSource = new EventSource("/mqtt");
eventSource.onopen = () => {
console.log("Settings: SSE connection opened successfully!");
};
eventSource.onmessage = (event) => {
if (eventSource.readyState !== EventSource.OPEN) {
console.log("Settings: Received message but connection is not open, ignoring");
return;
}
try {
const messageData = JSON.parse(event.data);
console.log("Settings page received SSE data:", messageData);
// Update device sensor data and device list from real-time updates
if (messageData.devices) {
console.log("Updating available devices with:", messageData.devices);
// Update available devices with latest data
availableDevices = messageData.devices;
// Update sensor data map
const newData = new Map();
messageData.devices.forEach((device) => {
if (device.sensorData) {
newData.set(device.id, device.sensorData);
}
});
deviceSensorData = newData;
console.log("Updated deviceSensorData:", deviceSensorData);
}
} catch (e) {
console.error("Settings: Error parsing SSE message:", e);
}
};
eventSource.onerror = (error) => {
console.error("SSE Error:", error);
};
});
onDestroy(() => {
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
console.log("Settings: Closing SSE connection");
eventSource.close();
}
});
function handleFileUpload(event) {
const file = event.target.files?.[0];
@ -168,6 +233,65 @@
<p class="mt-2 text-sm text-gray-600">
Drag ESP8266 devices from below onto the floor plan to place them in specific rooms.
</p>
{#if floorPlanImage && placedDevices.length > 0}
<div class="mt-4 rounded-lg border bg-blue-50 p-4">
<h4 class="mb-3 font-semibold">Save Floor Configuration</h4>
<form
method="post"
action="?/savefloor"
use:enhance={() => {
return async ({ result }) => {
if (result.type === "success") {
saveMessage = { type: "success", text: result.data.message };
// Clear form after successful save
selectedFloorNumber = "";
} else if (result.type === "failure") {
saveMessage = {
type: "error",
text: result.data?.message || "Failed to save configuration",
};
}
};
}}
>
<input type="hidden" name="floorPlanImage" value={floorPlanImage} />
<input type="hidden" name="devices" value={JSON.stringify(placedDevices)} />
<div class="mb-3 flex items-end gap-4">
<div class="flex-1">
<Label for="floorNumber">Select Floor</Label>
<select
name="floorNumber"
id="floorNumber"
bind:value={selectedFloorNumber}
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
required
>
{#if data.floors && data.floors.length > 0}
{#each data.floors as floor}
<option value={floor.toString()}>
Floor {floor}
</option>
{/each}
{/if}
</select>
</div>
<Button type="submit" variant="default" disabled={!selectedFloorNumber}>
Save Floor Configuration
</Button>
</div>
</form>
{#if saveMessage}
<p
class="mt-2 text-sm {saveMessage.type === 'success'
? 'text-green-600'
: 'text-red-600'}"
>
{saveMessage.text}
</p>
{/if}
</div>
{/if}
{/if}
<div class="pt-4">
@ -200,19 +324,43 @@
<div class="grid grid-cols-2 gap-1">
<div class="flex items-center gap-1 text-xs">
<Thermometer class="h-3 w-3 text-orange-500" />
<span>Temperature</span>
<span>
{#if device.sensorData}
{device.sensorData.temperature.toFixed(1)}°C
{:else}
--°C
{/if}
</span>
</div>
<div class="flex items-center gap-1 text-xs">
<Droplets class="h-3 w-3 text-blue-500" />
<span>Humidity</span>
<span>
{#if device.sensorData}
{device.sensorData.humidity.toFixed(1)}%
{:else}
--%
{/if}
</span>
</div>
<div class="flex items-center gap-1 text-xs">
<Gauge class="h-3 w-3 text-green-500" />
<span>Pressure</span>
<span>
{#if device.sensorData}
{Math.round(device.sensorData.pressure)}Pa
{:else}
--Pa
{/if}
</span>
</div>
<div class="flex items-center gap-1 text-xs">
<Mountain class="h-3 w-3 text-purple-500" />
<span>Altitude</span>
<span>
{#if device.sensorData}
{device.sensorData.altitude.toFixed(1)}m
{:else}
--m
{/if}
</span>
</div>
</div>
</div>

View file

@ -1,5 +1,9 @@
// src/routes/mqtt/+server.js
import * as mqtt from "mqtt";
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";
// A Svelte store to hold the latest MQTT message.
@ -10,6 +14,85 @@ const devices = writable([]);
let client = null;
// Function to update device status and sensor data
function updateDevice(deviceId, sensorData = null) {
const now = new Date();
const deviceName = `ESP8266 #${deviceId.slice(-6)}`;
// Update device info
connectedDevices.set(deviceId, {
id: deviceId,
name: deviceName,
type: "esp8266",
status: "online",
lastSeen: now,
});
// Update sensor data if provided
if (sensorData) {
deviceSensorData.set(deviceId, {
...sensorData,
timestamp: now,
});
}
// Update the devices store
updateDevicesStore();
}
// Function to parse sensor data from MQTT payload
function parseSensorData(payload) {
try {
const data = JSON.parse(payload);
return {
temperature: parseFloat(data.temperature) || 0,
humidity: parseFloat(data.humidity?.replace("%", "")) || 0,
pressure: parseFloat(data.pressure) || 0,
altitude: parseFloat(data.altitude) || 0,
};
} catch (error) {
console.error("Error parsing sensor data:", error);
return null;
}
}
// Store SSE controllers for cleanup
const sseControllers = new Set();
// Function to update devices store with latest data
function updateDevicesStore() {
const deviceList = Array.from(connectedDevices.values()).map((device) => {
const sensorData = deviceSensorData.get(device.id);
return {
...device,
sensorData: sensorData || null,
};
});
devices.set(deviceList);
}
// 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;
}
}
if (updated) {
updateDevicesStore();
}
}
// Run cleanup every minute
setInterval(cleanupDevices, 60000);
// getCurrentDevices is now imported from the shared module
// Function to connect to MQTT
function connectMqtt() {
if (client && client.connected) {
@ -18,6 +101,7 @@ function connectMqtt() {
// Replace with your MQTT broker details
const BROKER_URL = "mqtt://kada49.it:1883"; // Example public broker
//const BROKER_URL = "mqtt://test.mosquitto.org:1883";
const TOPIC = "esp8266/+/data"; // Replace with your desired topic
client = mqtt.connect(BROKER_URL);
@ -33,11 +117,38 @@ 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
console.log(topic.split("/")[1]);
// Extract device ID from topic (esp8266/DEVICE_ID/data)
const topicParts = topic.split("/");
if (topicParts.length >= 3 && topicParts[0] === "esp8266" && topicParts[2] === "data") {
const deviceId = topicParts[1];
console.log(`Processing data for device: ${deviceId}`);
// Parse sensor data
const sensorData = parseSensorData(payload);
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);
}
}
});
client.on("error", (err) => {
@ -70,24 +181,98 @@ export async function GET({ request }) {
return new Response(
new ReadableStream({
start(controller) {
console.log("Client connected to SSE stream.");
console.log("Client connected to MQTT SSE stream");
let isConnected = true;
let interval;
// Add this controller to the broadcast set
sseControllers.add(controller);
// Cleanup function
const cleanup = () => {
console.log("Cleaning up MQTT SSE connection");
isConnected = false;
if (interval) {
clearInterval(interval);
interval = null;
}
sseControllers.delete(controller);
if (unsubscribe) {
unsubscribe();
}
};
// Send current device data
function sendDevices() {
if (!isConnected) {
cleanup();
return;
}
try {
const devices = getCurrentDevices();
const message = {
devices: devices,
timestamp: new Date().toISOString(),
};
// Check if controller is still valid before enqueueing
if (isConnected) {
controller.enqueue(`data: ${JSON.stringify(message)}\n\n`);
}
} catch (error) {
// Handle controller closed error specifically
if (
error.code === "ERR_INVALID_STATE" ||
error.message.includes("Controller is already closed")
) {
console.log("MQTT SSE controller closed, stopping interval");
} else {
console.error("Error sending MQTT device data:", error);
}
cleanup();
}
}
// Send initial data
sendDevices();
// Send updates every 2 seconds
interval = setInterval(sendDevices, 2000);
// Subscribe to MQTT messages via the store for real-time updates
const unsubscribe = latestMessage.subscribe((message) => {
if (message !== "No message yet") {
const data = `data: ${JSON.stringify({ message: message })}\n\n`;
controller.enqueue(data);
if (message !== "No message yet" && isConnected) {
try {
const data = `data: ${JSON.stringify({
message: message,
devices: getCurrentDevices(),
timestamp: new Date().toISOString(),
})}\n\n`;
if (isConnected) {
controller.enqueue(data);
}
} catch (error) {
if (
error.code === "ERR_INVALID_STATE" ||
error.message.includes("Controller is already closed")
) {
console.log("MQTT SSE controller closed during message send");
cleanup();
}
}
}
});
// Handle client disconnection
request.signal.addEventListener("abort", () => {
console.log("Client disconnected from SSE stream.");
unsubscribe(); // Stop listening to store updates
controller.close();
console.log("Client disconnected from MQTT SSE stream");
cleanup();
});
},
cancel() {
console.log("SSE stream cancelled.");
console.log("MQTT SSE stream cancelled");
},
}),
{ headers },