Compare commits

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

10 commits
main ... david

16 changed files with 1387 additions and 216 deletions

View file

@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" text PRIMARY KEY NOT NULL,
"age" integer,
"username" text NOT NULL,
"password_hash" text NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,43 @@
CREATE TABLE "floors" (
"floor" integer PRIMARY KEY NOT NULL,
"url" text NOT NULL,
"image" text
);
--> statement-breakpoint
CREATE TABLE "plans" (
"floor" integer PRIMARY KEY NOT NULL,
"plan" json NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sensor_data" (
"uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sensor" text NOT NULL,
"temperature" real NOT NULL,
"humidity" real NOT NULL,
"pressure" real NOT NULL,
"altitude" real NOT NULL,
"time" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sensors" (
"id" text PRIMARY KEY NOT NULL,
"user" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"age" integer,
"username" text NOT NULL,
"password_hash" text NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "sensor_data" ADD CONSTRAINT "sensor_data_sensor_sensors_id_fk" FOREIGN KEY ("sensor") REFERENCES "public"."sensors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sensors" ADD CONSTRAINT "sensors_user_users_id_fk" FOREIGN KEY ("user") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View file

@ -1,103 +0,0 @@
{
"id": "4b3474a3-7de5-4b99-878e-f839b53c52f7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,261 @@
{
"id": "23f11c9d-f98b-4321-aca4-54ec7fc4eece",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.floors": {
"name": "floors",
"schema": "",
"columns": {
"floor": {
"name": "floor",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.plans": {
"name": "plans",
"schema": "",
"columns": {
"floor": {
"name": "floor",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"plan": {
"name": "plan",
"type": "json",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sensor_data": {
"name": "sensor_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"sensor": {
"name": "sensor",
"type": "text",
"primaryKey": false,
"notNull": true
},
"temperature": {
"name": "temperature",
"type": "real",
"primaryKey": false,
"notNull": true
},
"humidity": {
"name": "humidity",
"type": "real",
"primaryKey": false,
"notNull": true
},
"pressure": {
"name": "pressure",
"type": "real",
"primaryKey": false,
"notNull": true
},
"altitude": {
"name": "altitude",
"type": "real",
"primaryKey": false,
"notNull": true
},
"time": {
"name": "time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sensor_data_sensor_sensors_id_fk": {
"name": "sensor_data_sensor_sensors_id_fk",
"tableFrom": "sensor_data",
"tableTo": "sensors",
"columnsFrom": ["sensor"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sensors": {
"name": "sensors",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user": {
"name": "user",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sensors_user_users_id_fk": {
"name": "sensors_user_users_id_fk",
"tableFrom": "sensors",
"tableTo": "users",
"columnsFrom": ["user"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1730747291671,
"tag": "20241104190811_lucia",
"when": 1749889934943,
"tag": "20250614083214_lucia",
"breakpoints": true
}
]

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,33 @@
let connectedDevices = new Map();
let deviceSensorData = new Map();
export { connectedDevices, deviceSensorData };
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 getCurrentDevices() {
cleanupDevices();
return Array.from(connectedDevices.values()).map((device) => {
const sensorData = deviceSensorData.get(device.id);
return {
...device,
sensorData: sensorData || null,
};
});
}
export function getDeviceSensorData(deviceId) {
return deviceSensorData.get(deviceId) || null;
}

View file

@ -2,36 +2,69 @@ import { db } from "$lib/server/db";
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
}
]
}});
const floorNumber = Number(params.slug);
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 {
const config = JSON.parse(floorData[0].url);
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);
}
}
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,43 +1,76 @@
<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);
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);
}
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);
}
};
eventSource.onerror = (error) => {
console.error("SSE Error:", error);
eventSource.close(); // Close the connection on error
eventSource.close();
};
});
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) {
@ -46,7 +79,6 @@
return;
}
// Draw walls, doors, and furniture (simplified example)
drawRegions(data.regions);
drawDoors(data.doors);
drawFurniture(data.furnitures);
@ -64,7 +96,7 @@
function drawDoors(doors) {
doors.forEach((door) => {
ctx.beginPath();
ctx.rect(door.location.x, door.location.y, door.width, 5); // Simplified door drawing
ctx.rect(door.location.x, door.location.y, door.width, 5);
ctx.stroke();
});
}
@ -81,14 +113,514 @@
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";
let chartData = $state([]);
let newChartData = $derived(
chartData.filter((item: object) => {
if (item.sensorId === selectedESP) {
return { ...item };
}
}),
);
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(
newChartData.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 sensorLabel = $derived.by(() => {
switch (selectedSensor) {
case "altitude":
return "Altitude (m)";
case "humidity":
return "Humidity (%)";
case "pressure":
return "Pressure (kPa)";
case "temperature":
default:
return "Temperature (°C)";
}
});
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 esps = $state([]);
let selectedESP = $state("Select ESP sensor");
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);
const allesps = rawData.map((i: any) => {
return i.sensorId;
});
esps = allesps.filter((v: any, i: any, a: any) => a.indexOf(v) === i);
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;
}
}
});
function exportCSV(): void {
let csv = "data:text/csv;charset=utf-8,";
const headers = "time," + selectedSensorConfig.key;
csv += headers + "\n";
newChartData.forEach((obj: any) => {
csv += obj.date.toISOString() + "," + obj[selectedSensorConfig.key] + "\n";
});
const encodedUri = encodeURI(csv);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", selectedESP + "_" + selectedSensorConfig.key + "_data.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</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">
<h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1>
{#if data.hasConfig && data.floorConfig}
<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}
<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">
<Select.Root type="single" bind:value={selectedESP}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a sensor">
{selectedESP}
</Select.Trigger>
<Select.Content class="rounded-xl">
{#each esps as esp}
<Select.Item value={esp} class="rounded-lg">{esp}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={selectedSensor}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a metric">
{sensorLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
<Select.Item value="altitude" class="rounded-lg">Altitude (m)</Select.Item>
<Select.Item value="humidity" class="rounded-lg">Humidity (%)</Select.Item>
<Select.Item value="pressure" class="rounded-lg">Pressure (kPa)</Select.Item>
<Select.Item value="temperature" class="rounded-lg">Temperature (°C)</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={timeRange}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a time range">
{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" onclick={() => exportCSV()}>
<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 newChartData.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}
<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>
<div class="rounded border bg-white p-4">
<svg viewBox="0 0 800 400" class="h-80 w-full">
{#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}
<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)"
/>
<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
>
{#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" });
}
}}
<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}
<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,29 @@
import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema"
import { eq } from "drizzle-orm";
export const GET = async (event) => {
console.log(event.locals.session.userId)
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,
sensorId: table.sensorData.sensor,
user: table.sensors.user,
}).from(table.sensorData).innerJoin(table.sensors, eq(table.sensors.id, table.sensorData.sensor)).where(eq(table.sensors.user, event.locals.session.userId));
const data = rawData.map(item => ({
...item,
pressure: Math.round((item.pressure / 1000) * 10) / 10
}));
console.log(`Returning ${data.length} data points`);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json"
}
});
}

