From 3221e95d0e21d273ea132864dc564c85dd619298 Mon Sep 17 00:00:00 2001 From: Kostas Drakontidis Date: Mon, 1 Jun 2026 02:30:59 +0300 Subject: [PATCH] prettier format --- .../src/api/livedata/livedata.controller.ts | 44 +-- .../src/api/livedata/livedata.repository.ts | 30 +- .../src/api/lookup/lookup.repository.ts | 2 +- .../src/api/management/adminjs.routes.ts | 115 ++++--- .../src/api/management/components.ts | 30 +- .../api/management/components/Dashboard.tsx | 294 +++++++++++------- .../components/FaceEmbeddingField.tsx | 36 ++- .../api/management/components/UploadFace.tsx | 86 ++--- .../resources/adminjs.room.resource.ts | 2 +- .../resources/adminjs.tag.resource.ts | 2 +- .../resources/adminjs.user.resource.ts | 218 ++++++------- .../src/api/mapper/mapper.controller.ts | 4 +- .../src/api/mapper/mapper.repository.ts | 36 ++- src/officesense_pi/src/config/config.ts | 6 +- src/officesense_pi/src/core/analyze.ts | 87 +++--- src/officesense_pi/src/core/bootstrap.ts | 5 +- src/officesense_pi/src/core/camera.ts | 156 +++++----- src/officesense_pi/src/core/mqtt.ts | 13 +- src/officesense_pi/src/core/transition.ts | 15 +- src/officesense_pi/src/index.ts | 9 +- src/officesense_pi/src/lib/prisma.ts | 6 +- src/officesense_pi/src/redis/redis.ts | 6 +- 22 files changed, 634 insertions(+), 568 deletions(-) diff --git a/src/officesense_pi/src/api/livedata/livedata.controller.ts b/src/officesense_pi/src/api/livedata/livedata.controller.ts index ae0a9bc..335e937 100644 --- a/src/officesense_pi/src/api/livedata/livedata.controller.ts +++ b/src/officesense_pi/src/api/livedata/livedata.controller.ts @@ -1,5 +1,10 @@ import type { Request, Response, NextFunction } from "express"; -import { getActiveUserData, getRedisRoomData, type RedisRoomData, type RedisUserData } from "./livedata.repository.js"; +import { + getActiveUserData, + getRedisRoomData, + type RedisRoomData, + type RedisUserData, +} from "./livedata.repository.js"; import { prisma } from "../../lib/prisma.js"; interface Params { @@ -44,11 +49,11 @@ function convertToNGSIUser(user: RedisUserData): User { type: "User", name: { type: "Property", - value: user.name + value: user.name, }, locatedIn: { type: "Relationship", - object: user.room + object: user.room, }, rssi: { type: "Property", @@ -56,20 +61,20 @@ function convertToNGSIUser(user: RedisUserData): User { }, authenticationStatus: { type: "Property", - value: user.verified ? "verified" : "unverified" + value: user.verified ? "verified" : "unverified", }, observedAt: { type: "Property", - value: (new Date(user.timestamp)).toISOString() - } - } + value: new Date(user.timestamp).toISOString(), + }, + }; } async function convertToNGSIRoom(room: RedisRoomData): Promise { const roomName = await prisma.room.findUnique({ where: { id: room.roomID }, - select: { name: true } - }) + select: { name: true }, + }); if (!roomName?.name) return null; @@ -78,13 +83,13 @@ async function convertToNGSIRoom(room: RedisRoomData): Promise { type: "Room", name: { type: "Property", - value: roomName.name + value: roomName.name, }, occupancy: { type: "Property", - value: room.occupancy - } - } + value: room.occupancy, + }, + }; } async function getNGSIUsers(): Promise { @@ -121,8 +126,8 @@ export async function getEntities(req: Request, res: Response, next: NextFunctio let entities: Entity[] = []; - entities.push(...await getNGSIUsers()); - entities.push(...await getNGSIRooms()); + entities.push(...(await getNGSIUsers())); + entities.push(...(await getNGSIRooms())); return res.status(200).send(entities); } @@ -131,8 +136,10 @@ export async function getEntitiesOfType(req: Request, res: Response) { const { type } = req.query; switch (type) { - case "user": return res.status(200).send(await getNGSIUsers()); - case "room": return res.status(200).send(await getNGSIRooms()); + case "user": + return res.status(200).send(await getNGSIUsers()); + case "room": + return res.status(200).send(await getNGSIRooms()); default: return res.sendStatus(400); } @@ -167,6 +174,5 @@ export async function getEntity(req: Request, res: Response) { if (!data.length) return res.sendStatus(404); return res.status(200).send(convertToNGSIUser(data[0]!)); - } else - return res.sendStatus(400); + } else return res.sendStatus(400); } diff --git a/src/officesense_pi/src/api/livedata/livedata.repository.ts b/src/officesense_pi/src/api/livedata/livedata.repository.ts index fc3ebbd..0268bdb 100644 --- a/src/officesense_pi/src/api/livedata/livedata.repository.ts +++ b/src/officesense_pi/src/api/livedata/livedata.repository.ts @@ -30,15 +30,9 @@ export async function getActiveUserData(userID?: string): Promise redis.get(key)) - ); - - result.push(...values - .filter((v): v is string => v !== null) - .map((v) => JSON.parse(v)) - ); + const values = await Promise.all(res.keys.map((key) => redis.get(key))); + result.push(...values.filter((v): v is string => v !== null).map((v) => JSON.parse(v))); } while (cursor !== "0"); return result; @@ -79,14 +73,14 @@ export async function getRedisRoomData(roomID?: string): Promise v.occupancy !== null) - .map((v) => ({ - roomID: v.roomID.slice(5), - occupancy: Number(v.occupancy), - })) + result.push( + ...values + .filter((v) => v.occupancy !== null) + .map((v) => ({ + roomID: v.roomID.slice(5), + occupancy: Number(v.occupancy), + })) ); - } while (cursor !== "0"); return result; @@ -99,8 +93,8 @@ export async function getRedisRoomData(roomID?: string): Promise { +export async function getRoomIDs(): Promise<{ id: string }[]> { return await prisma.room.findMany({ - select: { id: true } + select: { id: true }, }); -} \ No newline at end of file +} diff --git a/src/officesense_pi/src/api/lookup/lookup.repository.ts b/src/officesense_pi/src/api/lookup/lookup.repository.ts index f50f025..189b58a 100644 --- a/src/officesense_pi/src/api/lookup/lookup.repository.ts +++ b/src/officesense_pi/src/api/lookup/lookup.repository.ts @@ -2,7 +2,7 @@ import { prisma } from "../../lib/prisma.js"; export async function existsByUUID(uuid: string): Promise { const tag = await prisma.tag.findUnique({ - where: { id: uuid } + where: { id: uuid }, }); return !!tag; diff --git a/src/officesense_pi/src/api/management/adminjs.routes.ts b/src/officesense_pi/src/api/management/adminjs.routes.ts index 7f1b6c9..8299c42 100644 --- a/src/officesense_pi/src/api/management/adminjs.routes.ts +++ b/src/officesense_pi/src/api/management/adminjs.routes.ts @@ -1,59 +1,56 @@ -import AdminJS from "adminjs"; -import AdminJSExpress from "@adminjs/express"; -import * as AdminJSPrisma from "@adminjs/prisma"; -import session from "express-session"; -import { componentLoader, Components } from './components.js' - -import userResource from "./resources/adminjs.user.resource.js"; -import tagResource from "./resources/adminjs.tag.resource.js"; -import roomResource from "./resources/adminjs.room.resource.js"; - -AdminJS.registerAdapter({ - Database: AdminJSPrisma.Database, - Resource: AdminJSPrisma.Resource, -}) - -async function createAdmin() { - const admin = new AdminJS({ - rootPath: "/admin", - resources: [userResource, tagResource, roomResource], - branding: { companyName: "OfficeSense", logo: false }, - dashboard: { component: Components.Dashboard }, - componentLoader, - }); - - await admin.watch(); - - const router = AdminJSExpress.buildAuthenticatedRouter( - admin, - { - authenticate: async (email, password) => { - if ( - email === process.env.ADMIN_EMAIL && - password === process.env.ADMIN_PASSWORD - ) { - return { email } - } - return null - }, - cookieName: 'adminjs', - cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? 'change-me', - }, - null, - { - resave: false, - saveUninitialized: false, - secret: process.env.ADMIN_COOKIE_SECRET ?? 'change-me', - } - ) - - return { admin, router } -} - -const sessionMiddleware = session({ - secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me", - resave: false, - saveUninitialized: false, -}); - -export { createAdmin, sessionMiddleware } \ No newline at end of file +import AdminJS from "adminjs"; +import AdminJSExpress from "@adminjs/express"; +import * as AdminJSPrisma from "@adminjs/prisma"; +import session from "express-session"; +import { componentLoader, Components } from "./components.js"; + +import userResource from "./resources/adminjs.user.resource.js"; +import tagResource from "./resources/adminjs.tag.resource.js"; +import roomResource from "./resources/adminjs.room.resource.js"; + +AdminJS.registerAdapter({ + Database: AdminJSPrisma.Database, + Resource: AdminJSPrisma.Resource, +}); + +async function createAdmin() { + const admin = new AdminJS({ + rootPath: "/admin", + resources: [userResource, tagResource, roomResource], + branding: { companyName: "OfficeSense", logo: false }, + dashboard: { component: Components.Dashboard }, + componentLoader, + }); + + await admin.watch(); + + const router = AdminJSExpress.buildAuthenticatedRouter( + admin, + { + authenticate: async (email, password) => { + if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASSWORD) { + return { email }; + } + return null; + }, + cookieName: "adminjs", + cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? "change-me", + }, + null, + { + resave: false, + saveUninitialized: false, + secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me", + } + ); + + return { admin, router }; +} + +const sessionMiddleware = session({ + secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me", + resave: false, + saveUninitialized: false, +}); + +export { createAdmin, sessionMiddleware }; diff --git a/src/officesense_pi/src/api/management/components.ts b/src/officesense_pi/src/api/management/components.ts index 93ea60a..fde3693 100644 --- a/src/officesense_pi/src/api/management/components.ts +++ b/src/officesense_pi/src/api/management/components.ts @@ -1,13 +1,25 @@ -import { ComponentLoader } from 'adminjs' -import { fileURLToPath } from 'url' -import path from 'path' +import { ComponentLoader } from "adminjs"; +import { fileURLToPath } from "url"; +import path from "path"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export const componentLoader = new ComponentLoader() +export const componentLoader = new ComponentLoader(); export const Components = { - Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'), - UploadFace: componentLoader.add('UploadFace', path.join(__dirname, './components/UploadFace.tsx'), 'components'), - FaceEmbeddingField: componentLoader.add('FaceEmbeddingField', path.join(__dirname, './components/FaceEmbeddingField.tsx'), 'components'), -} \ No newline at end of file + Dashboard: componentLoader.add( + "Dashboard", + path.join(__dirname, "./components/Dashboard.tsx"), + "components" + ), + UploadFace: componentLoader.add( + "UploadFace", + path.join(__dirname, "./components/UploadFace.tsx"), + "components" + ), + FaceEmbeddingField: componentLoader.add( + "FaceEmbeddingField", + path.join(__dirname, "./components/FaceEmbeddingField.tsx"), + "components" + ), +}; diff --git a/src/officesense_pi/src/api/management/components/Dashboard.tsx b/src/officesense_pi/src/api/management/components/Dashboard.tsx index 2f9cb18..aabf1f9 100644 --- a/src/officesense_pi/src/api/management/components/Dashboard.tsx +++ b/src/officesense_pi/src/api/management/components/Dashboard.tsx @@ -1,121 +1,173 @@ -import React, { useEffect, useState } from 'react' -import { Box, H2, Text, Loader } from '@adminjs/design-system' -import { ApiClient } from 'adminjs' - -type Session = { - realUserId: string - firstName: string - lastName: string - pseudoId: string - pseudoName: string -} - -const Dashboard = () => { - const [sessions, setSessions] = useState([]) - const [loading, setLoading] = useState(true) - const [updated, setUpdated] = useState('') - - const load = async () => { - try { - const res = await fetch('/admin/map', { credentials: 'include' }) - const data = await res.json() - setSessions(data) - setUpdated(new Date().toLocaleTimeString()) - } catch (e) { - console.error(e) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - const interval = setInterval(load, 10000) - return () => clearInterval(interval) - }, []) - - const initials = (first: string, last: string) => - ((first?.[0] ?? '') + (last?.[0] ?? '')).toUpperCase() || '?' - - return ( - - - -

OfficeSense Admin Dashboard

- Live Redis sessions mapped to registered users -
- - Updated {updated} - - Refresh - - -
- - {loading ? ( - - ) : sessions.length === 0 ? ( - No active sessions. - ) : ( - - - - - {['User', 'Real UUID', 'Pseudo UUID', 'Pseudo name'].map(h => ( - - ))} - - - - {sessions.map(s => ( - - - - - - - ))} - -
{h}
-
-
- {initials(s.firstName, s.lastName)} -
- {s.firstName} {s.lastName} -
-
{s.realUserId}{s.pseudoId}{s.pseudoName ?? '—'}
-
- )} -
- ) -} - -export default Dashboard \ No newline at end of file +import React, { useEffect, useState } from "react"; +import { Box, H2, Text, Loader } from "@adminjs/design-system"; +import { ApiClient } from "adminjs"; + +type Session = { + realUserId: string; + firstName: string; + lastName: string; + pseudoId: string; + pseudoName: string; +}; + +const Dashboard = () => { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [updated, setUpdated] = useState(""); + + const load = async () => { + try { + const res = await fetch("/admin/map", { credentials: "include" }); + const data = await res.json(); + setSessions(data); + setUpdated(new Date().toLocaleTimeString()); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + const interval = setInterval(load, 10000); + return () => clearInterval(interval); + }, []); + + const initials = (first: string, last: string) => + ((first?.[0] ?? "") + (last?.[0] ?? "")).toUpperCase() || "?"; + + return ( + + + +

OfficeSense Admin Dashboard

+ Live Redis sessions mapped to registered users +
+ + + Updated {updated} + + + Refresh + + +
+ + {loading ? ( + + ) : sessions.length === 0 ? ( + No active sessions. + ) : ( + + + + + {["User", "Real UUID", "Pseudo UUID", "Pseudo name"].map((h) => ( + + ))} + + + + {sessions.map((s) => ( + + + + + + + ))} + +
+ {h} +
+
+
+ {initials(s.firstName, s.lastName)} +
+ {s.firstName} {s.lastName} +
+
+ {s.realUserId} + + {s.pseudoId} + {s.pseudoName ?? "—"}
+
+ )} +
+ ); +}; + +export default Dashboard; diff --git a/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx b/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx index 2b8caed..c1635a0 100644 --- a/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx +++ b/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx @@ -1,26 +1,28 @@ -import React from 'react' -import { BasePropertyProps } from 'adminjs' -import { Label } from '@adminjs/design-system' +import React from "react"; +import { BasePropertyProps } from "adminjs"; +import { Label } from "@adminjs/design-system"; const FaceEmbeddingField: React.FC = ({ record, property }) => { - const has = !!record?.params?.faceEmbedding + const has = !!record?.params?.faceEmbedding; return ( -
+
- - {has ? 'SET' : 'UNSET'} + + {has ? "SET" : "UNSET"}
- ) -} + ); +}; -export default FaceEmbeddingField \ No newline at end of file +export default FaceEmbeddingField; diff --git a/src/officesense_pi/src/api/management/components/UploadFace.tsx b/src/officesense_pi/src/api/management/components/UploadFace.tsx index 3dc6e58..86c9591 100644 --- a/src/officesense_pi/src/api/management/components/UploadFace.tsx +++ b/src/officesense_pi/src/api/management/components/UploadFace.tsx @@ -1,48 +1,50 @@ -import React, { useState, useRef } from 'react' -import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system' -import { ActionProps, useRecord } from 'adminjs' +import React, { useState, useRef } from "react"; +import { Box, H3, Text, Button, MessageBox } from "@adminjs/design-system"; +import { ActionProps, useRecord } from "adminjs"; const UploadFace: React.FC = ({ record, action }) => { - const [preview, setPreview] = useState(null) - const [base64, setBase64] = useState(null) - const [loading, setLoading] = useState(false) - const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null) - const inputRef = useRef(null) + const [preview, setPreview] = useState(null); + const [base64, setBase64] = useState(null); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState<{ message: string; type: "success" | "error" } | null>( + null + ); + const inputRef = useRef(null); const handleFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const reader = new FileReader() + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); reader.onload = () => { - const result = reader.result as string - setPreview(result) - setBase64(result.split(',')[1]) // strip data:image/jpeg;base64, - } - reader.readAsDataURL(file) - } + const result = reader.result as string; + setPreview(result); + setBase64(result.split(",")[1]); // strip data:image/jpeg;base64, + }; + reader.readAsDataURL(file); + }; const handleSubmit = async () => { - if (!base64) return - setLoading(true) - setNotice(null) + if (!base64) return; + setLoading(true); + setNotice(null); try { const res = await fetch( `/admin/api/resources/User/records/${record?.params.id}/uploadFace`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ base64 }), } - ) - const data = await res.json() - setNotice(data.notice) + ); + const data = await res.json(); + setNotice(data.notice); } catch (e: any) { - setNotice({ message: e.message, type: 'error' }) + setNotice({ message: e.message, type: "error" }); } finally { - setLoading(false) + setLoading(false); } - } + }; return ( @@ -54,7 +56,7 @@ const UploadFace: React.FC = ({ record, action }) => { {notice && ( )} @@ -65,7 +67,7 @@ const UploadFace: React.FC = ({ record, action }) => { type="file" accept="image/jpeg" onChange={handleFile} - style={{ display: 'none' }} + style={{ display: "none" }} /> - ) -} + ); +}; -export default UploadFace \ No newline at end of file +export default UploadFace; diff --git a/src/officesense_pi/src/api/management/resources/adminjs.room.resource.ts b/src/officesense_pi/src/api/management/resources/adminjs.room.resource.ts index 09136cc..a4befe4 100644 --- a/src/officesense_pi/src/api/management/resources/adminjs.room.resource.ts +++ b/src/officesense_pi/src/api/management/resources/adminjs.room.resource.ts @@ -13,4 +13,4 @@ export default { editProperties: ["name"], filterProperties: ["name"], }, -} \ No newline at end of file +}; diff --git a/src/officesense_pi/src/api/management/resources/adminjs.tag.resource.ts b/src/officesense_pi/src/api/management/resources/adminjs.tag.resource.ts index 6703a67..736d6b1 100644 --- a/src/officesense_pi/src/api/management/resources/adminjs.tag.resource.ts +++ b/src/officesense_pi/src/api/management/resources/adminjs.tag.resource.ts @@ -18,4 +18,4 @@ export default { }, }, }, -} \ No newline at end of file +}; diff --git a/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts b/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts index a7d8b70..4ea32c6 100644 --- a/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts +++ b/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts @@ -1,109 +1,109 @@ -import { prisma } from "../../../lib/prisma.js"; -import { getModelByName } from "@adminjs/prisma"; -import { spawn } from "child_process"; -import { Components } from '../components.js' -import { fileURLToPath } from 'url' -import path from 'path' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const execPath = '../../../../bin'; -const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings') - -async function extractEmbedding(base64: string): Promise { - return new Promise((resolve, reject) => { - const proc = spawn(binaryPath, [], { - cwd: path.join(__dirname, execPath) - }) - let stdout = '' - let stderr = '' - - proc.stdout.on('data', (d) => stdout += d) - proc.stderr.on('data', (d) => stderr += d) - - proc.stdin.on('error', (err) => { }); - - proc.on('error', (err) => { - reject(new Error(`Failed to start binary: ${err.message}`)) - }) - - proc.on('close', (code) => { - console.log('extractEmbeddings exited with code', code) - console.log('stdout:', stdout) - console.log('stderr:', stderr) - if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)) - try { - const result = JSON.parse(stdout) - if (!result.success) return reject(new Error(result.error)) - resolve(JSON.stringify(result.descriptor)) - } catch (e) { - reject(new Error(`Failed to parse output: ${stdout}`)) - } - }) - - proc.stdin.write(base64) - proc.stdin.end() - }) -} - -export default { - resource: { - model: getModelByName("User"), - client: prisma, - }, - options: { - titleProperty: "id", - navigation: { icon: "User" }, - listProperties: ["id", "firstName", "lastName"], - showProperties: ["id", "firstName", "lastName", "faceEmbedding"], - editProperties: ["firstName", "lastName"], - filterProperties: ["firstName", "lastName"], - properties: { - faceEmbedding: { - isVisible: { list: false, show: true, edit: false, filter: false }, - type: "string", - components: { - list: Components.FaceEmbeddingField, - show: Components.FaceEmbeddingField, - }, - }, - }, - actions: { - uploadFace: { - actionType: 'record', - icon: 'Camera', - label: 'Upload face', - showInDrawer: true, - handler: async (request: any, response: any, context: any) => { - const { record, currentAdmin } = context - if (request.method === 'post') { - const { base64 } = request.payload - if (!base64) { - return { - record: record.toJSON(currentAdmin), - notice: { message: 'No image provided', type: 'error' }, - } - } - try { - const embedding = await extractEmbedding(base64) - await prisma.user.update({ - where: { id: record.params.id }, - data: { faceEmbedding: embedding }, - }) - return { - record: record.toJSON(currentAdmin), - notice: { message: 'Face embedding saved!', type: 'success' }, - } - } catch (e: any) { - return { - record: record.toJSON(currentAdmin), - notice: { message: `Failed: ${e.message}`, type: 'error' }, - } - } - } - return { record: record.toJSON(currentAdmin) } - }, - component: Components.UploadFace, - }, - }, - }, -} \ No newline at end of file +import { prisma } from "../../../lib/prisma.js"; +import { getModelByName } from "@adminjs/prisma"; +import { spawn } from "child_process"; +import { Components } from "../components.js"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const execPath = "../../../../bin"; +const binaryPath = path.join(__dirname, execPath, "extractEmbeddings"); + +async function extractEmbedding(base64: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(binaryPath, [], { + cwd: path.join(__dirname, execPath), + }); + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + + proc.stdin.on("error", (err) => {}); + + proc.on("error", (err) => { + reject(new Error(`Failed to start binary: ${err.message}`)); + }); + + proc.on("close", (code) => { + console.log("extractEmbeddings exited with code", code); + console.log("stdout:", stdout); + console.log("stderr:", stderr); + if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)); + try { + const result = JSON.parse(stdout); + if (!result.success) return reject(new Error(result.error)); + resolve(JSON.stringify(result.descriptor)); + } catch (e) { + reject(new Error(`Failed to parse output: ${stdout}`)); + } + }); + + proc.stdin.write(base64); + proc.stdin.end(); + }); +} + +export default { + resource: { + model: getModelByName("User"), + client: prisma, + }, + options: { + titleProperty: "id", + navigation: { icon: "User" }, + listProperties: ["id", "firstName", "lastName"], + showProperties: ["id", "firstName", "lastName", "faceEmbedding"], + editProperties: ["firstName", "lastName"], + filterProperties: ["firstName", "lastName"], + properties: { + faceEmbedding: { + isVisible: { list: false, show: true, edit: false, filter: false }, + type: "string", + components: { + list: Components.FaceEmbeddingField, + show: Components.FaceEmbeddingField, + }, + }, + }, + actions: { + uploadFace: { + actionType: "record", + icon: "Camera", + label: "Upload face", + showInDrawer: true, + handler: async (request: any, response: any, context: any) => { + const { record, currentAdmin } = context; + if (request.method === "post") { + const { base64 } = request.payload; + if (!base64) { + return { + record: record.toJSON(currentAdmin), + notice: { message: "No image provided", type: "error" }, + }; + } + try { + const embedding = await extractEmbedding(base64); + await prisma.user.update({ + where: { id: record.params.id }, + data: { faceEmbedding: embedding }, + }); + return { + record: record.toJSON(currentAdmin), + notice: { message: "Face embedding saved!", type: "success" }, + }; + } catch (e: any) { + return { + record: record.toJSON(currentAdmin), + notice: { message: `Failed: ${e.message}`, type: "error" }, + }; + } + } + return { record: record.toJSON(currentAdmin) }; + }, + component: Components.UploadFace, + }, + }, + }, +}; diff --git a/src/officesense_pi/src/api/mapper/mapper.controller.ts b/src/officesense_pi/src/api/mapper/mapper.controller.ts index f6e9c2c..68ba625 100644 --- a/src/officesense_pi/src/api/mapper/mapper.controller.ts +++ b/src/officesense_pi/src/api/mapper/mapper.controller.ts @@ -3,7 +3,7 @@ import { generateMap } from "./mapper.repository.js"; export function checkSession(req: Request, res: Response, next: NextFunction) { if (!(req.session as any)?.adminUser) { - return res.status(401).json({ error: 'Unauthorized' }) + return res.status(401).json({ error: "Unauthorized" }); } next(); } @@ -12,6 +12,6 @@ export async function getRedisMapping(req: Request, res: Response) { try { res.status(200).send(await generateMap()); } catch (err) { - res.status(500).json({ error: 'Failed to fetch sessions' }) + res.status(500).json({ error: "Failed to fetch sessions" }); } } diff --git a/src/officesense_pi/src/api/mapper/mapper.repository.ts b/src/officesense_pi/src/api/mapper/mapper.repository.ts index 3a03969..86652b4 100644 --- a/src/officesense_pi/src/api/mapper/mapper.repository.ts +++ b/src/officesense_pi/src/api/mapper/mapper.repository.ts @@ -4,26 +4,28 @@ import { prisma } from "../../lib/prisma.js"; export async function generateMap() { const redis = getRedis(); - const keys = await redis.keys('user:*') + const keys = await redis.keys("user:*"); - const sessions = await Promise.all(keys.map(async (key) => { - const raw = await redis.get(key) - const session = JSON.parse(raw!) - const realUserId = key.replace('user:', '') + const sessions = await Promise.all( + keys.map(async (key) => { + const raw = await redis.get(key); + const session = JSON.parse(raw!); + const realUserId = key.replace("user:", ""); - const user = await prisma.user.findUnique({ - where: { id: realUserId }, - select: { id: true, firstName: true, lastName: true } + const user = await prisma.user.findUnique({ + where: { id: realUserId }, + select: { id: true, firstName: true, lastName: true }, + }); + + return { + realUserId, + firstName: user?.firstName ?? "Unknown", + lastName: user?.lastName ?? "Unknown", + pseudoId: session.userID, + pseudoName: session.name, + }; }) - - return { - realUserId, - firstName: user?.firstName ?? 'Unknown', - lastName: user?.lastName ?? 'Unknown', - pseudoId: session.userID, - pseudoName: session.name - } - })) + ); return sessions; } diff --git a/src/officesense_pi/src/config/config.ts b/src/officesense_pi/src/config/config.ts index 1cdd242..7aaf351 100644 --- a/src/officesense_pi/src/config/config.ts +++ b/src/officesense_pi/src/config/config.ts @@ -52,8 +52,8 @@ const config: Config = { transitionCleanupInterval: 60 * 1000, lossThreshold: 5000, userTTL: 3 * 60 * 1000, - verifyTimeout: 30 * 1000 + verifyTimeout: 30 * 1000, }, -} +}; -export default config; \ No newline at end of file +export default config; diff --git a/src/officesense_pi/src/core/analyze.ts b/src/officesense_pi/src/core/analyze.ts index 51e4176..e9d9dc3 100644 --- a/src/officesense_pi/src/core/analyze.ts +++ b/src/officesense_pi/src/core/analyze.ts @@ -4,25 +4,9 @@ import { RoomTransition, transitions } from "./transition.js"; import config from "../config/config.js"; import { type RedisUserData } from "../api/livedata/livedata.repository.js"; -const adjectives = [ - "Crazy", - "Silent", - "Dark", - "Fast", - "Lucky", - "Wild", - "Epic" -]; +const adjectives = ["Crazy", "Silent", "Dark", "Fast", "Lucky", "Wild", "Epic"]; -const nouns = [ - "Tiger", - "Wolf", - "Falcon", - "Shadow", - "Ninja", - "Dragon", - "Phoenix" -]; +const nouns = ["Tiger", "Wolf", "Falcon", "Shadow", "Ninja", "Dragon", "Phoenix"]; const running = new Set(); const userRooms = new Map(); @@ -38,7 +22,7 @@ export async function updateRoomOccupancyListener(message: string, channel: stri const redis = getRedis(); if (!lastRoomID) { - console.log("[!] Key not found in local map") + console.log("[!] Key not found in local map"); } else { await redis.decr(`room:${lastRoomID}`); } @@ -50,28 +34,29 @@ export async function updateRoomOccupancyListener(message: string, channel: stri } function generateNickname() { - const adjective = - adjectives[Math.floor(Math.random() * adjectives.length)]; + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; - const noun = - nouns[Math.floor(Math.random() * nouns.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; const number = Math.floor(Math.random() * 1000); return `${adjective}${noun}${number}`; } -function newUser(): { pseudoID: string, psuedoName: string } { +function newUser(): { pseudoID: string; psuedoName: string } { return { pseudoID: crypto.randomUUID(), - psuedoName: generateNickname() + psuedoName: generateNickname(), }; } -export async function analyzeData(roomID: string, metrics: { - tagID: string, - rssi: number -}) { +export async function analyzeData( + roomID: string, + metrics: { + tagID: string; + rssi: number; + } +) { if (running.has(metrics.tagID)) { console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`); return; @@ -80,9 +65,11 @@ export async function analyzeData(roomID: string, metrics: { running.add(metrics.tagID); try { - console.log(`\n=======================================================\n` + - `ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` + - `=======================================================\n`); + console.log( + `\n=======================================================\n` + + `ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` + + `=======================================================\n` + ); const userID = ( await prisma.tag.findUnique({ @@ -93,8 +80,7 @@ export async function analyzeData(roomID: string, metrics: { console.log(`[SQLite] Resolved USER_ID: ${userID}`); - if (!userID) - return; + if (!userID) return; const redis = getRedis(); const res = await redis.get(`user:${userID}`); @@ -103,14 +89,18 @@ export async function analyzeData(roomID: string, metrics: { const user = newUser(); console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`); - await redis.set(`user:${userID}`, JSON.stringify({ - userID: user.pseudoID, - name: user.psuedoName, - rssi: metrics.rssi, - room: roomID, - verified: false, - timestamp: Date.now() - }), { PX: config.core.userTTL }); + await redis.set( + `user:${userID}`, + JSON.stringify({ + userID: user.pseudoID, + name: user.psuedoName, + rssi: metrics.rssi, + room: roomID, + verified: false, + timestamp: Date.now(), + }), + { PX: config.core.userTTL } + ); await redis.set(`_user:${user.pseudoID}`, userID); @@ -130,7 +120,7 @@ export async function analyzeData(roomID: string, metrics: { if (roomID == resObj["room"]) { console.log("[Redis] Same room, updating redis..."); resObj["rssi"] = metrics.rssi; - resObj["timestamp"] = Date.now() + resObj["timestamp"] = Date.now(); await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL }); @@ -145,7 +135,9 @@ export async function analyzeData(roomID: string, metrics: { transitions.set(userID, transition); } - if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) { + if ( + transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"]) + ) { console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`); await redis.decr(`room:${resObj["room"]}`); @@ -155,13 +147,12 @@ export async function analyzeData(roomID: string, metrics: { resObj["rssi"] = metrics.rssi; resObj["room"] = roomID; - resObj["timestamp"] = Date.now() + resObj["timestamp"] = Date.now(); resObj["verified"] = false; await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL }); - } else - console.log("[!] Transition declined"); + } else console.log("[!] Transition declined"); } finally { running.delete(metrics.tagID); } -} \ No newline at end of file +} diff --git a/src/officesense_pi/src/core/bootstrap.ts b/src/officesense_pi/src/core/bootstrap.ts index 0748b87..3d115f0 100644 --- a/src/officesense_pi/src/core/bootstrap.ts +++ b/src/officesense_pi/src/core/bootstrap.ts @@ -12,8 +12,7 @@ export async function bootstrap() { console.log(`[!] Flushed Redis.`); const rooms = await getRoomIDs(); - for (const { id } of rooms) - await redis.set(`room:${id}`, 0); + for (const { id } of rooms) await redis.set(`room:${id}`, 0); await initSubRedis(); await mqtt.start(); @@ -27,4 +26,4 @@ export async function bootstrap() { console.log("[!] Bootstrap failed:", err.message); process.exit(1); } -} \ No newline at end of file +} diff --git a/src/officesense_pi/src/core/camera.ts b/src/officesense_pi/src/core/camera.ts index 9ea4a4e..7eb1821 100644 --- a/src/officesense_pi/src/core/camera.ts +++ b/src/officesense_pi/src/core/camera.ts @@ -1,43 +1,47 @@ -import { fileURLToPath } from 'url' -import path from 'path' +import { fileURLToPath } from "url"; +import path from "path"; import { spawn } from "child_process"; -import { getRedis } from '../redis/redis.js'; -import { prisma } from '../lib/prisma.js'; -import config from '../config/config.js'; +import { getRedis } from "../redis/redis.js"; +import { prisma } from "../lib/prisma.js"; +import config from "../config/config.js"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const execPath = '../../bin'; -const binaryPath = path.join(__dirname, execPath, 'cameraSession') +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const execPath = "../../bin"; +const binaryPath = path.join(__dirname, execPath, "cameraSession"); type InputPacket = { - uuid: string - descriptor: number[] -} + uuid: string; + descriptor: number[]; +}; type ResultPacket = { - uuid: string - verified: boolean -} + uuid: string; + verified: boolean; +}; export async function getUnverifiedUserKeys(): Promise { - const redis = getRedis() + const redis = getRedis(); - const keys = await redis.keys('user:*') + const keys = await redis.keys("user:*"); - const unverified: string[] = [] + const unverified: string[] = []; - await Promise.all(keys.map(async (key) => { - const raw = await redis.get(key) - if (!raw) return - const session = JSON.parse(raw) - if (!session.verified) unverified.push(key.replace('user:', '')) - })) + await Promise.all( + keys.map(async (key) => { + const raw = await redis.get(key); + if (!raw) return; + const session = JSON.parse(raw); + if (!session.verified) unverified.push(key.replace("user:", "")); + }) + ); - return unverified + return unverified; } -export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string, descriptor: number[] }[]> { - const userIds = await getUnverifiedUserKeys() +export async function getUnverifiedUsersWithEmbeddings(): Promise< + { uuid: string; descriptor: number[] }[] +> { + const userIds = await getUnverifiedUserKeys(); const users = await prisma.user.findMany({ where: { @@ -45,88 +49,90 @@ export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string faceEmbedding: { not: null }, }, select: { id: true, faceEmbedding: true }, - }) + }); return users.map((user) => ({ uuid: user.id, descriptor: JSON.parse(user.faceEmbedding!), - })) + })); } async function cameraSession(packets: InputPacket[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(binaryPath, [], { - cwd: path.join(__dirname, execPath) - }) - let stdout = '' - let stderr = '' + cwd: path.join(__dirname, execPath), + }); + let stdout = ""; + let stderr = ""; - proc.stdout.on('data', (d) => stdout += d) - proc.stderr.on('data', (d) => stderr += d) - proc.stdin.on('error', () => { }) + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + proc.stdin.on("error", () => {}); - proc.on('error', (err) => { - reject(new Error(`Failed to start binary: ${err.message}`)) - }) + proc.on("error", (err) => { + reject(new Error(`Failed to start binary: ${err.message}`)); + }); - proc.on('close', (code) => { - console.log('cameraSession exited with code', code) - const trimmed = stdout.trim() - if (trimmed === 'no face found.') return resolve(null) - if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)) + proc.on("close", (code) => { + console.log("cameraSession exited with code", code); + const trimmed = stdout.trim(); + if (trimmed === "no face found.") return resolve(null); + if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)); try { - const results: ResultPacket[] = JSON.parse(trimmed) - resolve(results) + const results: ResultPacket[] = JSON.parse(trimmed); + resolve(results); } catch (e) { - reject(new Error(`Failed to parse output: ${trimmed}`)) + reject(new Error(`Failed to parse output: ${trimmed}`)); } - }) + }); for (const packet of packets) { - proc.stdin.write(JSON.stringify(packet) + '\n') + proc.stdin.write(JSON.stringify(packet) + "\n"); } - proc.stdin.end() - }) + proc.stdin.end(); + }); } const setUnverified = async (uuid: string) => { - const redis = getRedis() - const key = `user:${uuid}` - const raw = await redis.get(key) - if (!raw) return - const session = JSON.parse(raw) - session.verified = false - await redis.set(key, JSON.stringify(session)) - console.log(`[Camera] Unverified user ${uuid} due to timeout`) -} + const redis = getRedis(); + const key = `user:${uuid}`; + const raw = await redis.get(key); + if (!raw) return; + const session = JSON.parse(raw); + session.verified = false; + await redis.set(key, JSON.stringify(session)); + console.log(`[Camera] Unverified user ${uuid} due to timeout`); +}; const run = async () => { const redis = getRedis(); try { - const packets = await getUnverifiedUsersWithEmbeddings() + const packets = await getUnverifiedUsersWithEmbeddings(); if (packets.length > 0) { - const results = await cameraSession(packets) + const results = await cameraSession(packets); if (results) { - await Promise.all(results.map(async (r) => { - if (!r.verified) return - const key = `user:${r.uuid}` - const raw = await redis.get(key) - if (!raw) return - const session = JSON.parse(raw) - session.verified = true - await redis.set(key, JSON.stringify(session)) + await Promise.all( + results.map(async (r) => { + if (!r.verified) return; + const key = `user:${r.uuid}`; + const raw = await redis.get(key); + if (!raw) return; + const session = JSON.parse(raw); + session.verified = true; + await redis.set(key, JSON.stringify(session)); - setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout) + setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout); - console.log(`[Camera] Verified user ${r.uuid}`) - })) + console.log(`[Camera] Verified user ${r.uuid}`); + }) + ); } } } catch (e) { - console.error('camera run error:', e) + console.error("camera run error:", e); } - setTimeout(run, 1000) -} + setTimeout(run, 1000); +}; export { run as initCamera }; diff --git a/src/officesense_pi/src/core/mqtt.ts b/src/officesense_pi/src/core/mqtt.ts index 747a1f4..8bb83f3 100644 --- a/src/officesense_pi/src/core/mqtt.ts +++ b/src/officesense_pi/src/core/mqtt.ts @@ -4,13 +4,10 @@ import { analyzeData } from "./analyze.js"; function initMqtt(): Promise { return new Promise((resolve, reject) => { - const client = mqtt.connect( - `mqtt://${config.mqtt.host}:${config.mqtt.port}`, - { - username: config.mqtt.username, - password: config.mqtt.password - } - ); + const client = mqtt.connect(`mqtt://${config.mqtt.host}:${config.mqtt.port}`, { + username: config.mqtt.username, + password: config.mqtt.password, + }); client.once("connect", () => { console.log("[MQTT] MQTT connected."); @@ -41,7 +38,7 @@ export async function start() { mqttClient.on("error", (err) => { console.log(err.message); - }) + }); } catch (err: any) { console.log("[MQTT] MQTT connection failed:", err.message); } diff --git a/src/officesense_pi/src/core/transition.ts b/src/officesense_pi/src/core/transition.ts index 7be7f29..704f507 100644 --- a/src/officesense_pi/src/core/transition.ts +++ b/src/officesense_pi/src/core/transition.ts @@ -9,8 +9,7 @@ export function cleanupWorker() { for (const [userID, transition] of transitions) { const inactiveFor = now - transition.lastSeen; - if (inactiveFor > 5 * config.core.transitionTTL) - transitions.delete(userID); + if (inactiveFor > 5 * config.core.transitionTTL) transitions.delete(userID); } }, config.core.transitionCleanupInterval); } @@ -31,7 +30,12 @@ export class RoomTransition { this.candidate = { roomID: null, since: null, samples: 0, rssi: null }; } - shouldTransitionTo(roomID: string, candidateRSSI: number, currRSSI: number, lastCurrentSeen: number) { + shouldTransitionTo( + roomID: string, + candidateRSSI: number, + currRSSI: number, + lastCurrentSeen: number + ) { const now = Date.now(); this.lastSeen = now; @@ -48,7 +52,8 @@ export class RoomTransition { } const noCandidate = this.candidate.roomID == null; - const betterCandidate = this.candidate.roomID != roomID && + const betterCandidate = + this.candidate.roomID != roomID && this.candidate.rssi !== null && candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis; @@ -91,4 +96,4 @@ export class RoomTransition { this.candidate.samples = 0; this.candidate.rssi = null; } -} \ No newline at end of file +} diff --git a/src/officesense_pi/src/index.ts b/src/officesense_pi/src/index.ts index 990b523..1201f57 100644 --- a/src/officesense_pi/src/index.ts +++ b/src/officesense_pi/src/index.ts @@ -1,8 +1,8 @@ import express from "express"; import lookupRouter from "./api/lookup/lookup.routes.js"; -import livedataRouter from "./api/livedata/livadata.routes.js" -import mapperRouter from "./api/mapper/mapper.routes.js" -import { createAdmin, sessionMiddleware } from './api/management/adminjs.routes.js' +import livedataRouter from "./api/livedata/livadata.routes.js"; +import mapperRouter from "./api/mapper/mapper.routes.js"; +import { createAdmin, sessionMiddleware } from "./api/management/adminjs.routes.js"; import config from "./config/config.js"; import { bootstrap } from "./core/bootstrap.js"; @@ -23,5 +23,4 @@ import { bootstrap } from "./core/bootstrap.js"; }); bootstrap(); - -})() +})(); diff --git a/src/officesense_pi/src/lib/prisma.ts b/src/officesense_pi/src/lib/prisma.ts index 7ededa5..a031944 100644 --- a/src/officesense_pi/src/lib/prisma.ts +++ b/src/officesense_pi/src/lib/prisma.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient() +const prisma = new PrismaClient(); -export { prisma }; \ No newline at end of file +export { prisma }; diff --git a/src/officesense_pi/src/redis/redis.ts b/src/officesense_pi/src/redis/redis.ts index d9826d7..bb03317 100644 --- a/src/officesense_pi/src/redis/redis.ts +++ b/src/officesense_pi/src/redis/redis.ts @@ -7,7 +7,7 @@ let subRedisClient: RedisClientType; export async function initSubRedis(): Promise { subRedisClient = createClient({ - url: `redis://${config.redis.host}:${config.redis.port}` + url: `redis://${config.redis.host}:${config.redis.port}`, }); subRedisClient.on("error", (err) => { @@ -25,7 +25,7 @@ export async function initSubRedis(): Promise { export async function initRedis(): Promise { redisClient = createClient({ - url: `redis://${config.redis.host}:${config.redis.port}` + url: `redis://${config.redis.host}:${config.redis.port}`, }); redisClient.on("error", (err) => { @@ -45,4 +45,4 @@ export function getRedis(): RedisClientType { } return redisClient; -} \ No newline at end of file +}