Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c9dbb9fa | ||
|
|
3c7e419ffa | ||
|
|
94d7b76c52 | ||
|
|
afbf67187f | ||
|
|
5004e86bee | ||
|
|
6a7e705f1f | ||
|
|
0f6728da40 | ||
|
|
7e1a7d0eca | ||
|
|
1ac5ddbf33 | ||
|
|
8dffdc9bf9 | ||
|
|
23116f6a19 | ||
|
|
2badcb92e2 | ||
|
|
8abae7b9ba | ||
|
|
1bce98e083 | ||
|
|
92b3c72e3e | ||
|
|
41cf0ad7ca | ||
|
|
80ff81b1e0 | ||
|
|
46016d966d | ||
|
|
c2b947a197 | ||
|
|
66ab3fa6ca | ||
|
|
c8da529ce3 | ||
|
|
93c65a948b | ||
|
|
124ef542b0 | ||
|
|
31f8dc2f98 | ||
|
|
77f53435fe |
+13
@@ -45,3 +45,16 @@
|
|||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- WiFi self-healing and MQTT reconnection & testing
|
- 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Generated
+6626
-1331
File diff suppressed because it is too large
Load Diff
@@ -12,19 +12,21 @@
|
|||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/express-session": "^1.19.0",
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"prisma": "^7.8.0",
|
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
"@adminjs/express": "^6.1.1",
|
||||||
"@prisma/client": "^7.8.0",
|
"@adminjs/prisma": "^5.0.4",
|
||||||
"dotenv": "^17.4.2",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"adminjs": "^7.8.17",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"redis": "^5.12.1"
|
"redis": "^5.12.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client-js"
|
||||||
output = "../src/generated/prisma"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
faceEmbedding Bytes?
|
faceEmbedding String?
|
||||||
tag Tag?
|
tag Tag?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Room {
|
model Room {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,11 @@
|
|||||||
|
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'),
|
||||||
|
}
|
||||||
@@ -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,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,23 @@
|
|||||||
|
import { prisma } from "../../../lib/prisma.js";
|
||||||
|
import { getModelByName } from "@adminjs/prisma";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
resource: {
|
||||||
|
model: getModelByName("User"),
|
||||||
|
client: prisma,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
titleProperty: "id",
|
||||||
|
navigation: { icon: "User" },
|
||||||
|
listProperties: ["id", "firstName", "lastName"],
|
||||||
|
showProperties: ["id", "firstName", "lastName"],
|
||||||
|
editProperties: ["firstName", "lastName"],
|
||||||
|
filterProperties: ["firstName", "lastName"],
|
||||||
|
properties: {
|
||||||
|
faceEmbedding: {
|
||||||
|
isVisible: false,
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getRedis } from "./redis.js";
|
import { getRedis } from "../redis/redis.js";
|
||||||
import { prisma } from "../lib/prisma.js";
|
import { prisma } from "../lib/prisma.js";
|
||||||
import { RoomTransition, transitions } from "./transition.js";
|
import { RoomTransition, transitions } from "./transition.js";
|
||||||
import config from "../config/config.js";
|
import config from "../config/config.js";
|
||||||
|
import { type RedisUserData } from "../api/livedata/livedata.repository.js";
|
||||||
|
|
||||||
const adjectives = [
|
const adjectives = [
|
||||||
"Crazy",
|
"Crazy",
|
||||||
@@ -23,6 +24,31 @@ const nouns = [
|
|||||||
"Phoenix"
|
"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() {
|
function generateNickname() {
|
||||||
const adjective =
|
const adjective =
|
||||||
adjectives[Math.floor(Math.random() * adjectives.length)];
|
adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||||
@@ -46,6 +72,14 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
tagID: string,
|
tagID: string,
|
||||||
rssi: number
|
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` +
|
console.log(`\n=======================================================\n` +
|
||||||
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
||||||
`=======================================================\n`);
|
`=======================================================\n`);
|
||||||
@@ -80,12 +114,19 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}), { PX: config.core.userTTL });
|
}), { 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.");
|
console.log("[Redis] Stored new user in redis.");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resObj = JSON.parse(res);
|
let resObj: RedisUserData = JSON.parse(res);
|
||||||
console.log(`[Redis] User exists in redis in ROOM_ID: ${resObj["room"]}`);
|
console.log(`[Redis] User exists in redis in ROOM_ID: ${resObj["room"]}`);
|
||||||
|
|
||||||
if (roomID == resObj["room"]) {
|
if (roomID == resObj["room"]) {
|
||||||
@@ -100,6 +141,8 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
|
|
||||||
let transition = transitions.get(userID);
|
let transition = transitions.get(userID);
|
||||||
|
|
||||||
|
// update room occupancy
|
||||||
|
|
||||||
if (!transition) {
|
if (!transition) {
|
||||||
console.log("[!] Starting new transition");
|
console.log("[!] Starting new transition");
|
||||||
transition = new RoomTransition();
|
transition = new RoomTransition();
|
||||||
@@ -111,6 +154,11 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
|
|
||||||
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
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["rssi"] = metrics.rssi;
|
||||||
resObj["room"] = roomID;
|
resObj["room"] = roomID;
|
||||||
resObj["timestamp"] = Date.now()
|
resObj["timestamp"] = Date.now()
|
||||||
@@ -119,4 +167,7 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
||||||
} else
|
} else
|
||||||
console.log("[!] Transition declined");
|
console.log("[!] Transition declined");
|
||||||
|
} finally {
|
||||||
|
running.delete(metrics.tagID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
import { initRedis } from "./redis.js";
|
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
|
||||||
|
import { initRedis, initSubRedis } from "../redis/redis.js";
|
||||||
import * as mqtt from "./mqtt.js";
|
import * as mqtt from "./mqtt.js";
|
||||||
import { cleanupWorker } from "./transition.js";
|
import { cleanupWorker } from "./transition.js";
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
await initRedis();
|
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();
|
await mqtt.start();
|
||||||
|
|
||||||
cleanupWorker();
|
cleanupWorker();
|
||||||
|
|
||||||
console.log("Core started.");
|
console.log("[!] Core started.");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log("Bootstrap failed:", err.message);
|
console.log("[!] Bootstrap failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,13 +13,13 @@ function initMqtt(): Promise<MqttClient> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
client.once("connect", () => {
|
client.once("connect", () => {
|
||||||
console.log("MQTT connected.");
|
console.log("[MQTT] MQTT connected.");
|
||||||
|
|
||||||
client.subscribe(config.mqtt.topic, (err) => {
|
client.subscribe(config.mqtt.topic, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log("MQTT subscribed.");
|
console.log("[MQTT] MQTT subscribed.");
|
||||||
resolve(client);
|
resolve(client);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -43,6 +43,6 @@ export async function start() {
|
|||||||
console.log(err.message);
|
console.log(err.message);
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log("MQTT connection failed:", err.message);
|
console.log("[MQTT] MQTT connection failed:", err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { createClient, type RedisClientType } from "redis";
|
|
||||||
import config from "../config/config.js";
|
|
||||||
|
|
||||||
let redisClient: RedisClientType;
|
|
||||||
|
|
||||||
export async function initRedis(): Promise<RedisClientType> {
|
|
||||||
redisClient = createClient({
|
|
||||||
url: `redis://${config.redis.host}:${config.redis.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
redisClient.on("error", (err) => {
|
|
||||||
console.log("Redis error:", err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
await redisClient.connect();
|
|
||||||
|
|
||||||
console.log("Redis connected.");
|
|
||||||
|
|
||||||
return redisClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRedis(): RedisClientType {
|
|
||||||
if (!redisClient) {
|
|
||||||
throw new Error("Redis not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
return redisClient;
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import express from "express";
|
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 config from "./config/config.js";
|
||||||
import { bootstrap } from "./core/bootstrap.js";
|
import { bootstrap } from "./core/bootstrap.js";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use("/", lookupRoutes);
|
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, () => {
|
app.listen(config.api.port, config.api.address, () => {
|
||||||
console.log(`Server listening on ${config.api.address}:${config.api.port}`);
|
console.log(`Server listening on ${config.api.address}:${config.api.port}`);
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import "dotenv/config";
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
|
||||||
import { PrismaClient } from "../generated/prisma/client.js";
|
|
||||||
|
|
||||||
const connectionString = `${process.env.DATABASE_URL}`;
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
const adapter = new PrismaBetterSqlite3({ url: connectionString });
|
|
||||||
const prisma = new PrismaClient({ adapter });
|
|
||||||
|
|
||||||
export { prisma };
|
export { prisma };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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