Author SHA1 Message Date
Kostas Drakontidis 98bffc4117 cameraSession integration 2026-06-01 02:24:33 +03:00
Kostas Drakontidis c7a0d921ea extractEmbeddings integration 2026-05-30 17:56:52 +03:00
Kostas Drakontidis a788ea2642 prettier 2026-05-30 17:56:21 +03:00
Kostas DrakontidisandGitHub 2fb15443ef Update progress.md with Week 5 details 2026-05-30 14:03:04 +03:00
Kostas DrakontidisandGitHub e3d8f41f02 Merge pull request #21 from dkwstas/adminjs
Adminjs
2026-05-30 05:50:51 +03:00
Kostas Drakontidis 83c9dbb9fa refactor 2026-05-30 05:49:28 +03:00
Kostas Drakontidis 3c7e419ffa session mapping private endpoint 2026-05-30 05:49:11 +03:00
Kostas Drakontidis 94d7b76c52 adminjs session map component 2026-05-30 05:48:58 +03:00
Kostas Drakontidis afbf67187f adminjs setup 2026-05-30 05:48:28 +03:00
Kostas Drakontidis 5004e86bee downgrade prisma & install adminjs 2026-05-30 05:47:05 +03:00
Kostas Drakontidis 6a7e705f1f downgrade to prisma 6 2026-05-30 05:46:15 +03:00
Kostas DrakontidisandGitHub 0f6728da40 Merge pull request #19 from dkwstas/vision_init
Vision init
2026-05-29 23:47:01 +03:00
Kostas Drakontidis 7e1a7d0eca verify faces from camera 2026-05-29 23:46:15 +03:00
Kostas Drakontidis 1ac5ddbf33 cross compile files for extract embeddings exec 2026-05-29 23:45:50 +03:00
Kostas Drakontidis 8dffdc9bf9 added go gitignore 2026-05-29 23:45:10 +03:00
Kostas Drakontidis 23116f6a19 move to subdir 2026-05-29 23:18:45 +03:00
Kostas Drakontidis 2badcb92e2 extract from jpeg 2026-05-29 02:02:48 +03:00
Kostas DrakontidisandGitHub 8abae7b9ba Update progress.md with new completed tasks 2026-05-25 06:04:28 +03:00
Kostas DrakontidisandGitHub 1bce98e083 Merge pull request #17 from dkwstas/nodered_config
nodered flow upload
2026-05-25 06:02:13 +03:00
Kostas Drakontidis 92b3c72e3e nodered flow upload 2026-05-25 06:01:47 +03:00
Kostas DrakontidisandGitHub 41cf0ad7ca Merge pull request #15 from dkwstas/public_api
Public api
2026-05-25 05:58:36 +03:00
Kostas Drakontidis 80ff81b1e0 room occupancy impl & race cond fix & local user roomID and psuedoID storage for cleanup and indexing 2026-05-25 05:57:35 +03:00
Kostas Drakontidis 46016d966d ngsi-ld public api code 2026-05-25 05:55:43 +03:00
Kostas Drakontidis c2b947a197 flush redis on startup and init subredis 2026-05-25 05:55:04 +03:00
Kostas Drakontidis 66ab3fa6ca added debug prefix 2026-05-25 05:54:37 +03:00
Kostas Drakontidis c8da529ce3 whitespace 2026-05-25 05:54:25 +03:00
Kostas Drakontidis 93c65a948b added routes 2026-05-25 05:54:17 +03:00
Kostas Drakontidis 124ef542b0 moved redis & added subredis for expire notifications 2026-05-25 05:53:53 +03:00
Kostas DrakontidisandGitHub 31f8dc2f98 Update progress.md with Week 4 details 2026-05-24 00:25:38 +03:00
Kostas DrakontidisandGitHub 77f53435fe Merge pull request #14 from dkwstas/core_dev
Core dev
2026-05-24 00:21:14 +03:00
Kostas Drakontidis 565900c9d4 init fully working core code 2026-05-24 00:19:09 +03:00
Kostas Drakontidis a3d28e8cd5 prisma migrations 2026-05-24 00:18:31 +03:00
Kostas Drakontidis 9d024de5a6 get data from tag table instead of user 2026-05-24 00:04:43 +03:00
Kostas Drakontidis 18932a3f4d add room entity 2026-05-24 00:03:59 +03:00
Kostas Drakontidis e4868f6fdc install mqtt & redis packages 2026-05-24 00:03:51 +03:00
Kostas Drakontidis d4cf7f5b48 mqtt topic and msg small change 2026-05-23 23:43:28 +03:00
Kostas Drakontidis c8f07e22d9 Add reset command scanner 2026-05-22 13:03:47 +03:00
Kostas DrakontidisandGitHub 22f4d777a4 Merge pull request #13 from dkwstas/persistent_config
Closes #11
2026-05-20 17:46:57 +03:00
49 changed files with 9337 additions and 1208 deletions
+21
View File
@@ -45,3 +45,24 @@
### Next Steps
- WiFi self-healing and MQTT reconnection & testing
## Week 4
### Completed
- Tag & Scanner CLI for configuration & persistent config
- Core script (MQTT, location estimation, Store in Redis)
- NGSI-LD public api
- public dashboard (Node-RED)
### In Progress
- Admin API with CRUD functions
### Next Steps
- Add verification with camera in core script
## Week 5
### Completed
- camera scripts
- adminjs configuration
### In Progress
- connect camera scripts to core script and adminjs
+352
View File
@@ -0,0 +1,352 @@
[
{
"id": "f1398f7c64b1d0ea",
"type": "tab",
"label": "OfficeSense Flow",
"disabled": false,
"info": "",
"env": []
},
{
"id": "20bb112ec6e856de",
"type": "ui-table",
"z": "f1398f7c64b1d0ea",
"group": "05cf402c93866d4e",
"name": "",
"label": "",
"order": 1,
"width": 0,
"height": 0,
"maxrows": 0,
"passthru": false,
"autocols": true,
"showSearch": true,
"deselect": true,
"selectionType": "none",
"columns": [],
"mobileBreakpoint": "sm",
"mobileBreakpointType": "defaults",
"action": "replace",
"className": "",
"x": 690,
"y": 360,
"wires": [
[]
]
},
{
"id": "0a5259b54388cdfc",
"type": "inject",
"z": "f1398f7c64b1d0ea",
"name": "",
"props": [
{
"p": "payload"
}
],
"repeat": "3",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 70,
"y": 400,
"wires": [
[
"4030b9e7bcab5435",
"f37401387ca7a595"
]
]
},
{
"id": "4030b9e7bcab5435",
"type": "http request",
"z": "f1398f7c64b1d0ea",
"name": "",
"method": "GET",
"ret": "obj",
"paytoqs": "ignore",
"url": "http://192.168.1.2/livedata/entities",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 270,
"y": 360,
"wires": [
[
"4aee360198b34403"
]
]
},
{
"id": "f37401387ca7a595",
"type": "http request",
"z": "f1398f7c64b1d0ea",
"name": "",
"method": "GET",
"ret": "obj",
"paytoqs": "ignore",
"url": "http://192.168.1.2/livedata/entities?type=room",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 270,
"y": 440,
"wires": [
[
"448ba65b518d534d"
]
]
},
{
"id": "448ba65b518d534d",
"type": "function",
"z": "f1398f7c64b1d0ea",
"name": "ParseNGSI",
"func": "msg.payload = msg.payload.map(r => {\n return {\n Room: r.name?.value,\n Occupancy: r.occupancy?.value\n };\n});\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 490,
"y": 440,
"wires": [
[
"586c57be87bf8bc6",
"49042a0c5086d4b8"
]
]
},
{
"id": "586c57be87bf8bc6",
"type": "ui-table",
"z": "f1398f7c64b1d0ea",
"group": "a98ec11a60f808be",
"name": "",
"label": "",
"order": 1,
"width": 0,
"height": 0,
"maxrows": 0,
"passthru": false,
"autocols": true,
"showSearch": true,
"deselect": true,
"selectionType": "none",
"columns": [],
"mobileBreakpoint": "sm",
"mobileBreakpointType": "defaults",
"action": "replace",
"className": "",
"x": 690,
"y": 440,
"wires": [
[]
]
},
{
"id": "4aee360198b34403",
"type": "function",
"z": "f1398f7c64b1d0ea",
"name": "ParseNGSI",
"func": "const entities = msg.payload;\n\nconst users = entities.filter(e => e.type === \"User\");\nconst rooms = entities.filter(e => e.type === \"Room\");\n\nconst roomMap = {};\nfor (const r of rooms) {\n roomMap[r.id.replace(\"urn:ngsi-ld:Room:\", \"\")] = r;\n}\n\nmsg.payload = users.map(u => {\n\n const roomId = u.locatedIn?.object;\n const room = roomMap[roomId];\n\n return {\n Name: u.name?.value,\n Room: room ? room.name?.value : \"Unknown\",\n RSSI: u.rssi?.value,\n Auth: u.authenticationStatus?.value,\n Time: new Date(u.observedAt?.value)\n .toLocaleString(\"el-GR\", { timeZone: \"Europe/Athens\" })\n };\n});\n\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 490,
"y": 360,
"wires": [
[
"20bb112ec6e856de"
]
]
},
{
"id": "49042a0c5086d4b8",
"type": "ui-chart",
"z": "f1398f7c64b1d0ea",
"group": "a98ec11a60f808be",
"name": "",
"label": "Room Occupancy Chart",
"order": 2,
"chartType": "bar",
"category": "",
"categoryType": "none",
"xAxisLabel": "Room",
"xAxisProperty": "Room",
"xAxisPropertyType": "property",
"xAxisType": "category",
"xAxisFormat": "",
"xAxisFormatType": "auto",
"xmin": "",
"xmax": "",
"yAxisLabel": "Occupancy",
"yAxisProperty": "Occupancy",
"yAxisPropertyType": "property",
"ymin": "0",
"ymax": "10",
"bins": 10,
"action": "replace",
"stackSeries": false,
"pointShape": "circle",
"pointRadius": 4,
"showLegend": true,
"removeOlder": 1,
"removeOlderUnit": "3600",
"removeOlderPoints": "",
"colors": [
"#0095ff",
"#ff0000",
"#ff7f0e",
"#2ca02c",
"#a347e1",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5"
],
"textColor": [
"#666666"
],
"textColorDefault": true,
"gridColor": [
"#e5e5e5"
],
"gridColorDefault": true,
"width": 6,
"height": 8,
"className": "",
"interpolation": "linear",
"x": 750,
"y": 520,
"wires": [
[]
]
},
{
"id": "05cf402c93866d4e",
"type": "ui-group",
"name": "Users",
"page": "fc203945d260d5ec",
"width": 6,
"height": 1,
"order": 1,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
"id": "a98ec11a60f808be",
"type": "ui-group",
"name": "Rooms",
"page": "fc203945d260d5ec",
"width": 6,
"height": 1,
"order": 2,
"showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
"id": "fc203945d260d5ec",
"type": "ui-page",
"name": "OfficeSense",
"ui": "7d0f4d8165214cca",
"path": "/officesense",
"icon": "home",
"layout": "grid",
"theme": "f49a05b173b6cac3",
"breakpoints": [
{
"name": "Default",
"px": "0",
"cols": "3"
},
{
"name": "Tablet",
"px": "576",
"cols": "6"
},
{
"name": "Small Desktop",
"px": "768",
"cols": "9"
},
{
"name": "Desktop",
"px": "1024",
"cols": "12"
}
],
"order": 1,
"className": "",
"visible": "true",
"disabled": "false"
},
{
"id": "7d0f4d8165214cca",
"type": "ui-base",
"name": "My Dashboard",
"path": "/dashboard",
"appIcon": "",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"headerContent": "page",
"navigationStyle": "default",
"titleBarStyle": "default",
"showReconnectNotification": true,
"notificationDisplayTime": 1,
"showDisconnectNotification": true,
"allowInstall": false
},
{
"id": "f49a05b173b6cac3",
"type": "ui-theme",
"name": "Default Theme",
"colors": {
"surface": "#ffffff",
"primary": "#0094ce",
"bgPage": "#eeeeee",
"groupBg": "#ffffff",
"groupOutline": "#cccccc"
},
"sizes": {
"density": "default",
"pagePadding": "12px",
"groupGap": "12px",
"groupBorderRadius": "4px",
"widgetGap": "12px"
}
},
{
"id": "2372b8eb214a55ee",
"type": "global-config",
"env": [],
"modules": {
"@flowfuse/node-red-dashboard": "1.30.2"
}
}
]
+3 -1
View File
@@ -2,4 +2,6 @@ node_modules
# Keep environment variables out of version control
.env
**/generated/prisma
/generated/prisma
/generated/prisma
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100
}
+6988 -1160
View File
File diff suppressed because it is too large Load Diff
+15 -8
View File
@@ -9,20 +9,27 @@
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "^25.7.0",
"prisma": "^7.8.0",
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"dotenv": "^17.4.2",
"express": "^5.2.1"
"@adminjs/express": "^6.1.1",
"@adminjs/prisma": "^5.0.4",
"@prisma/client": "^6.19.3",
"adminjs": "^7.8.17",
"express": "^5.2.1",
"express-session": "^1.19.0",
"mqtt": "^5.15.1",
"prisma": "^6.19.3",
"redis": "^5.12.1"
}
}
}
-14
View File
@@ -1,14 +0,0 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"nameame" TEXT NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"faceEmbedding" BLOB
);
INSERT INTO "new_User" ("faceEmbedding", "firstName", "id", "lastName") SELECT "faceEmbedding", "firstName", "id", "lastName" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,19 @@
/*
Warnings:
- You are about to drop the column `nameame` on the `Room` table. All the data in the column will be lost.
- Added the required column `name` to the `Room` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
INSERT INTO "new_Room" ("id") SELECT "id" FROM "Room";
DROP TABLE "Room";
ALTER TABLE "new_Room" RENAME TO "Room";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,14 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"faceEmbedding" TEXT
);
INSERT INTO "new_User" ("faceEmbedding", "firstName", "id", "lastName") SELECT "faceEmbedding", "firstName", "id", "lastName" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,14 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Tag" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Tag" ("id", "userId") SELECT "id", "userId" FROM "Tag";
DROP TABLE "Tag";
ALTER TABLE "new_Tag" RENAME TO "Tag";
CREATE UNIQUE INDEX "Tag_userId_key" ON "Tag"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+10 -5
View File
@@ -1,22 +1,27 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
firstName String
lastName String
faceEmbedding Bytes?
faceEmbedding String?
tag Tag?
}
model Tag {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
}
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}
model Room {
id String @id @default(uuid())
name String
}
@@ -0,0 +1,9 @@
import express from "express";
import { getEntities, getEntitiesOfType, getEntity } from "./livedata.controller.js";
const router = express.Router();
router.get("/entities", getEntities, getEntitiesOfType);
router.get("/entities/:urn", getEntity);
export default router;
@@ -0,0 +1,172 @@
import type { Request, Response, NextFunction } from "express";
import { getActiveUserData, getRedisRoomData, type RedisRoomData, type RedisUserData } from "./livedata.repository.js";
import { prisma } from "../../lib/prisma.js";
interface Params {
urn: string;
}
type Relationship = {
type: "Relationship";
object: string;
};
type Property<T = any> = {
type: "Property";
value: T;
};
interface Entity {
id: string;
type: string;
name: {
type: "Property";
value: string;
};
}
interface User extends Entity {
type: "User";
locatedIn: Relationship;
rssi: Property<number>;
authenticationStatus: Property<string>;
observedAt: Property<string>;
}
interface Room extends Entity {
type: "Room";
occupancy: Property<number>;
}
function convertToNGSIUser(user: RedisUserData): User {
return {
id: `urn:ngsi-ld:User:${user.userID}`,
type: "User",
name: {
type: "Property",
value: user.name
},
locatedIn: {
type: "Relationship",
object: user.room
},
rssi: {
type: "Property",
value: user.rssi,
},
authenticationStatus: {
type: "Property",
value: user.verified ? "verified" : "unverified"
},
observedAt: {
type: "Property",
value: (new Date(user.timestamp)).toISOString()
}
}
}
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
const roomName = await prisma.room.findUnique({
where: { id: room.roomID },
select: { name: true }
})
if (!roomName?.name) return null;
return {
id: `urn:ngsi-ld:Room:${room.roomID}`,
type: "Room",
name: {
type: "Property",
value: roomName.name
},
occupancy: {
type: "Property",
value: room.occupancy
}
}
}
async function getNGSIUsers(): Promise<User[]> {
let users: User[] = [];
const result: RedisUserData[] = await getActiveUserData();
for (const r of result) {
const user = convertToNGSIUser(r);
users.push(user);
}
return users;
}
async function getNGSIRooms(): Promise<Room[]> {
let rooms: Room[] = [];
const result: RedisRoomData[] = await getRedisRoomData();
for (const r of result) {
const room = await convertToNGSIRoom(r);
if (room == null) continue;
rooms.push(room);
}
return rooms;
}
export async function getEntities(req: Request, res: Response, next: NextFunction) {
if (Object.keys(req.query).length > 0) return next();
let entities: Entity[] = [];
entities.push(...await getNGSIUsers());
entities.push(...await getNGSIRooms());
return res.status(200).send(entities);
}
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());
default:
return res.sendStatus(400);
}
}
export async function getEntity(req: Request<Params>, res: Response) {
const { urn } = req.params;
if (!urn || !urn.startsWith("urn:ngsi-ld:")) return res.sendStatus(400);
const entity = urn.slice(12);
if (!entity) return res.sendStatus(400);
if (entity.toLowerCase().startsWith("room:")) {
const roomID = entity.slice(5);
if (!roomID) return res.sendStatus(400);
const data = await getRedisRoomData(roomID);
if (!data.length) return res.sendStatus(404);
return res.status(200).send(await convertToNGSIRoom(data[0]!));
} else if (entity.toLowerCase().startsWith("user:")) {
const userID = entity.slice(5);
if (!userID) return res.sendStatus(400);
const data = await getActiveUserData(userID);
if (!data.length) return res.sendStatus(404);
return res.status(200).send(convertToNGSIUser(data[0]!));
} else
return res.sendStatus(400);
}
@@ -0,0 +1,106 @@
import { prisma } from "../../lib/prisma.js";
import { getRedis } from "../../redis/redis.js";
export interface RedisUserData {
userID: string;
name: string;
rssi: number;
room: string;
verified: boolean;
timestamp: number;
}
export interface RedisRoomData {
roomID: string;
occupancy: number;
}
export async function getActiveUserData(userID?: string): Promise<RedisUserData[]> {
const redis = getRedis();
if (!userID) {
let cursor = "0";
let result: RedisUserData[] = [];
do {
const res = await redis.scan(cursor, {
MATCH: "user:*",
COUNT: 100,
});
cursor = res.cursor;
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;
}
const _userID = await redis.get(`_user:${userID}`);
if (!_userID) return [];
const res = await redis.get(`user:${_userID}`);
if (!res) return [];
const resObj: RedisUserData = JSON.parse(res);
return [resObj];
}
export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]> {
const redis = getRedis();
if (!roomID) {
let cursor = "0";
let result: RedisRoomData[] = [];
do {
const res = await redis.scan(cursor, {
MATCH: "room:*",
COUNT: 100,
});
cursor = res.cursor;
const values = await Promise.all(
res.keys.map(async (key) => ({
roomID: key,
occupancy: await redis.get(key),
}))
);
result.push(...values
.filter((v) => v.occupancy !== null)
.map((v) => ({
roomID: v.roomID.slice(5),
occupancy: Number(v.occupancy),
}))
);
} while (cursor !== "0");
return result;
}
const res = await redis.get(`room:${roomID}`);
if (!res) return [];
return [{ roomID: roomID, occupancy: Number(res) }];
}
export async function getRoomIDs(): Promise<{ id: string; }[]> {
return await prisma.room.findMany({
select: { id: true }
});
}
@@ -1,9 +1,9 @@
import { prisma } from "../../lib/prisma.js";
export async function existsByUUID(uuid: string): Promise<boolean> {
const user = await prisma.user.findUnique({
const tag = await prisma.tag.findUnique({
where: { id: uuid }
});
return !!user;
return !!tag;
}
@@ -0,0 +1,59 @@
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 }
@@ -0,0 +1,13 @@
import { ComponentLoader } from 'adminjs'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
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'),
}
@@ -0,0 +1,121 @@
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<Session[]>([])
const [loading, setLoading] = useState(true)
const [updated, setUpdated] = useState<string>('')
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 (
<Box padding="xl">
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom="xl">
<Box>
<H2>OfficeSense Admin Dashboard</H2>
<Text color="grey60">Live Redis sessions mapped to registered users</Text>
</Box>
<Box display="flex" alignItems="center">
<Text color="grey60" fontSize="sm" marginRight="md">Updated {updated}</Text>
<Box
as="button"
onClick={load}
style={{
padding: '6px 14px',
borderRadius: '6px',
border: '1px solid #e5e7eb',
background: '#fff',
cursor: 'pointer',
fontSize: '13px',
}}
>
Refresh
</Box>
</Box>
</Box>
{loading ? (
<Loader />
) : sessions.length === 0 ? (
<Text color="grey60">No active sessions.</Text>
) : (
<Box
style={{
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['User', 'Real UUID', 'Pseudo UUID', 'Pseudo name'].map(h => (
<th key={h} style={{
textAlign: 'left', padding: '10px 16px',
fontSize: '11px', color: '#9ca3af',
textTransform: 'uppercase', letterSpacing: '0.05em',
borderBottom: '1px solid #f3f4f6', fontWeight: 500,
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{sessions.map(s => (
<tr key={s.realUserId} style={{ borderBottom: '1px solid #f9fafb' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: '#eff6ff', color: '#2563eb',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '11px', fontWeight: 600, flexShrink: 0,
}}>
{initials(s.firstName, s.lastName)}
</div>
{s.firstName} {s.lastName}
</div>
</td>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.realUserId}</td>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.pseudoId}</td>
<td style={{ padding: '12px 16px' }}>{s.pseudoName ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
)
}
export default Dashboard
@@ -0,0 +1,26 @@
import React from 'react'
import { BasePropertyProps } from 'adminjs'
import { Label } from '@adminjs/design-system'
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
const has = !!record?.params?.faceEmbedding
return (
<div style={{ marginBottom: '16px' }}>
<Label>{property.label}</Label>
<div style={{ marginTop: 6 }}>
<span style={{
fontSize: '12px',
fontWeight: 600,
padding: '3px 10px',
borderRadius: '4px',
background: has ? '#dcfce7' : '#fee2e2',
color: has ? '#16a34a' : '#dc2626',
}}>
{has ? 'SET' : 'UNSET'}
</span>
</div>
</div>
)
}
export default FaceEmbeddingField
@@ -0,0 +1,96 @@
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<ActionProps> = ({ record, action }) => {
const [preview, setPreview] = useState<string | null>(null)
const [base64, setBase64] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
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 handleSubmit = async () => {
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',
body: JSON.stringify({ base64 }),
}
)
const data = await res.json()
setNotice(data.notice)
} catch (e: any) {
setNotice({ message: e.message, type: 'error' })
} finally {
setLoading(false)
}
}
return (
<Box padding="xl">
<H3 marginBottom="md">Upload face image</H3>
<Text color="grey60" marginBottom="lg">
Upload a JPEG photo of the user to generate and store their face embedding.
</Text>
{notice && (
<MessageBox
message={notice.message}
variant={notice.type === 'success' ? 'success' : 'danger'}
marginBottom="lg"
/>
)}
<Box marginBottom="lg">
<input
ref={inputRef}
type="file"
accept="image/jpeg"
onChange={handleFile}
style={{ display: 'none' }}
/>
<Button onClick={() => inputRef.current?.click()} variant="outlined">
Choose JPEG
</Button>
</Box>
{preview && (
<Box marginBottom="lg">
<img
src={preview}
alt="Preview"
style={{ width: 160, height: 160, objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }}
/>
</Box>
)}
<Button
onClick={handleSubmit}
disabled={!base64 || loading}
variant="contained"
>
{loading ? 'Processing…' : 'Save embedding'}
</Button>
</Box>
)
}
export default UploadFace
@@ -0,0 +1,16 @@
import { prisma } from "../../../lib/prisma.js";
import { getModelByName } from "@adminjs/prisma";
export default {
resource: {
model: getModelByName("Room"),
client: prisma,
},
options: {
navigation: { icon: "Map" },
listProperties: ["id", "name"],
showProperties: ["id", "name"],
editProperties: ["name"],
filterProperties: ["name"],
},
}
@@ -0,0 +1,21 @@
import { prisma } from "../../../lib/prisma.js";
import { getModelByName } from "@adminjs/prisma";
export default {
resource: {
model: getModelByName("Tag"),
client: prisma,
},
options: {
navigation: { icon: "Tag" },
listProperties: ["id", "user"],
showProperties: ["id", "user"],
editProperties: ["user"],
filterProperties: ["user"],
properties: {
user: {
reference: "User",
},
},
},
}
@@ -0,0 +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<string> {
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,
},
},
},
}
@@ -0,0 +1,17 @@
import type { NextFunction, Request, Response } from "express";
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' })
}
next();
}
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' })
}
}
@@ -0,0 +1,29 @@
import { getRedis } from "../../redis/redis.js";
import { prisma } from "../../lib/prisma.js";
export async function generateMap() {
const redis = getRedis();
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 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 sessions;
}
@@ -0,0 +1,8 @@
import express from "express";
import { getRedisMapping, checkSession } from "./mapper.controller.js";
const router = express.Router();
router.get("/map", checkSession, getRedisMapping);
export default router;
+59
View File
@@ -0,0 +1,59 @@
interface Config {
api: {
address: string;
port: number;
};
mqtt: {
host: string;
port: number;
username: string;
password: string;
topic: string;
};
redis: {
host: string;
port: number;
};
core: {
hysteresis: number;
candidateHysteresis: number;
debounceMS: number;
minSamples: number;
transitionTTL: number;
transitionCleanupInterval: number;
lossThreshold: number;
userTTL: number;
verifyTimeout: number;
};
}
const config: Config = {
api: {
address: "0.0.0.0",
port: 80,
},
mqtt: {
host: "192.168.1.2",
port: 1883,
username: "user",
password: "pass",
topic: "scanners/+",
},
redis: {
host: "192.168.1.2",
port: 6379,
},
core: {
hysteresis: 6,
candidateHysteresis: 3,
debounceMS: 3000,
minSamples: 4,
transitionTTL: 5 * 60 * 1000,
transitionCleanupInterval: 60 * 1000,
lossThreshold: 5000,
userTTL: 3 * 60 * 1000,
verifyTimeout: 30 * 1000
},
}
export default config;
+167
View File
@@ -0,0 +1,167 @@
import { getRedis } from "../redis/redis.js";
import { prisma } from "../lib/prisma.js";
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 nouns = [
"Tiger",
"Wolf",
"Falcon",
"Shadow",
"Ninja",
"Dragon",
"Phoenix"
];
const running = new Set<string>();
const userRooms = new Map<string, string>();
const userPseudo = new Map<string, string>();
export async function updateRoomOccupancyListener(message: string, channel: string) {
console.log(`[Redis] Key ${message} expired.`);
const id = message.startsWith("user:") ? message.slice(5) : message;
const lastRoomID = userRooms.get(id);
const userPseudoID = userPseudo.get(id);
const redis = getRedis();
if (!lastRoomID) {
console.log("[!] Key not found in local map")
} else {
await redis.decr(`room:${lastRoomID}`);
}
await redis.del(`_user:${userPseudoID}`);
userRooms.delete(id);
userPseudo.delete(id);
}
function generateNickname() {
const adjective =
adjectives[Math.floor(Math.random() * adjectives.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 } {
return {
pseudoID: crypto.randomUUID(),
psuedoName: generateNickname()
};
}
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;
}
running.add(metrics.tagID);
try {
console.log(`\n=======================================================\n` +
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
`=======================================================\n`);
const userID = (
await prisma.tag.findUnique({
where: { id: metrics.tagID },
select: { userId: true },
})
)?.userId;
console.log(`[SQLite] Resolved USER_ID: ${userID}`);
if (!userID)
return;
const redis = getRedis();
const res = await redis.get(`user:${userID}`);
if (!res) {
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:${user.pseudoID}`, userID);
await redis.incr(`room:${roomID}`);
userRooms.set(userID, roomID);
userPseudo.set(userID, user.pseudoID);
console.log("[Redis] Stored new user in redis.");
return;
}
let resObj: RedisUserData = JSON.parse(res);
console.log(`[Redis] User exists in redis in ROOM_ID: ${resObj["room"]}`);
if (roomID == resObj["room"]) {
console.log("[Redis] Same room, updating redis...");
resObj["rssi"] = metrics.rssi;
resObj["timestamp"] = Date.now()
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
return;
}
let transition = transitions.get(userID);
if (!transition) {
console.log("[!] Starting new transition");
transition = new RoomTransition();
transitions.set(userID, transition);
}
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) {
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
await redis.decr(`room:${resObj["room"]}`);
await redis.incr(`room:${roomID}`);
userRooms.set(userID, roomID);
resObj["rssi"] = metrics.rssi;
resObj["room"] = roomID;
resObj["timestamp"] = Date.now()
resObj["verified"] = false;
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
} else
console.log("[!] Transition declined");
} finally {
running.delete(metrics.tagID);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
import { initRedis, initSubRedis } from "../redis/redis.js";
import { initCamera } from "./camera.js";
import * as mqtt from "./mqtt.js";
import { cleanupWorker } from "./transition.js";
export async function bootstrap() {
try {
const redis = await initRedis();
await redis.flushDb();
console.log(`[!] Flushed Redis.`);
const rooms = await getRoomIDs();
for (const { id } of rooms)
await redis.set(`room:${id}`, 0);
await initSubRedis();
await mqtt.start();
cleanupWorker();
await initCamera();
console.log("[!] Core started.");
} catch (err: any) {
console.log("[!] Bootstrap failed:", err.message);
process.exit(1);
}
}
+132
View File
@@ -0,0 +1,132 @@
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';
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const execPath = '../../bin';
const binaryPath = path.join(__dirname, execPath, 'cameraSession')
type InputPacket = {
uuid: string
descriptor: number[]
}
type ResultPacket = {
uuid: string
verified: boolean
}
export async function getUnverifiedUserKeys(): Promise<string[]> {
const redis = getRedis()
const keys = await redis.keys('user:*')
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:', ''))
}))
return unverified
}
export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string, descriptor: number[] }[]> {
const userIds = await getUnverifiedUserKeys()
const users = await prisma.user.findMany({
where: {
id: { in: userIds },
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<ResultPacket[] | null> {
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', () => { })
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}`))
try {
const results: ResultPacket[] = JSON.parse(trimmed)
resolve(results)
} catch (e) {
reject(new Error(`Failed to parse output: ${trimmed}`))
}
})
for (const packet of packets) {
proc.stdin.write(JSON.stringify(packet) + '\n')
}
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 run = async () => {
const redis = getRedis();
try {
const packets = await getUnverifiedUsersWithEmbeddings()
if (packets.length > 0) {
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))
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout)
console.log(`[Camera] Verified user ${r.uuid}`)
}))
}
}
} catch (e) {
console.error('camera run error:', e)
}
setTimeout(run, 1000)
}
export { run as initCamera };
+48
View File
@@ -0,0 +1,48 @@
import mqtt, { MqttClient } from "mqtt";
import config from "../config/config.js";
import { analyzeData } from "./analyze.js";
function initMqtt(): Promise<MqttClient> {
return new Promise((resolve, reject) => {
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.");
client.subscribe(config.mqtt.topic, (err) => {
if (err) {
reject(err);
} else {
console.log("[MQTT] MQTT subscribed.");
resolve(client);
}
});
});
client.once("error", (err) => {
reject(err);
});
});
}
export async function start() {
try {
const mqttClient = await initMqtt();
mqttClient.on("message", (topic, message) => {
analyzeData(topic.split("/").pop()!, JSON.parse(message.toString()));
});
mqttClient.on("error", (err) => {
console.log(err.message);
})
} catch (err: any) {
console.log("[MQTT] MQTT connection failed:", err.message);
}
}
+94
View File
@@ -0,0 +1,94 @@
import config from "../config/config.js";
export const transitions = new Map<string, RoomTransition>();
export function cleanupWorker() {
setInterval(() => {
const now = Date.now();
for (const [userID, transition] of transitions) {
const inactiveFor = now - transition.lastSeen;
if (inactiveFor > 5 * config.core.transitionTTL)
transitions.delete(userID);
}
}, config.core.transitionCleanupInterval);
}
type Candidate = {
roomID: string | null;
since: number | null;
samples: number;
rssi: number | null;
};
export class RoomTransition {
candidate: Candidate;
lastSeen: number;
constructor() {
this.lastSeen = Date.now();
this.candidate = { roomID: null, since: null, samples: 0, rssi: null };
}
shouldTransitionTo(roomID: string, candidateRSSI: number, currRSSI: number, lastCurrentSeen: number) {
const now = Date.now();
this.lastSeen = now;
const stronger = candidateRSSI > currRSSI + config.core.hysteresis;
const signalLost = now - lastCurrentSeen > config.core.lossThreshold;
console.log(`[TRANSITION] stronger = ${stronger}`);
console.log(`[TRANSITION] signalLost = ${signalLost}`);
if (!stronger && !signalLost) {
this.reset();
return false;
}
const noCandidate = this.candidate.roomID == null;
const betterCandidate = this.candidate.roomID != roomID &&
this.candidate.rssi !== null &&
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
console.log(`[TRANSITION] noCandidate = ${noCandidate}`);
console.log(`[TRANSITION] betterCandidate = ${betterCandidate}`);
if (noCandidate || betterCandidate) {
this.candidate.roomID = roomID;
this.candidate.since = now;
this.candidate.samples = 1;
this.candidate.rssi = candidateRSSI;
return false;
}
this.candidate.samples++;
this.candidate.rssi = candidateRSSI;
console.log(`[TRANSITION] samples = ${this.candidate.samples}`);
console.log(`[TRANSITION] candidateRSSI = ${this.candidate.rssi}`);
const enoughTime = now - (this.candidate.since ?? now) >= config.core.debounceMS;
const enoughConfirmations = this.candidate.samples >= config.core.minSamples;
console.log(`[TRANSITION] enoughTime = ${enoughTime}`);
console.log(`[TRANSITION] enoughConfirmations = ${enoughConfirmations}`);
console.log(`[TRANSITION] signalLost = ${signalLost}`);
if ((enoughTime && enoughConfirmations) || signalLost) {
this.reset();
return true;
}
return false;
}
reset() {
this.candidate.roomID = null;
this.candidate.since = null;
this.candidate.samples = 0;
this.candidate.rssi = null;
}
}
+23 -8
View File
@@ -1,12 +1,27 @@
import express from "express";
import lookupRoutes from "./api/lookup/lookup.routes.js";
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 config from "./config/config.js";
import { bootstrap } from "./core/bootstrap.js";
const app = express();
const PORT = 80;
const ADDRESS = "0.0.0.0";
(async () => {
const app = express();
app.use("/", lookupRoutes);
const { admin, router: adminRouter } = await createAdmin();
app.listen(PORT, ADDRESS, () => {
console.log(`Server listening on ${ADDRESS}:${PORT}`);
});
app.use(sessionMiddleware);
app.use("/lookup", lookupRouter);
app.use("/livedata", livedataRouter);
app.use("/admin", mapperRouter);
app.use("/admin", adminRouter);
app.listen(config.api.port, config.api.address, () => {
console.log(`Server listening on ${config.api.address}:${config.api.port}`);
});
bootstrap();
})()
+2 -7
View File
@@ -1,10 +1,5 @@
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client.js";
import { PrismaClient } from '@prisma/client'
const connectionString = `${process.env.DATABASE_URL}`;
const adapter = new PrismaBetterSqlite3({ url: connectionString });
const prisma = new PrismaClient({ adapter });
const prisma = new PrismaClient()
export { prisma };
+48
View File
@@ -0,0 +1,48 @@
import { createClient, type RedisClientType } from "redis";
import config from "../config/config.js";
import { updateRoomOccupancyListener } from "../core/analyze.js";
let redisClient: RedisClientType;
let subRedisClient: RedisClientType;
export async function initSubRedis(): Promise<void> {
subRedisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}`
});
subRedisClient.on("error", (err) => {
console.log("[Redis] SubRedis error:", err.message);
});
await subRedisClient.connect();
console.log("[Redis] SubRedis connected.");
subRedisClient.pSubscribe("__keyevent@0__:expired", updateRoomOccupancyListener);
console.log("[Redis] SubRedis listener set.");
}
export async function initRedis(): Promise<RedisClientType> {
redisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}`
});
redisClient.on("error", (err) => {
console.log("[Redis] Redis error:", err.message);
});
await redisClient.connect();
console.log("[Redis] Redis connected.");
return redisClient;
}
export function getRedis(): RedisClientType {
if (!redisClient) {
throw new Error("[Redis] Redis not initialized");
}
return redisClient;
}
+11 -2
View File
@@ -128,7 +128,7 @@ void mqttTask(void *)
{
while (!mqtt.connected())
{
Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
// Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
if (mqtt.connect(config.dev_name.c_str(), config.mqtt.username.c_str(), config.mqtt.password.c_str()))
Serial.println("MQTT connected.");
vTaskDelay(pdMS_TO_TICKS(1000));
@@ -139,7 +139,7 @@ void mqttTask(void *)
char payload[128];
snprintf(payload, sizeof(payload),
"{\"uuid\":\"%s\",\"rssi\":%.2f}",
"{\"tagID\":\"%s\",\"rssi\":%.2f}",
ev.uuid,
ev.ema);
@@ -369,6 +369,14 @@ void handleCommand(String line)
else if (key == TAG_NAME_CONFIG_KEY)
Serial.println(config.tag_name);
}
else if (line == "reset")
{
prefs.begin("config", false);
prefs.clear();
prefs.end();
esp_restart();
}
else if (line == "reboot")
{
Serial.println("Rebooting...");
@@ -381,6 +389,7 @@ void handleCommand(String line)
Serial.println(" set <key> <value> - Set a configuration value");
Serial.println(" show <key> - Show a configuration value");
Serial.println(" save - Save configuration to non-volatile storage");
Serial.println(" reset - Reset configuration to defaults");
Serial.println(" reboot - Reboot the device");
Serial.println(" help - Show this help message");
}
+1 -1
View File
@@ -35,7 +35,7 @@
#define MQTT_TOPIC "scanners/"
#define WIFI_SSID "COSMOTE-489882"
#define WIFI_PASS "x32hbh54673ngccdsfa9"
#define DEV_NAME "Scanner_Room_A"
#define DEV_NAME "572b29cb-6a93-480e-adf0-c5c44e9a58df"
struct Config
{
+8
View File
@@ -0,0 +1,8 @@
# dlib compile cache
.docker-cache/
# face recognition models
models/
# executable
output/
@@ -0,0 +1,55 @@
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder
# Install dlib deps
RUN dpkg --add-architecture arm64 && \
apt-get update && apt-get install -y \
g++-aarch64-linux-gnu \
libdlib-dev:arm64 \
libjpeg-dev:arm64 \
libblas-dev:arm64 \
libopenblas-dev:arm64 \
libatlas-base-dev:arm64 \
liblapack-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Compiler wrapper for arm64
RUN printf '#!/bin/bash\nexec aarch64-linux-gnu-gcc "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-gcc && \
printf '#!/bin/bash\nexec aarch64-linux-gnu-g++ "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-g++ && \
chmod +x /usr/local/bin/arm64-gcc /usr/local/bin/arm64-g++
# Compiler variables for cross-compile to arm64
ENV CGO_ENABLED=1 \
GOOS=linux \
GOARCH=arm64 \
CC=/usr/local/bin/arm64-gcc \
CXX=/usr/local/bin/arm64-g++ \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
COPY go.mod go.sum ./
RUN go mod download
# Pre-compile all CGO deps into cache
RUN go build -v ./... 2>/dev/null || \
go list -m all && \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
go build -gcflags="-trimpath" -v \
$(go list -f '{{if .CgoFiles}}{{.ImportPath}}{{end}}' ./...) 2>/dev/null; exit 0
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go mod download
COPY . .
# Build - reuse CGO cache
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go build -o cameraSession
FROM scratch AS export
COPY --from=builder /app/cameraSession /cameraSession
@@ -0,0 +1,10 @@
all:
docker buildx build --platform linux/amd64 --cache-from type=local,src=./.docker-cache --cache-to type=local,dest=./.docker-cache --target export --output type=local,dest=./output .
models:
wget https://github.com/Kagami/go-face-testdata/raw/master/models/shape_predictor_5_face_landmarks.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/dlib_face_recognition_resnet_model_v1.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/mmod_human_face_detector.dat
clean:
rm -r output/
@@ -0,0 +1,163 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"github.com/Kagami/go-face"
"github.com/blackjack/webcam"
)
type InputPacket struct {
UUID string `json:"uuid"`
Descriptor face.Descriptor `json:"descriptor"`
}
type ResultPacket struct {
UUID string `json:"uuid"`
Verified bool `json:"verified"`
}
var (
rec *face.Recognizer
mtx sync.Mutex
results = make([]ResultPacket, 0)
)
func captureCameraFrame() ([]byte, error) {
cam, err := webcam.Open("/dev/video0")
if err != nil {
return nil, err
}
defer cam.Close()
format := webcam.PixelFormat(uint32(0x47504a4d)) // MJPG
_, _, _, err = cam.SetImageFormat(format, 1280, 720)
if err != nil {
return nil, fmt.Errorf("camera format error: %v", err)
}
err = cam.StartStreaming()
if err != nil {
return nil, err
}
defer cam.StopStreaming()
for {
err = cam.WaitForFrame(5)
if err != nil {
continue
}
frame, err := cam.ReadFrame()
if err != nil {
return nil, err
}
if len(frame) != 0 {
result := make([]byte, len(frame))
copy(result, frame)
return result, nil
}
}
}
// verfication thread
func verifyFace(packet InputPacket, descriptors []face.Descriptor, wg *sync.WaitGroup) {
defer wg.Done()
const threshold = 0.35
verified := false
for _, descriptor := range descriptors {
distance := face.SquaredEuclideanDistance(packet.Descriptor, descriptor)
if distance < threshold {
verified = true
break
}
}
mtx.Lock()
results = append(results, ResultPacket{
UUID: packet.UUID,
Verified: verified,
})
mtx.Unlock()
}
func main() {
var err error
// initialize recognizer
rec, err = face.NewRecognizer("./models")
if err != nil {
log.Fatalf("failed to initialize recognizer: %v", err)
}
defer rec.Close()
// capture image
cameraImage, err := captureCameraFrame()
if err != nil {
log.Fatalf("failed to read camera: %v", err)
}
// recognize faces from camera input
faces, err := rec.RecognizeCNN(cameraImage)
if err != nil {
log.Fatalf("recognition error: %v", err)
}
if len(faces) == 0 {
fmt.Println("no face found.")
return
}
var descriptors []face.Descriptor
// extract descriptors to array
for _, f := range faces {
descriptors = append(descriptors, f.Descriptor)
}
// read stdin
scanner := bufio.NewScanner(os.Stdin)
var wg sync.WaitGroup
for scanner.Scan() {
line := scanner.Bytes()
var packet InputPacket
err := json.Unmarshal(line, &packet)
if err != nil {
log.Printf("invalid json packet: %v", err)
continue
}
wg.Add(1)
go verifyFace(packet, descriptors, &wg)
}
if err := scanner.Err(); err != nil {
log.Printf("stdin error: %v", err)
}
// wait threads to finish
wg.Wait()
// print results to stdout
output, err := json.Marshal(results)
if err != nil {
log.Fatalf("failed to marshal results: %v", err)
}
fmt.Println(string(output))
}
@@ -0,0 +1,10 @@
module OfficeSense/cameraSession
go 1.26.3
require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
github.com/blackjack/webcam v0.6.1
)
require golang.org/x/sys v0.14.0 // indirect
@@ -0,0 +1,6 @@
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
github.com/blackjack/webcam v0.6.1 h1:K0T6Q0zto23U99gNAa5q/hFoye6uGcKr2aE6hFoxVoE=
github.com/blackjack/webcam v0.6.1/go.mod h1:zs+RkUZzqpFPHPiwBZ6U5B34ZXXe9i+SiHLKnnukJuI=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -0,0 +1,55 @@
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder
# Install dlib deps
RUN dpkg --add-architecture arm64 && \
apt-get update && apt-get install -y \
g++-aarch64-linux-gnu \
libdlib-dev:arm64 \
libjpeg-dev:arm64 \
libblas-dev:arm64 \
libopenblas-dev:arm64 \
libatlas-base-dev:arm64 \
liblapack-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Compiler wrapper for arm64
RUN printf '#!/bin/bash\nexec aarch64-linux-gnu-gcc "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-gcc && \
printf '#!/bin/bash\nexec aarch64-linux-gnu-g++ "${@//-march=native/-march=armv8-a}"\n' \
> /usr/local/bin/arm64-g++ && \
chmod +x /usr/local/bin/arm64-gcc /usr/local/bin/arm64-g++
# Compiler variables for cross-compile to arm64
ENV CGO_ENABLED=1 \
GOOS=linux \
GOARCH=arm64 \
CC=/usr/local/bin/arm64-gcc \
CXX=/usr/local/bin/arm64-g++ \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
COPY go.mod go.sum ./
RUN go mod download
# Pre-compile all CGO deps into cache
RUN go build -v ./... 2>/dev/null || \
go list -m all && \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
go build -gcflags="-trimpath" -v \
$(go list -f '{{if .CgoFiles}}{{.ImportPath}}{{end}}' ./...) 2>/dev/null; exit 0
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go mod download
COPY . .
# Build - reuse CGO cache
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go build -o extractEmbeddings
FROM scratch AS export
COPY --from=builder /app/extractEmbeddings /extractEmbeddings
@@ -0,0 +1,10 @@
all:
docker buildx build --platform linux/amd64 --cache-from type=local,src=./.docker-cache --cache-to type=local,dest=./.docker-cache --target export --output type=local,dest=./output .
models:
wget https://github.com/Kagami/go-face-testdata/raw/master/models/shape_predictor_5_face_landmarks.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/dlib_face_recognition_resnet_model_v1.dat
wget https://github.com/Kagami/go-face-testdata/raw/master/models/mmod_human_face_detector.dat
clean:
rm -r output/
@@ -0,0 +1,126 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"os"
"github.com/Kagami/go-face"
"golang.org/x/image/draw"
)
type Result struct {
Success bool `json:"success"`
Descriptor face.Descriptor `json:"descriptor"`
Error string `json:"error"`
}
var res Result
func atExit() {
resString, err := json.Marshal(res)
if err != nil {
os.Exit(1)
}
fmt.Println(string(resString))
os.Exit(0)
}
func resizeIfNeeded(img image.Image, max int) image.Image {
b := img.Bounds()
w := b.Dx()
h := b.Dy()
if w <= max && h <= max {
return img
}
var scale float64
if w > h {
scale = float64(max) / float64(w)
} else {
scale = float64(max) / float64(h)
}
newW := int(float64(w) * scale)
newH := int(float64(h) * scale)
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.BiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func main() {
defer atExit()
// Read input image from stdin in Base64 encoding
input, err := io.ReadAll(os.Stdin)
if err != nil {
res.Success = false
res.Error = "[STDIN]: " + err.Error()
return
}
// Decode Base64
image, err := base64.StdEncoding.DecodeString(string(input))
if err != nil {
res.Success = false
res.Error = "[BASE64]: " + err.Error()
return
}
imageJPEG, err := jpeg.Decode(bytes.NewReader(image))
if err != nil {
res.Success = false
res.Error = "[JPEG][DECODE]: " + err.Error()
return
}
imageJPEG = resizeIfNeeded(imageJPEG, 600)
var buf bytes.Buffer
err = jpeg.Encode(&buf, imageJPEG, &jpeg.Options{Quality: 90})
if err != nil {
res.Success = false
res.Error = "[JPEG][ENCODE]: " + err.Error()
return
}
image = buf.Bytes()
// Create recognizer - provide ./models dir
rec, err := face.NewRecognizer("models")
if err != nil {
res.Success = false
res.Error = "[DLIB][INIT]: " + err.Error()
return
}
defer rec.Close()
faces, err := rec.RecognizeCNN(image)
if err != nil {
res.Success = false
res.Error = "[DLIB][CNN]: " + err.Error()
return
} else if len(faces) == 0 {
res.Success = false
res.Error = "[DLIB][CNN]: No face found in image."
return
} else if len(faces) != 1 {
res.Success = false
res.Error = "[DLIB][CNN]: Multiple faces found in image."
return
}
res.Success = true
res.Descriptor = faces[0].Descriptor
}
@@ -0,0 +1,8 @@
module OfficeSense/extractEmbeddings
go 1.26.3
require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e
golang.org/x/image v0.41.0
)
@@ -0,0 +1,4 @@
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=