View file

@ -1,10 +1,32 @@
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 {};
const floors = await db
.select({ floor: table.floors.floor })
.from(table.floors)
.orderBy(table.floors.floor);
let devices = getCurrentDevices();
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 +63,48 @@ 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 });
});
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.` });
}
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,78 @@
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(() => {
if (data.devices) {
availableDevices = data.devices;
}
});
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);
if (messageData.devices) {
console.log("Updating available devices with:", messageData.devices);
availableDevices = messageData.devices;
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];
@ -51,14 +111,11 @@
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
// Check if device is already placed
const existingIndex = placedDevices.findIndex((d) => d.id === draggedDevice.id);
if (existingIndex >= 0) {
// Update position
placedDevices[existingIndex] = { ...placedDevices[existingIndex], x, y };
} else {
// Add new device
placedDevices = [...placedDevices, { ...draggedDevice, x, y }];
}
@ -168,6 +225,64 @@
<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 };
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 +315,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

@ -63,7 +63,6 @@ export const actions = {
const userId = generateUserId();
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
@ -84,7 +83,6 @@ export const actions = {
};
function generateUserId() {
// ID with 120 bits of entropy, or about the same as UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(15));
const id = encodeBase64url(bytes);
return id;

View file

@ -1,24 +1,90 @@
// 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.
// In a real application, you might want to store more data or
// use a more robust way to manage messages, but for a basic example, this works.
const latestMessage = writable("No message yet");
const devices = writable([]);
let client = null;
// Function to connect to MQTT
function connectMqtt() {
if (client && client.connected) {
return; // Already connected
function updateDevice(deviceId, sensorData = null) {
const now = new Date();
const deviceName = `ESP8266 #${deviceId.slice(-6)}`;
connectedDevices.set(deviceId, {
id: deviceId,
name: deviceName,
type: "esp8266",
status: "online",
lastSeen: now,
});
if (sensorData) {
deviceSensorData.set(deviceId, {
...sensorData,
timestamp: now,
});
}
// Replace with your MQTT broker details
const BROKER_URL = "mqtt://kada49.it:1883"; // Example public broker
const TOPIC = "esp8266/+/data"; // Replace with your desired topic
updateDevicesStore();
}
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;
}
}
const sseControllers = new Set();
function updateDevicesStore() {
const deviceList = Array.from(connectedDevices.values()).map((device) => {
const sensorData = deviceSensorData.get(device.id);
return {
...device,
sensorData: sensorData || null,
};
});
devices.set(deviceList);
}
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();
}
}
setInterval(cleanupDevices, 60000);
function connectMqtt() {
if (client && client.connected) {
return;
}
const BROKER_URL = "mqtt://kada49.it:1883";
const TOPIC = "esp8266/+/data";
client = mqtt.connect(BROKER_URL);
@ -33,30 +99,49 @@ 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]);
latestMessage.set(payload);
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}`);
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 {
updateDevice(deviceId);
}
}
});
client.on("error", (err) => {
console.error(`MQTT error: ${err}`);
if (client) {
client.end(); // Close connection on error
client.end();
}
client = null;
// Implement re-connection logic here if needed
});
client.on("close", () => {
console.log("MQTT connection closed.");
client = null;
// Implement re-connection logic here if needed
});
}
// Connect to MQTT when the server starts
connectMqtt();
/** @type {import("./$types").RequestHandler} */
@ -70,24 +155,89 @@ 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;
sseControllers.add(controller);
const cleanup = () => {
console.log("Cleaning up MQTT SSE connection");
isConnected = false;
if (interval) {
clearInterval(interval);
interval = null;
}
sseControllers.delete(controller);
if (unsubscribe) {
unsubscribe();
}
};
function sendDevices() {
if (!isConnected) {
cleanup();
return;
}
try {
const devices = getCurrentDevices();
const message = {
devices: devices,
timestamp: new Date().toISOString(),
};
if (isConnected) {
controller.enqueue(`data: ${JSON.stringify(message)}\n\n`);
}
} catch (error) {
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();
}
}
sendDevices();
interval = setInterval(sendDevices, 2000);
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 },