Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
0f79e7d121 | |||
b8b0f2d6a1 | |||
b57d9b7875 | |||
9fc7453a44 | |||
![]() |
ca805be243 | ||
1841746f6a | |||
e29c6db908 | |||
fae128bc1b | |||
![]() |
91ca53880b | ||
![]() |
5d689e6005 |
16 changed files with 1387 additions and 216 deletions
|
@ -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 $$;
|
|
43
drizzle/20250614083214_lucia.sql
Normal file
43
drizzle/20250614083214_lucia.sql
Normal 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;
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
261
drizzle/meta/20250614083214_snapshot.json
Normal file
261
drizzle/meta/20250614083214_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1730747291671,
|
"when": 1749889934943,
|
||||||
"tag": "20241104190811_lucia",
|
"tag": "20250614083214_lucia",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -68,6 +68,8 @@
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.2.0",
|
||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"mqtt": "^5.13.1",
|
"mqtt": "^5.13.1",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -17,6 +17,12 @@ importers:
|
||||||
'@oslojs/encoding':
|
'@oslojs/encoding':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 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:
|
drizzle-orm:
|
||||||
specifier: ^0.43.1
|
specifier: ^0.43.1
|
||||||
version: 0.43.1(postgres@3.4.5)
|
version: 0.43.1(postgres@3.4.5)
|
||||||
|
|
|
@ -34,7 +34,7 @@ export const sensors = pgTable("sensors", {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sensorData = pgTable("sensor_data", {
|
export const sensorData = pgTable("sensor_data", {
|
||||||
uuid: uuid().primaryKey(),
|
uuid: uuid().primaryKey().defaultRandom(),
|
||||||
sensor: text("sensor")
|
sensor: text("sensor")
|
||||||
.references(() => sensors.id)
|
.references(() => sensors.id)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
@ -42,4 +42,5 @@ export const sensorData = pgTable("sensor_data", {
|
||||||
humidity: real().notNull(),
|
humidity: real().notNull(),
|
||||||
pressure: real().notNull(),
|
pressure: real().notNull(),
|
||||||
altitude: real().notNull(),
|
altitude: real().notNull(),
|
||||||
|
time: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
33
src/lib/server/mqtt-devices.js
Normal file
33
src/lib/server/mqtt-devices.js
Normal 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;
|
||||||
|
}
|
|
@ -2,10 +2,40 @@ import { db } from "$lib/server/db";
|
||||||
import * as table from "$lib/server/db/schema";
|
import * as table from "$lib/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { connect } from "mqtt";
|
import { getDeviceSensorData } from "$lib/server/mqtt-devices.js";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
{
|
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));
|
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) {
|
if (floor_cnt.length == 0) {
|
||||||
await db.insert(table.plans).values({ floor: params.slug, plan: {
|
await db.insert(table.plans).values({ floor: params.slug, plan: {
|
||||||
|
@ -31,7 +61,10 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||||
}});
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
const floor_ = await db.select({ floor: table.plans.floor, json: table.plans.plan }).from(table.plans).where(eq(table.plans.floor, params.slug));
|
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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,43 +1,76 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
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();
|
const { data } = $props();
|
||||||
|
|
||||||
let mqttMessage = $state("Waiting for MQTT message...");
|
let mqttMessage = $state("Waiting for MQTT message...");
|
||||||
let eventSource;
|
let eventSource;
|
||||||
|
let deviceSensorData = $state(new Map());
|
||||||
|
let refreshInterval;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Connect to the SSE endpoint
|
console.log("Attempting to connect to MQTT SSE...");
|
||||||
eventSource = new EventSource("/mqtt");
|
eventSource = new EventSource("/mqtt");
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
console.log("SSE connection opened.");
|
console.log("SSE connection opened successfully!");
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
if (eventSource.readyState !== EventSource.OPEN) {
|
||||||
|
console.log("Floor page: Received message but connection is not open, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const messageData = JSON.parse(event.data);
|
||||||
mqttMessage = data.message;
|
console.log("Floor page received SSE data:", messageData);
|
||||||
console.log("Received SSE message:", mqttMessage);
|
|
||||||
|
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) {
|
} 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) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error("SSE Error:", error);
|
console.error("SSE Error:", error);
|
||||||
eventSource.close(); // Close the connection on error
|
eventSource.close();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (eventSource) {
|
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||||
console.log("Closing SSE connection.");
|
console.log("Floor page: Closing SSE connection");
|
||||||
eventSource.close(); // Clean up the connection when the component is destroyed
|
eventSource.close();
|
||||||
|
}
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement;
|
let canvasEl = $state<HTMLCanvasElement>();
|
||||||
let ctx;
|
let ctx;
|
||||||
|
|
||||||
function drawFloorplan(data) {
|
function drawFloorplan(data) {
|
||||||
|
@ -46,7 +79,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw walls, doors, and furniture (simplified example)
|
|
||||||
drawRegions(data.regions);
|
drawRegions(data.regions);
|
||||||
drawDoors(data.doors);
|
drawDoors(data.doors);
|
||||||
drawFurniture(data.furnitures);
|
drawFurniture(data.furnitures);
|
||||||
|
@ -64,7 +96,7 @@
|
||||||
function drawDoors(doors) {
|
function drawDoors(doors) {
|
||||||
doors.forEach((door) => {
|
doors.forEach((door) => {
|
||||||
ctx.beginPath();
|
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();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -81,14 +113,514 @@
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (!data.hasConfig && data.floor?.[0]?.json) {
|
||||||
ctx = canvasEl.getContext("2d");
|
ctx = canvasEl.getContext("2d");
|
||||||
drawFloorplan(data.floor[0].json);
|
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>
|
</script>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
|
<div class="container mx-auto p-6">
|
||||||
<h1 class="text-center text-4xl font-bold">Floor {data.slug}</h1>
|
<h1 class="mb-6 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>
|
{#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>
|
</div>
|
||||||
|
|
29
src/routes/(app)/[slug]/stats/+server.ts
Normal file
29
src/routes/(app)/[slug]/stats/+server.ts
Normal 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,10 +1,32 @@
|
||||||
import { db } from "$lib/server/db";
|
import { db } from "$lib/server/db";
|
||||||
import * as table from "$lib/server/db/schema";
|
import * as table from "$lib/server/db/schema";
|
||||||
|
import { getCurrentDevices } from "$lib/server/mqtt-devices.js";
|
||||||
import { fail } from "@sveltejs/kit";
|
import { fail } from "@sveltejs/kit";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const load = async (event) => {
|
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 = {
|
export const actions = {
|
||||||
|
@ -41,4 +63,48 @@ export const actions = {
|
||||||
|
|
||||||
await db.delete(table.floors).where(eq(table.floors.floor, n));
|
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!" });
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
import Input from "$lib/components/ui/input/input.svelte";
|
||||||
import Label from "$lib/components/ui/label/label.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 CardTitle from "$lib/components/ui/card/card-title.svelte";
|
||||||
import { Thermometer, Droplets, Gauge, Mountain, Upload, Cpu } from "@lucide/svelte";
|
import { Thermometer, Droplets, Gauge, Mountain, Upload, Cpu } from "@lucide/svelte";
|
||||||
|
|
||||||
const { form } = $props();
|
const { form, data } = $props();
|
||||||
|
|
||||||
let floorPlanImage = $state(null);
|
let floorPlanImage = $state(null);
|
||||||
let availableDevices = $state([
|
let availableDevices = $state(data.devices || []);
|
||||||
{ id: "esp8266-1", name: "ESP8266 #1", type: "esp8266", status: "online" },
|
let deviceSensorData = $state(new Map());
|
||||||
{ id: "esp8266-2", name: "ESP8266 #2", type: "esp8266", status: "online" },
|
let eventSource;
|
||||||
{ id: "esp8266-3", name: "ESP8266 #3", type: "esp8266", status: "offline" },
|
|
||||||
{ id: "esp8266-4", name: "ESP8266 #4", type: "esp8266", status: "online" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
let placedDevices = $state([]);
|
let placedDevices = $state([]);
|
||||||
let draggedDevice = $state(null);
|
let draggedDevice = $state(null);
|
||||||
let floorPlanRef = $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) {
|
function handleFileUpload(event) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
|
@ -51,14 +111,11 @@
|
||||||
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
||||||
const y = ((event.clientY - rect.top) / rect.height) * 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);
|
const existingIndex = placedDevices.findIndex((d) => d.id === draggedDevice.id);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Update position
|
|
||||||
placedDevices[existingIndex] = { ...placedDevices[existingIndex], x, y };
|
placedDevices[existingIndex] = { ...placedDevices[existingIndex], x, y };
|
||||||
} else {
|
} else {
|
||||||
// Add new device
|
|
||||||
placedDevices = [...placedDevices, { ...draggedDevice, x, y }];
|
placedDevices = [...placedDevices, { ...draggedDevice, x, y }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,6 +225,64 @@
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
Drag ESP8266 devices from below onto the floor plan to place them in specific rooms.
|
Drag ESP8266 devices from below onto the floor plan to place them in specific rooms.
|
||||||
</p>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
|
@ -200,19 +315,43 @@
|
||||||
<div class="grid grid-cols-2 gap-1">
|
<div class="grid grid-cols-2 gap-1">
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<div class="flex items-center gap-1 text-xs">
|
||||||
<Thermometer class="h-3 w-3 text-orange-500" />
|
<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>
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<div class="flex items-center gap-1 text-xs">
|
||||||
<Droplets class="h-3 w-3 text-blue-500" />
|
<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>
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<div class="flex items-center gap-1 text-xs">
|
||||||
<Gauge class="h-3 w-3 text-green-500" />
|
<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>
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<div class="flex items-center gap-1 text-xs">
|
||||||
<Mountain class="h-3 w-3 text-purple-500" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -63,7 +63,6 @@ export const actions = {
|
||||||
|
|
||||||
const userId = generateUserId();
|
const userId = generateUserId();
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hash(password, {
|
||||||
// recommended minimum parameters
|
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
outputLen: 32,
|
outputLen: 32,
|
||||||
|
@ -84,7 +83,6 @@ export const actions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateUserId() {
|
function generateUserId() {
|
||||||
// ID with 120 bits of entropy, or about the same as UUID v4.
|
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
const id = encodeBase64url(bytes);
|
const id = encodeBase64url(bytes);
|
||||||
return id;
|
return id;
|
||||||
|
|
|
@ -1,24 +1,90 @@
|
||||||
// src/routes/mqtt/+server.js
|
import { db } from "$lib/server/db";
|
||||||
import * as mqtt from "mqtt";
|
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";
|
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 latestMessage = writable("No message yet");
|
||||||
const devices = writable([]);
|
const devices = writable([]);
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
|
|
||||||
// Function to connect to MQTT
|
function updateDevice(deviceId, sensorData = null) {
|
||||||
function connectMqtt() {
|
const now = new Date();
|
||||||
if (client && client.connected) {
|
const deviceName = `ESP8266 #${deviceId.slice(-6)}`;
|
||||||
return; // Already connected
|
|
||||||
|
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
|
updateDevicesStore();
|
||||||
const BROKER_URL = "mqtt://kada49.it:1883"; // Example public broker
|
}
|
||||||
const TOPIC = "esp8266/+/data"; // Replace with your desired topic
|
|
||||||
|
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);
|
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();
|
const payload = message.toString();
|
||||||
console.log(`Received message from topic "${topic}": ${payload}`);
|
console.log(`Received message from topic "${topic}": ${payload}`);
|
||||||
latestMessage.set(payload); // Update the Svelte store
|
latestMessage.set(payload);
|
||||||
console.log(topic.split("/")[1]);
|
|
||||||
|
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) => {
|
client.on("error", (err) => {
|
||||||
console.error(`MQTT error: ${err}`);
|
console.error(`MQTT error: ${err}`);
|
||||||
if (client) {
|
if (client) {
|
||||||
client.end(); // Close connection on error
|
client.end();
|
||||||
}
|
}
|
||||||
client = null;
|
client = null;
|
||||||
// Implement re-connection logic here if needed
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("close", () => {
|
client.on("close", () => {
|
||||||
console.log("MQTT connection closed.");
|
console.log("MQTT connection closed.");
|
||||||
client = null;
|
client = null;
|
||||||
// Implement re-connection logic here if needed
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to MQTT when the server starts
|
|
||||||
connectMqtt();
|
connectMqtt();
|
||||||
|
|
||||||
/** @type {import("./$types").RequestHandler} */
|
/** @type {import("./$types").RequestHandler} */
|
||||||
|
@ -70,24 +155,89 @@ export async function GET({ request }) {
|
||||||
return new Response(
|
return new Response(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
start(controller) {
|
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) => {
|
const unsubscribe = latestMessage.subscribe((message) => {
|
||||||
if (message !== "No message yet") {
|
if (message !== "No message yet" && isConnected) {
|
||||||
const data = `data: ${JSON.stringify({ message: message })}\n\n`;
|
try {
|
||||||
|
const data = `data: ${JSON.stringify({
|
||||||
|
message: message,
|
||||||
|
devices: getCurrentDevices(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})}\n\n`;
|
||||||
|
if (isConnected) {
|
||||||
controller.enqueue(data);
|
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", () => {
|
request.signal.addEventListener("abort", () => {
|
||||||
console.log("Client disconnected from SSE stream.");
|
console.log("Client disconnected from MQTT SSE stream");
|
||||||
unsubscribe(); // Stop listening to store updates
|
cleanup();
|
||||||
controller.close();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
console.log("SSE stream cancelled.");
|
console.log("MQTT SSE stream cancelled");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ headers },
|
{ headers },
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue