Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221e95d0e | ||
|
|
a7974c78f3 | ||
|
|
98bffc4117 | ||
|
|
c7a0d921ea | ||
|
|
a788ea2642 | ||
|
|
2fb15443ef | ||
|
|
e3d8f41f02 | ||
|
|
83c9dbb9fa | ||
|
|
3c7e419ffa | ||
|
|
94d7b76c52 | ||
|
|
afbf67187f | ||
|
|
5004e86bee | ||
|
|
6a7e705f1f | ||
|
|
0f6728da40 | ||
|
|
7e1a7d0eca | ||
|
|
1ac5ddbf33 | ||
|
|
8dffdc9bf9 | ||
|
|
23116f6a19 | ||
|
|
2badcb92e2 | ||
|
|
8abae7b9ba | ||
|
|
1bce98e083 | ||
|
|
92b3c72e3e | ||
|
|
41cf0ad7ca |
+10
-1
@@ -50,10 +50,19 @@
|
||||
### 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
|
||||
- NGSI-LD data for public API
|
||||
- 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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,4 +2,6 @@ node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
**/generated/prisma
|
||||
/generated/prisma
|
||||
|
||||
/generated/prisma
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
Generated
+6648
-1336
File diff suppressed because it is too large
Load Diff
@@ -9,22 +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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -1,24 +1,24 @@
|
||||
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 {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { getEntities, getEntitiesOfType, getEntity } from "./livedata.controller
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/entities", getEntities);
|
||||
router.get("/entities", getEntitiesOfType);
|
||||
router.get("/entities", getEntities, getEntitiesOfType);
|
||||
router.get("/entities/:urn", getEntity);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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<Room | null> {
|
||||
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<Room | null> {
|
||||
type: "Room",
|
||||
name: {
|
||||
type: "Property",
|
||||
value: roomName.name
|
||||
value: roomName.name,
|
||||
},
|
||||
occupancy: {
|
||||
type: "Property",
|
||||
value: room.occupancy
|
||||
}
|
||||
}
|
||||
value: room.occupancy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getNGSIUsers(): Promise<User[]> {
|
||||
@@ -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<Params>, 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);
|
||||
}
|
||||
|
||||
@@ -30,15 +30,9 @@ export async function getActiveUserData(userID?: string): Promise<RedisUserData[
|
||||
|
||||
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))
|
||||
);
|
||||
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<RedisRoomData[]
|
||||
}))
|
||||
);
|
||||
|
||||
result.push(...values
|
||||
.filter((v) => 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<RedisRoomData[]
|
||||
return [{ roomID: roomID, occupancy: Number(res) }];
|
||||
}
|
||||
|
||||
export async function getRoomIDs(): Promise<{ id: string; }[]> {
|
||||
export async function getRoomIDs(): Promise<{ id: string }[]> {
|
||||
return await prisma.room.findMany({
|
||||
select: { id: true }
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export async function existsByUUID(uuid: string): Promise<boolean> {
|
||||
const tag = await prisma.tag.findUnique({
|
||||
where: { id: uuid }
|
||||
where: { id: uuid },
|
||||
});
|
||||
|
||||
return !!tag;
|
||||
|
||||
@@ -0,0 +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 };
|
||||
@@ -0,0 +1,25 @@
|
||||
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,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<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,28 @@
|
||||
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,100 @@
|
||||
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,31 @@
|
||||
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;
|
||||
@@ -23,6 +23,7 @@ interface Config {
|
||||
transitionCleanupInterval: number;
|
||||
lossThreshold: number;
|
||||
userTTL: number;
|
||||
verifyTimeout: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,8 +51,9 @@ const config: Config = {
|
||||
transitionTTL: 5 * 60 * 1000,
|
||||
transitionCleanupInterval: 60 * 1000,
|
||||
lossThreshold: 5000,
|
||||
userTTL: 3 * 60 * 1000
|
||||
userTTL: 3 * 60 * 1000,
|
||||
verifyTimeout: 30 * 1000,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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<string>();
|
||||
const userRooms = new Map<string, string>();
|
||||
@@ -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,16 +89,18 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
const user = newUser();
|
||||
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
|
||||
|
||||
// trigger camera
|
||||
|
||||
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);
|
||||
|
||||
@@ -132,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 });
|
||||
|
||||
@@ -141,17 +129,15 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
|
||||
let transition = transitions.get(userID);
|
||||
|
||||
// update room occupancy
|
||||
|
||||
if (!transition) {
|
||||
console.log("[!] Starting new transition");
|
||||
transition = new RoomTransition();
|
||||
transitions.set(userID, transition);
|
||||
}
|
||||
|
||||
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) {
|
||||
// trigger camera
|
||||
|
||||
if (
|
||||
transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])
|
||||
) {
|
||||
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
||||
|
||||
await redis.decr(`room:${resObj["room"]}`);
|
||||
@@ -161,12 +147,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -11,14 +12,15 @@ 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();
|
||||
|
||||
cleanupWorker();
|
||||
|
||||
await initCamera();
|
||||
|
||||
console.log("[!] Core started.");
|
||||
} catch (err: any) {
|
||||
console.log("[!] Bootstrap failed:", err.message);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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 };
|
||||
@@ -4,13 +4,10 @@ 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
|
||||
}
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import express from "express";
|
||||
import lookupRoutes from "./api/lookup/lookup.routes.js";
|
||||
import livedataRoutes from "./api/livedata/livadata.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";
|
||||
|
||||
(async () => {
|
||||
const app = express();
|
||||
|
||||
app.use("/lookup", lookupRoutes);
|
||||
app.use("/livedata", livedataRoutes);
|
||||
const { admin, router: adminRouter } = await createAdmin();
|
||||
|
||||
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();
|
||||
|
||||
})()
|
||||
})();
|
||||
|
||||
@@ -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 };
|
||||
@@ -7,7 +7,7 @@ let subRedisClient: RedisClientType;
|
||||
|
||||
export async function initSubRedis(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
export async function initRedis(): Promise<RedisClientType> {
|
||||
redisClient = createClient({
|
||||
url: `redis://${config.redis.host}:${config.redis.port}`
|
||||
url: `redis://${config.redis.host}:${config.redis.port}`,
|
||||
});
|
||||
|
||||
redisClient.on("error", (err) => {
|
||||
|
||||
@@ -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=
|
||||
Reference in New Issue
Block a user