Compare commits
56
Commits
public_api
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4c1e99e1 | ||
|
|
0222f34621 | ||
|
|
97dbdade45 | ||
|
|
4ab27bbabc | ||
|
|
5c7af613ff | ||
|
|
6040ce4c1d | ||
|
|
733e76a4bd | ||
|
|
ff7d0d4ee1 | ||
|
|
60277dfbd7 | ||
|
|
67695e8d47 | ||
|
|
f9a14fa2f1 | ||
|
|
2ae3674869 | ||
|
|
40c7fbda9c | ||
|
|
cbb89defe1 | ||
|
|
b4f66069d2 | ||
|
|
31fc14310b | ||
|
|
0576e1becf | ||
|
|
2f7454147a | ||
|
|
51b06ae868 | ||
|
|
734d1a29ad | ||
|
|
a16e8cc4ea | ||
|
|
d3ced437ec | ||
|
|
691fc3318e | ||
|
|
ccb71ecbd9 | ||
|
|
1d50ec3791 | ||
|
|
368806f5ed | ||
|
|
65e088f8e8 | ||
|
|
657c1db80d | ||
|
|
9cf8ddd921 | ||
|
|
600dfbe2a4 | ||
|
|
5b1c8e83a8 | ||
|
|
52da5fa2cb | ||
|
|
1075c7c7ad | ||
|
|
3221e95d0e | ||
|
|
a7974c78f3 | ||
|
|
98bffc4117 | ||
|
|
c7a0d921ea | ||
|
|
a788ea2642 | ||
|
|
2fb15443ef | ||
|
|
e3d8f41f02 | ||
|
|
83c9dbb9fa | ||
|
|
3c7e419ffa | ||
|
|
94d7b76c52 | ||
|
|
afbf67187f | ||
|
|
5004e86bee | ||
|
|
6a7e705f1f | ||
|
|
0f6728da40 | ||
|
|
7e1a7d0eca | ||
|
|
1ac5ddbf33 | ||
|
|
8dffdc9bf9 | ||
|
|
23116f6a19 | ||
|
|
2badcb92e2 | ||
|
|
8abae7b9ba | ||
|
|
1bce98e083 | ||
|
|
92b3c72e3e | ||
|
|
41cf0ad7ca |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 556 KiB |
+14
-1
@@ -50,10 +50,23 @@
|
|||||||
### Completed
|
### Completed
|
||||||
- Tag & Scanner CLI for configuration & persistent config
|
- Tag & Scanner CLI for configuration & persistent config
|
||||||
- Core script (MQTT, location estimation, Store in Redis)
|
- Core script (MQTT, location estimation, Store in Redis)
|
||||||
|
- NGSI-LD public api
|
||||||
|
- public dashboard (Node-RED)
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
- NGSI-LD data for public API
|
|
||||||
- Admin API with CRUD functions
|
- Admin API with CRUD functions
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- Add verification with camera in core script
|
- Add verification with camera in core script
|
||||||
|
|
||||||
|
## Week 5
|
||||||
|
### Completed
|
||||||
|
- camera scripts
|
||||||
|
- adminjs configuration
|
||||||
|
- integrate camera scripts to system
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- ~connect camera scripts to core script and adminjs~
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Debugging and refactor
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
services:
|
||||||
|
mosquitto:
|
||||||
|
image: eclipse-mosquitto:latest
|
||||||
|
container_name: mosquitto
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/config:/mosquitto/config
|
||||||
|
- ./mosquitto/data:/mosquitto/data
|
||||||
|
- ./mosquitto/log:/mosquitto/log
|
||||||
|
nodered:
|
||||||
|
image: nodered/node-red:latest
|
||||||
|
container_name: nodered
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1880:1880"
|
||||||
|
volumes:
|
||||||
|
- nodered_data:/data
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: ["redis-server", "/etc/redis/redis.conf"]
|
||||||
|
volumes:
|
||||||
|
- ./redis/data:/data
|
||||||
|
- ./redis/redis.conf:/etc/redis/redis.conf
|
||||||
|
influxdb:
|
||||||
|
image: influxdb:2
|
||||||
|
container_name: influxdb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8086:8086"
|
||||||
|
volumes:
|
||||||
|
- ./influxdb/data:/var/lib/influxdb2
|
||||||
|
- ./influxdb/config:/etc/influxdb2
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nodered_data:
|
||||||
|
external: true
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.git
|
.git
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
|
*.log
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
.prettierrc*
|
||||||
|
data
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ node_modules
|
|||||||
# Keep environment variables out of version control
|
# Keep environment variables out of version control
|
||||||
.env
|
.env
|
||||||
|
|
||||||
**/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -1,19 +1,75 @@
|
|||||||
FROM node:24-slim
|
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update -y && apt-get install -y openssl
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install all deps (including devDependencies for build)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
# Generate Prisma client (needed for TypeScript types)
|
||||||
|
COPY prisma ./prisma
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Pre-bundle AdminJS components at build time
|
||||||
|
RUN npm run bundle:adminjs && cp .adminjs/components.bundle.js .adminjs/bundle.js
|
||||||
|
|
||||||
|
# Compile TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: Production ──────────────────────────────────────────────────────
|
||||||
|
FROM node:24-slim AS production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install native runtime libs
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libdlib19.1 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libblas3 \
|
||||||
|
libopenblas0 \
|
||||||
|
libatlas3-base \
|
||||||
|
liblapack3 \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Copy compiled JS from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# AdminJS ComponentLoader validates source file paths exist at startup
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
|
||||||
|
# Copy pre-built AdminJS bundle
|
||||||
|
COPY --from=builder /app/.adminjs ./.adminjs
|
||||||
|
|
||||||
|
# Copy pre-built Go binaries
|
||||||
|
COPY --from=builder /app/bin ./bin
|
||||||
|
|
||||||
|
# Copy Prisma schema + generated client
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Set permissions for non-root user
|
||||||
|
RUN chown -R node:node /app/.adminjs /app/dist /app/src /app/bin
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
USER node
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV ADMIN_JS_SKIP_BUNDLE="true"
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
build:
|
||||||
|
docker build -t officesense:latest .
|
||||||
|
|
||||||
|
run:
|
||||||
|
docker run --rm -it --init -p 80:80 \
|
||||||
|
-e ADMIN_EMAIL=admin@officesense.com \
|
||||||
|
-e ADMIN_PASSWORD=supersecret \
|
||||||
|
-e ADMIN_COOKIE_SECRET=some-long-random-string \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e DATABASE_URL=file:/app/data/officesense.sqlite \
|
||||||
|
--device /dev/video0:/dev/video0 \
|
||||||
|
-v $(shell pwd)/data:/app/data \
|
||||||
|
officesense:latest
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker run --rm -it --init -p 80:80 \
|
||||||
|
-v $(shell pwd):/app \
|
||||||
|
-w /app \
|
||||||
|
-e ADMIN_EMAIL=admin@officesense.com \
|
||||||
|
-e ADMIN_PASSWORD=supersecret \
|
||||||
|
-e ADMIN_COOKIE_SECRET=some-long-random-string \
|
||||||
|
-e NODE_ENV=development \
|
||||||
|
--device /dev/video0:/dev/video0 \
|
||||||
|
node:24-slim \
|
||||||
|
bash -c "apt-get update && apt-get install -y libdlib19.1 libjpeg62-turbo libblas3 libopenblas0 libatlas3-base liblapack3 && node --run dev"
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
docker run --rm \
|
||||||
|
-e DATABASE_URL=file:/app/data/officesense.sqlite \
|
||||||
|
-v $(shell pwd)/data:/app/data \
|
||||||
|
officesense:latest \
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
sh:
|
||||||
|
docker run --rm -it -v $(shell pwd):/app -w /app node:24-slim sh
|
||||||
|
|
||||||
|
format:
|
||||||
|
docker run --rm -it --init \
|
||||||
|
-v $(shell pwd):/app \
|
||||||
|
-w /app \
|
||||||
|
node:24-slim \
|
||||||
|
bash -c "node --run format"
|
||||||
|
|
||||||
|
.PHONY: build run dev migrate sh format
|
||||||
Generated
+6666
-1323
File diff suppressed because it is too large
Load Diff
@@ -8,23 +8,32 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
|
"bundle:adminjs": "tsx src/api/management/bundler.ts",
|
||||||
"build": "tsc",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@adminjs/bundler": "^3.0.0",
|
||||||
"@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",
|
"prettier": "^3.8.3",
|
||||||
"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",
|
||||||
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"adminjs": "^7.8.17",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { getEntities, getEntitiesOfType, getEntity } from "./livedata.controller
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get("/entities", getEntities);
|
router.get("/entities", getEntities, getEntitiesOfType);
|
||||||
router.get("/entities", getEntitiesOfType);
|
|
||||||
router.get("/entities/:urn", getEntity);
|
router.get("/entities/:urn", getEntity);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
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";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
@@ -44,11 +49,11 @@ function convertToNGSIUser(user: RedisUserData): User {
|
|||||||
type: "User",
|
type: "User",
|
||||||
name: {
|
name: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
value: user.name
|
value: user.name,
|
||||||
},
|
},
|
||||||
locatedIn: {
|
locatedIn: {
|
||||||
type: "Relationship",
|
type: "Relationship",
|
||||||
object: user.room
|
object: user.room,
|
||||||
},
|
},
|
||||||
rssi: {
|
rssi: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
@@ -56,20 +61,20 @@ function convertToNGSIUser(user: RedisUserData): User {
|
|||||||
},
|
},
|
||||||
authenticationStatus: {
|
authenticationStatus: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
value: user.verified ? "verified" : "unverified"
|
value: user.verified ? "verified" : "unverified",
|
||||||
},
|
},
|
||||||
observedAt: {
|
observedAt: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
value: (new Date(user.timestamp)).toISOString()
|
value: new Date(user.timestamp).toISOString(),
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
|
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
|
||||||
const roomName = await prisma.room.findUnique({
|
const roomName = await prisma.room.findUnique({
|
||||||
where: { id: room.roomID },
|
where: { id: room.roomID },
|
||||||
select: { name: true }
|
select: { name: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!roomName?.name) return null;
|
if (!roomName?.name) return null;
|
||||||
|
|
||||||
@@ -78,13 +83,13 @@ async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
|
|||||||
type: "Room",
|
type: "Room",
|
||||||
name: {
|
name: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
value: roomName.name
|
value: roomName.name,
|
||||||
},
|
},
|
||||||
occupancy: {
|
occupancy: {
|
||||||
type: "Property",
|
type: "Property",
|
||||||
value: room.occupancy
|
value: room.occupancy,
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNGSIUsers(): Promise<User[]> {
|
async function getNGSIUsers(): Promise<User[]> {
|
||||||
@@ -121,8 +126,8 @@ export async function getEntities(req: Request, res: Response, next: NextFunctio
|
|||||||
|
|
||||||
let entities: Entity[] = [];
|
let entities: Entity[] = [];
|
||||||
|
|
||||||
entities.push(...await getNGSIUsers());
|
entities.push(...(await getNGSIUsers()));
|
||||||
entities.push(...await getNGSIRooms());
|
entities.push(...(await getNGSIRooms()));
|
||||||
|
|
||||||
return res.status(200).send(entities);
|
return res.status(200).send(entities);
|
||||||
}
|
}
|
||||||
@@ -131,8 +136,10 @@ export async function getEntitiesOfType(req: Request, res: Response) {
|
|||||||
const { type } = req.query;
|
const { type } = req.query;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "user": return res.status(200).send(await getNGSIUsers());
|
case "user":
|
||||||
case "room": return res.status(200).send(await getNGSIRooms());
|
return res.status(200).send(await getNGSIUsers());
|
||||||
|
case "room":
|
||||||
|
return res.status(200).send(await getNGSIRooms());
|
||||||
default:
|
default:
|
||||||
return res.sendStatus(400);
|
return res.sendStatus(400);
|
||||||
}
|
}
|
||||||
@@ -167,6 +174,5 @@ export async function getEntity(req: Request<Params>, res: Response) {
|
|||||||
if (!data.length) return res.sendStatus(404);
|
if (!data.length) return res.sendStatus(404);
|
||||||
|
|
||||||
return res.status(200).send(convertToNGSIUser(data[0]!));
|
return res.status(200).send(convertToNGSIUser(data[0]!));
|
||||||
} else
|
} else return res.sendStatus(400);
|
||||||
return res.sendStatus(400);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,9 @@ export async function getActiveUserData(userID?: string): Promise<RedisUserData[
|
|||||||
|
|
||||||
cursor = res.cursor;
|
cursor = res.cursor;
|
||||||
|
|
||||||
const values = await Promise.all(
|
const values = await Promise.all(res.keys.map((key) => redis.get(key)));
|
||||||
res.keys.map((key) => redis.get(key))
|
|
||||||
);
|
|
||||||
|
|
||||||
result.push(...values
|
|
||||||
.filter((v): v is string => v !== null)
|
|
||||||
.map((v) => JSON.parse(v))
|
|
||||||
);
|
|
||||||
|
|
||||||
|
result.push(...values.filter((v): v is string => v !== null).map((v) => JSON.parse(v)));
|
||||||
} while (cursor !== "0");
|
} while (cursor !== "0");
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -79,14 +73,14 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
result.push(...values
|
result.push(
|
||||||
.filter((v) => v.occupancy !== null)
|
...values
|
||||||
.map((v) => ({
|
.filter((v) => v.occupancy !== null)
|
||||||
roomID: v.roomID.slice(5),
|
.map((v) => ({
|
||||||
occupancy: Number(v.occupancy),
|
roomID: v.roomID.slice(5),
|
||||||
}))
|
occupancy: Number(v.occupancy),
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
} while (cursor !== "0");
|
} while (cursor !== "0");
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -99,8 +93,8 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
|
|||||||
return [{ roomID: roomID, occupancy: Number(res) }];
|
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({
|
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> {
|
export async function existsByUUID(uuid: string): Promise<boolean> {
|
||||||
const tag = await prisma.tag.findUnique({
|
const tag = await prisma.tag.findUnique({
|
||||||
where: { id: uuid }
|
where: { id: uuid },
|
||||||
});
|
});
|
||||||
|
|
||||||
return !!tag;
|
return !!tag;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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.bundler.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",
|
||||||
|
branding: { companyName: "OfficeSense", logo: false },
|
||||||
|
componentLoader,
|
||||||
|
resources: [userResource, tagResource, roomResource],
|
||||||
|
dashboard: { component: Components.Dashboard },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") await admin.watch();
|
||||||
|
else await admin.initialize();
|
||||||
|
|
||||||
|
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,9 @@
|
|||||||
|
import { bundle } from "@adminjs/bundler";
|
||||||
|
import { componentLoader } from "./components.bundler.js";
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await bundle({
|
||||||
|
componentLoader,
|
||||||
|
destinationDir: "./.adminjs",
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ComponentLoader } from "adminjs";
|
||||||
|
import path from "path";
|
||||||
|
import * as url from "url";
|
||||||
|
|
||||||
|
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
export const componentLoader = new ComponentLoader();
|
||||||
|
|
||||||
|
const add = (filePath: string, componentName: string): string =>
|
||||||
|
componentLoader.add(componentName, path.join(__dirname, filePath));
|
||||||
|
|
||||||
|
export const Components = {
|
||||||
|
Dashboard: add("components/Dashboard", "Dashboard"),
|
||||||
|
UploadFace: add("components/UploadFace", "UploadFace"),
|
||||||
|
FaceEmbeddingField: add("components/FaceEmbeddingField", "FaceEmbeddingField"),
|
||||||
|
};
|
||||||
@@ -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 type { 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 type { ActionProps } 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] ?? null); // 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.bundler.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;
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
api: {
|
api: {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -13,6 +16,13 @@ interface Config {
|
|||||||
redis: {
|
redis: {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
influx: {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
org: string;
|
||||||
|
bucket: string;
|
||||||
};
|
};
|
||||||
core: {
|
core: {
|
||||||
hysteresis: number;
|
hysteresis: number;
|
||||||
@@ -23,35 +33,48 @@ interface Config {
|
|||||||
transitionCleanupInterval: number;
|
transitionCleanupInterval: number;
|
||||||
lossThreshold: number;
|
lossThreshold: number;
|
||||||
userTTL: number;
|
userTTL: number;
|
||||||
|
verifyTimeout: number;
|
||||||
|
cameraInterval: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
api: {
|
api: {
|
||||||
address: "0.0.0.0",
|
address: process.env.API_ADDRESS ?? "0.0.0.0",
|
||||||
port: 80,
|
port: parseInt(process.env.API_PORT ?? "80"),
|
||||||
},
|
},
|
||||||
mqtt: {
|
mqtt: {
|
||||||
host: "192.168.1.2",
|
host: process.env.MQTT_HOST ?? "10.24.4.13",
|
||||||
port: 1883,
|
port: parseInt(process.env.MQTT_PORT ?? "1883"),
|
||||||
username: "user",
|
username: process.env.MQTT_USERNAME ?? "user",
|
||||||
password: "pass",
|
password: process.env.MQTT_PASSWORD ?? "pass",
|
||||||
topic: "scanners/+",
|
topic: process.env.MQTT_TOPIC ?? "scanners/+",
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
host: "192.168.1.2",
|
host: process.env.REDIS_HOST ?? "10.24.4.13",
|
||||||
port: 6379,
|
port: parseInt(process.env.REDIS_PORT ?? "6379"),
|
||||||
|
password: process.env.REDIS_PASSWORD ?? "redispassword",
|
||||||
|
},
|
||||||
|
influx: {
|
||||||
|
url: process.env.INFLUX_URL ?? "http://10.24.4.13:8086",
|
||||||
|
token: process.env.INFLUX_TOKEN ?? "",
|
||||||
|
org: process.env.INFLUX_ORG ?? "officesense",
|
||||||
|
bucket: process.env.INFLUX_BUCKET ?? "officesense",
|
||||||
},
|
},
|
||||||
core: {
|
core: {
|
||||||
hysteresis: 6,
|
hysteresis: parseInt(process.env.CORE_HYSTERESIS ?? "6"),
|
||||||
candidateHysteresis: 3,
|
candidateHysteresis: parseInt(process.env.CORE_CANDIDATE_HYSTERESIS ?? "5"),
|
||||||
debounceMS: 3000,
|
debounceMS: parseInt(process.env.CORE_DEBOUNCE_MS ?? String(3 * 1000)),
|
||||||
minSamples: 4,
|
minSamples: parseInt(process.env.CORE_MIN_SAMPLES ?? "4"),
|
||||||
transitionTTL: 5 * 60 * 1000,
|
transitionTTL: parseInt(process.env.CORE_TRANSITION_TTL ?? String(5 * 60 * 1000)),
|
||||||
transitionCleanupInterval: 60 * 1000,
|
transitionCleanupInterval: parseInt(
|
||||||
lossThreshold: 5000,
|
process.env.CORE_TRANSITION_CLEANUP_INTERVAL ?? String(60 * 1000)
|
||||||
userTTL: 3 * 60 * 1000
|
),
|
||||||
|
lossThreshold: parseInt(process.env.CORE_LOSS_THRESHOLD ?? String(8 * 1000)),
|
||||||
|
userTTL: parseInt(process.env.CORE_USER_TTL ?? String(3 * 60 * 1000)),
|
||||||
|
verifyTimeout: parseInt(process.env.CORE_VERIFY_TIMEOUT ?? String(5 * 60 * 1000)),
|
||||||
|
cameraInterval: parseInt(process.env.CORE_CAMERA_INTERVAL ?? String(5 * 60 * 1000)),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -3,31 +3,45 @@ 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";
|
import { type RedisUserData } from "../api/livedata/livedata.repository.js";
|
||||||
|
import { getInflux } from "../influx/influx.js";
|
||||||
|
import { Point } from "@influxdata/influxdb-client";
|
||||||
|
|
||||||
const adjectives = [
|
const adjectives = ["Crazy", "Silent", "Dark", "Fast", "Lucky", "Wild", "Epic"];
|
||||||
"Crazy",
|
const nouns = ["Tiger", "Wolf", "Falcon", "Shadow", "Ninja", "Dragon", "Phoenix"];
|
||||||
"Silent",
|
|
||||||
"Dark",
|
|
||||||
"Fast",
|
|
||||||
"Lucky",
|
|
||||||
"Wild",
|
|
||||||
"Epic"
|
|
||||||
];
|
|
||||||
|
|
||||||
const nouns = [
|
|
||||||
"Tiger",
|
|
||||||
"Wolf",
|
|
||||||
"Falcon",
|
|
||||||
"Shadow",
|
|
||||||
"Ninja",
|
|
||||||
"Dragon",
|
|
||||||
"Phoenix"
|
|
||||||
];
|
|
||||||
|
|
||||||
const running = new Set<string>();
|
const running = new Set<string>();
|
||||||
const userRooms = new Map<string, string>();
|
const userRooms = new Map<string, string>();
|
||||||
const userPseudo = new Map<string, string>();
|
const userPseudo = new Map<string, string>();
|
||||||
|
|
||||||
|
async function writeRoomEvent(
|
||||||
|
event: "enter" | "leave",
|
||||||
|
pseudoID: string,
|
||||||
|
roomID: string,
|
||||||
|
rssi: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const room = await prisma.room.findUnique({
|
||||||
|
where: { id: roomID },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomName = room?.name ?? roomID;
|
||||||
|
|
||||||
|
const point = new Point("room_events")
|
||||||
|
.tag("pseudo_id", pseudoID)
|
||||||
|
.tag("room_id", roomID)
|
||||||
|
.tag("room_name", roomName)
|
||||||
|
.tag("event", event)
|
||||||
|
.floatField("rssi", rssi);
|
||||||
|
|
||||||
|
getInflux().writePoint(point);
|
||||||
|
|
||||||
|
console.log(`[InfluxDB] Wrote '${event}' event for pseudo ${pseudoID} in room ${roomName}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[InfluxDB] Failed to write event:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateRoomOccupancyListener(message: string, channel: string) {
|
export async function updateRoomOccupancyListener(message: string, channel: string) {
|
||||||
console.log(`[Redis] Key ${message} expired.`);
|
console.log(`[Redis] Key ${message} expired.`);
|
||||||
|
|
||||||
@@ -37,10 +51,11 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
|
|||||||
|
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
|
|
||||||
if (!lastRoomID) {
|
if (!lastRoomID) console.log("[!] Key not found in local map");
|
||||||
console.log("[!] Key not found in local mapι")
|
else {
|
||||||
} else {
|
|
||||||
await redis.decr(`room:${lastRoomID}`);
|
await redis.decr(`room:${lastRoomID}`);
|
||||||
|
|
||||||
|
if (userPseudoID) await writeRoomEvent("leave", userPseudoID, lastRoomID, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.del(`_user:${userPseudoID}`);
|
await redis.del(`_user:${userPseudoID}`);
|
||||||
@@ -50,39 +65,39 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateNickname() {
|
function generateNickname() {
|
||||||
const adjective =
|
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||||
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);
|
const number = Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
return `${adjective}${noun}${number}`;
|
return `${adjective}${noun}${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newUser(): { pseudoID: string, psuedoName: string } {
|
function newUser(): { pseudoID: string; psuedoName: string } {
|
||||||
return {
|
return {
|
||||||
pseudoID: crypto.randomUUID(),
|
pseudoID: crypto.randomUUID(),
|
||||||
psuedoName: generateNickname()
|
psuedoName: generateNickname(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeData(roomID: string, metrics: {
|
export async function analyzeData(
|
||||||
tagID: string,
|
roomID: string,
|
||||||
rssi: number
|
metrics: {
|
||||||
}) {
|
tagID: string;
|
||||||
|
rssi: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
if (running.has(metrics.tagID)) {
|
if (running.has(metrics.tagID)) {
|
||||||
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`);
|
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} ROOM_ID: ${roomID} already in process.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
running.add(metrics.tagID);
|
running.add(metrics.tagID);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`\n=======================================================\n` +
|
console.log(
|
||||||
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
`\n=======================================================\n` +
|
||||||
`=======================================================\n`);
|
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
||||||
|
`=======================================================\n`
|
||||||
|
);
|
||||||
|
|
||||||
const userID = (
|
const userID = (
|
||||||
await prisma.tag.findUnique({
|
await prisma.tag.findUnique({
|
||||||
@@ -93,8 +108,7 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
|
|
||||||
console.log(`[SQLite] Resolved USER_ID: ${userID}`);
|
console.log(`[SQLite] Resolved USER_ID: ${userID}`);
|
||||||
|
|
||||||
if (!userID)
|
if (!userID) return;
|
||||||
return;
|
|
||||||
|
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
const res = await redis.get(`user:${userID}`);
|
const res = await redis.get(`user:${userID}`);
|
||||||
@@ -103,26 +117,28 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
const user = newUser();
|
const user = newUser();
|
||||||
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
|
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
|
||||||
|
|
||||||
// trigger camera
|
await redis.set(
|
||||||
|
`user:${userID}`,
|
||||||
await redis.set(`user:${userID}`, JSON.stringify({
|
JSON.stringify({
|
||||||
userID: user.pseudoID,
|
userID: user.pseudoID,
|
||||||
name: user.psuedoName,
|
name: user.psuedoName,
|
||||||
rssi: metrics.rssi,
|
rssi: metrics.rssi,
|
||||||
room: roomID,
|
room: roomID,
|
||||||
verified: false,
|
verified: false,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
}), { PX: config.core.userTTL });
|
}),
|
||||||
|
{ PX: config.core.userTTL }
|
||||||
|
);
|
||||||
|
|
||||||
await redis.set(`_user:${user.pseudoID}`, userID);
|
await redis.set(`_user:${user.pseudoID}`, userID);
|
||||||
|
|
||||||
await redis.incr(`room:${roomID}`);
|
await redis.incr(`room:${roomID}`);
|
||||||
|
|
||||||
userRooms.set(userID, roomID);
|
userRooms.set(userID, roomID);
|
||||||
userPseudo.set(userID, user.pseudoID);
|
userPseudo.set(userID, user.pseudoID);
|
||||||
|
|
||||||
console.log("[Redis] Stored new user in redis.");
|
await writeRoomEvent("enter", user.pseudoID, roomID, metrics.rssi);
|
||||||
|
|
||||||
|
console.log("[Redis] Stored new user in redis.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,42 +148,46 @@ export async function analyzeData(roomID: string, metrics: {
|
|||||||
if (roomID == resObj["room"]) {
|
if (roomID == resObj["room"]) {
|
||||||
console.log("[Redis] Same room, updating redis...");
|
console.log("[Redis] Same room, updating redis...");
|
||||||
resObj["rssi"] = metrics.rssi;
|
resObj["rssi"] = metrics.rssi;
|
||||||
resObj["timestamp"] = Date.now()
|
resObj["timestamp"] = Date.now();
|
||||||
|
|
||||||
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
transitions.set(userID, transition);
|
transitions.set(userID, transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) {
|
if (
|
||||||
// trigger camera
|
transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])
|
||||||
|
) {
|
||||||
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
||||||
|
|
||||||
await redis.decr(`room:${resObj["room"]}`);
|
const previousRoomID = resObj["room"];
|
||||||
|
const pseudoID = resObj["userID"];
|
||||||
|
|
||||||
|
await redis.decr(`room:${previousRoomID}`);
|
||||||
await redis.incr(`room:${roomID}`);
|
await redis.incr(`room:${roomID}`);
|
||||||
|
|
||||||
userRooms.set(userID, 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();
|
||||||
|
resObj["verified"] = false;
|
||||||
|
|
||||||
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
|
|
||||||
|
await writeRoomEvent("leave", pseudoID, previousRoomID, metrics.rssi);
|
||||||
|
await writeRoomEvent("enter", pseudoID, roomID, metrics.rssi);
|
||||||
|
} else {
|
||||||
console.log("[!] Transition declined");
|
console.log("[!] Transition declined");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
running.delete(metrics.tagID);
|
running.delete(metrics.tagID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
|
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
|
||||||
import { initRedis, initSubRedis } from "../redis/redis.js";
|
import { initRedis, initSubRedis } from "../redis/redis.js";
|
||||||
|
import { initCamera } from "./camera.js";
|
||||||
import * as mqtt from "./mqtt.js";
|
import * as mqtt from "./mqtt.js";
|
||||||
import { cleanupWorker } from "./transition.js";
|
import { cleanupWorker } from "./transition.js";
|
||||||
|
import { initInflux } from "../influx/influx.js";
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
@@ -11,17 +13,20 @@ export async function bootstrap() {
|
|||||||
console.log(`[!] Flushed Redis.`);
|
console.log(`[!] Flushed Redis.`);
|
||||||
|
|
||||||
const rooms = await getRoomIDs();
|
const rooms = await getRoomIDs();
|
||||||
for (const { id } of rooms)
|
for (const { id } of rooms) await redis.set(`room:${id}`, 0);
|
||||||
await redis.set(`room:${id}`, 0);
|
|
||||||
|
|
||||||
await initSubRedis();
|
await initSubRedis();
|
||||||
await mqtt.start();
|
await mqtt.start();
|
||||||
|
|
||||||
|
initInflux();
|
||||||
|
|
||||||
cleanupWorker();
|
cleanupWorker();
|
||||||
|
|
||||||
|
await initCamera();
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), { KEEPTTL: true, XX: true });
|
||||||
|
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), { KEEPTTL: true, XX: true });
|
||||||
|
|
||||||
|
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout);
|
||||||
|
|
||||||
|
console.log(`[Camera] Verified user ${r.uuid}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("camera run error:", e);
|
||||||
|
}
|
||||||
|
setTimeout(run, config.core.cameraInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { run as initCamera };
|
||||||
@@ -4,13 +4,10 @@ import { analyzeData } from "./analyze.js";
|
|||||||
|
|
||||||
function initMqtt(): Promise<MqttClient> {
|
function initMqtt(): Promise<MqttClient> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const client = mqtt.connect(
|
const client = mqtt.connect(`mqtt://${config.mqtt.host}:${config.mqtt.port}`, {
|
||||||
`mqtt://${config.mqtt.host}:${config.mqtt.port}`,
|
username: config.mqtt.username,
|
||||||
{
|
password: config.mqtt.password,
|
||||||
username: config.mqtt.username,
|
});
|
||||||
password: config.mqtt.password
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
client.once("connect", () => {
|
client.once("connect", () => {
|
||||||
console.log("[MQTT] MQTT connected.");
|
console.log("[MQTT] MQTT connected.");
|
||||||
@@ -41,7 +38,7 @@ export async function start() {
|
|||||||
|
|
||||||
mqttClient.on("error", (err) => {
|
mqttClient.on("error", (err) => {
|
||||||
console.log(err.message);
|
console.log(err.message);
|
||||||
})
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log("[MQTT] MQTT connection failed:", err.message);
|
console.log("[MQTT] MQTT connection failed:", err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ export function cleanupWorker() {
|
|||||||
for (const [userID, transition] of transitions) {
|
for (const [userID, transition] of transitions) {
|
||||||
const inactiveFor = now - transition.lastSeen;
|
const inactiveFor = now - transition.lastSeen;
|
||||||
|
|
||||||
if (inactiveFor > 5 * config.core.transitionTTL)
|
if (inactiveFor > 5 * config.core.transitionTTL) transitions.delete(userID);
|
||||||
transitions.delete(userID);
|
|
||||||
}
|
}
|
||||||
}, config.core.transitionCleanupInterval);
|
}, config.core.transitionCleanupInterval);
|
||||||
}
|
}
|
||||||
@@ -31,16 +30,20 @@ export class RoomTransition {
|
|||||||
this.candidate = { roomID: null, since: null, samples: 0, rssi: null };
|
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();
|
const now = Date.now();
|
||||||
this.lastSeen = now;
|
this.lastSeen = now;
|
||||||
|
|
||||||
const stronger = candidateRSSI > currRSSI + config.core.hysteresis;
|
const stronger = candidateRSSI > currRSSI + config.core.hysteresis;
|
||||||
|
|
||||||
const signalLost = now - lastCurrentSeen > config.core.lossThreshold;
|
const signalLost = now - lastCurrentSeen > config.core.lossThreshold;
|
||||||
|
|
||||||
console.log(`[TRANSITION] stronger = ${stronger}`);
|
console.log(`[TRANSITION] stronger = ${stronger}`);
|
||||||
console.log(`[TRANSITION] signalLost = ${signalLost}`);
|
console.log(`[TRANSITION] signalLost = ${signalLost} (prev=${lastCurrentSeen} now=${now})`);
|
||||||
|
|
||||||
if (!stronger && !signalLost) {
|
if (!stronger && !signalLost) {
|
||||||
this.reset();
|
this.reset();
|
||||||
@@ -48,7 +51,8 @@ export class RoomTransition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noCandidate = this.candidate.roomID == null;
|
const noCandidate = this.candidate.roomID == null;
|
||||||
const betterCandidate = this.candidate.roomID != roomID &&
|
const betterCandidate =
|
||||||
|
this.candidate.roomID != roomID &&
|
||||||
this.candidate.rssi !== null &&
|
this.candidate.rssi !== null &&
|
||||||
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
|
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
|
||||||
|
|
||||||
@@ -77,7 +81,11 @@ export class RoomTransition {
|
|||||||
console.log(`[TRANSITION] enoughConfirmations = ${enoughConfirmations}`);
|
console.log(`[TRANSITION] enoughConfirmations = ${enoughConfirmations}`);
|
||||||
console.log(`[TRANSITION] signalLost = ${signalLost}`);
|
console.log(`[TRANSITION] signalLost = ${signalLost}`);
|
||||||
|
|
||||||
if ((enoughTime && enoughConfirmations) || signalLost) {
|
if (enoughTime && enoughConfirmations) {
|
||||||
|
this.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (signalLost && this.candidate.roomID !== null && enoughConfirmations) {
|
||||||
this.reset();
|
this.reset();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -91,4 +99,4 @@ export class RoomTransition {
|
|||||||
this.candidate.samples = 0;
|
this.candidate.samples = 0;
|
||||||
this.candidate.rssi = null;
|
this.candidate.rssi = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
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 livedataRoutes from "./api/livedata/livadata.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("/lookup", lookupRoutes);
|
const { admin, router: adminRouter } = await createAdmin();
|
||||||
app.use("/livedata", livedataRoutes);
|
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
app.use("/lookup", lookupRouter);
|
||||||
|
app.use("/livedata", livedataRouter);
|
||||||
|
app.use("/admin", mapperRouter);
|
||||||
|
app.use("/admin/frontend/assets", express.static("/app/.adminjs"));
|
||||||
|
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
})();
|
||||||
})()
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { InfluxDB, type WriteApi } from "@influxdata/influxdb-client";
|
||||||
|
import config from "../config/config.js";
|
||||||
|
|
||||||
|
let writeApi: WriteApi | null = null;
|
||||||
|
|
||||||
|
export function initInflux(): void {
|
||||||
|
const client = new InfluxDB({
|
||||||
|
url: config.influx.url,
|
||||||
|
token: config.influx.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
writeApi = client.getWriteApi(config.influx.org, config.influx.bucket, "ms");
|
||||||
|
|
||||||
|
writeApi.useDefaultTags({ host: "officesense-pi" });
|
||||||
|
|
||||||
|
console.log("[InfluxDB] Write API initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInflux(): WriteApi {
|
||||||
|
if (!writeApi) throw new Error("[InfluxDB] Not initialized. Call initInflux() first.");
|
||||||
|
return writeApi;
|
||||||
|
}
|
||||||
@@ -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 });
|
export { prisma };
|
||||||
const prisma = new PrismaClient({ adapter });
|
|
||||||
|
|
||||||
export { prisma };
|
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import { updateRoomOccupancyListener } from "../core/analyze.js";
|
|||||||
let redisClient: RedisClientType;
|
let redisClient: RedisClientType;
|
||||||
let subRedisClient: RedisClientType;
|
let subRedisClient: RedisClientType;
|
||||||
|
|
||||||
|
const redisOptions = {
|
||||||
|
socket: {
|
||||||
|
host: config.redis.host,
|
||||||
|
port: config.redis.port,
|
||||||
|
},
|
||||||
|
password: config.redis.password,
|
||||||
|
};
|
||||||
|
|
||||||
export async function initSubRedis(): Promise<void> {
|
export async function initSubRedis(): Promise<void> {
|
||||||
subRedisClient = createClient({
|
subRedisClient = createClient(redisOptions);
|
||||||
url: `redis://${config.redis.host}:${config.redis.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
subRedisClient.on("error", (err) => {
|
subRedisClient.on("error", (err) => {
|
||||||
console.log("[Redis] SubRedis error:", err.message);
|
console.log("[Redis] SubRedis error:", err.message);
|
||||||
@@ -24,9 +30,7 @@ export async function initSubRedis(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initRedis(): Promise<RedisClientType> {
|
export async function initRedis(): Promise<RedisClientType> {
|
||||||
redisClient = createClient({
|
redisClient = createClient(redisOptions);
|
||||||
url: `redis://${config.redis.host}:${config.redis.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
redisClient.on("error", (err) => {
|
redisClient.on("error", (err) => {
|
||||||
console.log("[Redis] Redis error:", err.message);
|
console.log("[Redis] Redis error:", err.message);
|
||||||
@@ -45,4 +49,4 @@ export function getRedis(): RedisClientType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return redisClient;
|
return redisClient;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ void mqttTask(void *)
|
|||||||
{
|
{
|
||||||
while (!mqtt.connected())
|
while (!mqtt.connected())
|
||||||
{
|
{
|
||||||
|
if (WiFi.status() != WL_CONNECTED)
|
||||||
|
{
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
|
// Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
|
||||||
if (mqtt.connect(config.dev_name.c_str(), config.mqtt.username.c_str(), config.mqtt.password.c_str()))
|
if (mqtt.connect(config.dev_name.c_str(), config.mqtt.username.c_str(), config.mqtt.password.c_str()))
|
||||||
Serial.println("MQTT connected.");
|
Serial.println("MQTT connected.");
|
||||||
@@ -253,6 +258,9 @@ void saveConfig()
|
|||||||
|
|
||||||
prefs.putString(WIFI_SSID_CONFIG_KEY, config.wifi.ssid);
|
prefs.putString(WIFI_SSID_CONFIG_KEY, config.wifi.ssid);
|
||||||
prefs.putString(WIFI_PASS_CONFIG_KEY, config.wifi.password);
|
prefs.putString(WIFI_PASS_CONFIG_KEY, config.wifi.password);
|
||||||
|
prefs.putString(WIFI_IP_CONFIG_KEY, config.wifi.ip);
|
||||||
|
prefs.putString(WIFI_GW_CONFIG_KEY, config.wifi.gateway);
|
||||||
|
prefs.putString(WIFI_SUBNET_CONFIG_KEY, config.wifi.subnet);
|
||||||
|
|
||||||
prefs.putString(DEV_NAME_CONFIG_KEY, config.dev_name);
|
prefs.putString(DEV_NAME_CONFIG_KEY, config.dev_name);
|
||||||
prefs.putString(TAG_NAME_CONFIG_KEY, config.tag_name);
|
prefs.putString(TAG_NAME_CONFIG_KEY, config.tag_name);
|
||||||
@@ -280,6 +288,9 @@ void loadConfig()
|
|||||||
|
|
||||||
config.wifi.ssid = prefs.getString(WIFI_SSID_CONFIG_KEY, WIFI_SSID);
|
config.wifi.ssid = prefs.getString(WIFI_SSID_CONFIG_KEY, WIFI_SSID);
|
||||||
config.wifi.password = prefs.getString(WIFI_PASS_CONFIG_KEY, WIFI_PASS);
|
config.wifi.password = prefs.getString(WIFI_PASS_CONFIG_KEY, WIFI_PASS);
|
||||||
|
config.wifi.ip = prefs.getString(WIFI_IP_CONFIG_KEY, WIFI_IP);
|
||||||
|
config.wifi.gateway = prefs.getString(WIFI_GW_CONFIG_KEY, WIFI_GW);
|
||||||
|
config.wifi.subnet = prefs.getString(WIFI_SUBNET_CONFIG_KEY, WIFI_SUBNET);
|
||||||
|
|
||||||
config.dev_name = prefs.getString(DEV_NAME_CONFIG_KEY, DEV_NAME);
|
config.dev_name = prefs.getString(DEV_NAME_CONFIG_KEY, DEV_NAME);
|
||||||
config.tag_name = prefs.getString(TAG_NAME_CONFIG_KEY, TAG_NAME);
|
config.tag_name = prefs.getString(TAG_NAME_CONFIG_KEY, TAG_NAME);
|
||||||
@@ -324,6 +335,12 @@ void handleCommand(String line)
|
|||||||
config.wifi.ssid = value;
|
config.wifi.ssid = value;
|
||||||
else if (key == WIFI_PASS_CONFIG_KEY)
|
else if (key == WIFI_PASS_CONFIG_KEY)
|
||||||
config.wifi.password = value;
|
config.wifi.password = value;
|
||||||
|
else if (key == WIFI_IP_CONFIG_KEY)
|
||||||
|
config.wifi.ip = value;
|
||||||
|
else if (key == WIFI_GW_CONFIG_KEY)
|
||||||
|
config.wifi.gateway = value;
|
||||||
|
else if (key == WIFI_SUBNET_CONFIG_KEY)
|
||||||
|
config.wifi.subnet = value;
|
||||||
else if (key == DEV_NAME_CONFIG_KEY)
|
else if (key == DEV_NAME_CONFIG_KEY)
|
||||||
config.dev_name = value;
|
config.dev_name = value;
|
||||||
else if (key == TAG_NAME_CONFIG_KEY)
|
else if (key == TAG_NAME_CONFIG_KEY)
|
||||||
@@ -364,6 +381,12 @@ void handleCommand(String line)
|
|||||||
Serial.println(config.wifi.ssid);
|
Serial.println(config.wifi.ssid);
|
||||||
else if (key == WIFI_PASS_CONFIG_KEY)
|
else if (key == WIFI_PASS_CONFIG_KEY)
|
||||||
Serial.println(config.wifi.password);
|
Serial.println(config.wifi.password);
|
||||||
|
else if (key == WIFI_IP_CONFIG_KEY)
|
||||||
|
Serial.println(config.wifi.ip);
|
||||||
|
else if (key == WIFI_GW_CONFIG_KEY)
|
||||||
|
Serial.println(config.wifi.gateway);
|
||||||
|
else if (key == WIFI_SUBNET_CONFIG_KEY)
|
||||||
|
Serial.println(config.wifi.subnet);
|
||||||
else if (key == DEV_NAME_CONFIG_KEY)
|
else if (key == DEV_NAME_CONFIG_KEY)
|
||||||
Serial.println(config.dev_name);
|
Serial.println(config.dev_name);
|
||||||
else if (key == TAG_NAME_CONFIG_KEY)
|
else if (key == TAG_NAME_CONFIG_KEY)
|
||||||
@@ -388,6 +411,25 @@ void handleCommand(String line)
|
|||||||
Serial.println("Commands:");
|
Serial.println("Commands:");
|
||||||
Serial.println(" set <key> <value> - Set a configuration value");
|
Serial.println(" set <key> <value> - Set a configuration value");
|
||||||
Serial.println(" show <key> - Show a configuration value");
|
Serial.println(" show <key> - Show a configuration value");
|
||||||
|
Serial.println(" <key>:");
|
||||||
|
Serial.println(" int.cln");
|
||||||
|
Serial.println(" int.tagto");
|
||||||
|
Serial.println(" int.urtry");
|
||||||
|
Serial.println(" int.ucclean");
|
||||||
|
Serial.println(" api.url");
|
||||||
|
Serial.println(" api.uuidchk");
|
||||||
|
Serial.println(" mq.host");
|
||||||
|
Serial.println(" mq.port");
|
||||||
|
Serial.println(" mq.user");
|
||||||
|
Serial.println(" mq.pass");
|
||||||
|
Serial.println(" mq.topic");
|
||||||
|
Serial.println(" wf.ssid");
|
||||||
|
Serial.println(" wf.pass");
|
||||||
|
Serial.println(" wf.ip");
|
||||||
|
Serial.println(" wf.gw");
|
||||||
|
Serial.println(" wf.subnet");
|
||||||
|
Serial.println(" dev.name");
|
||||||
|
Serial.println(" tag.name");
|
||||||
Serial.println(" save - Save configuration to non-volatile storage");
|
Serial.println(" save - Save configuration to non-volatile storage");
|
||||||
Serial.println(" reset - Reset configuration to defaults");
|
Serial.println(" reset - Reset configuration to defaults");
|
||||||
Serial.println(" reboot - Reboot the device");
|
Serial.println(" reboot - Reboot the device");
|
||||||
@@ -395,13 +437,47 @@ void handleCommand(String line)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void wifiBegin()
|
||||||
|
{
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.disconnect();
|
||||||
|
delay(100);
|
||||||
|
|
||||||
|
IPAddress local_ip, gateway, subnet;
|
||||||
|
local_ip.fromString(config.wifi.ip);
|
||||||
|
|
||||||
|
if (local_ip != IPAddress(0, 0, 0, 0))
|
||||||
|
{
|
||||||
|
gateway.fromString(config.wifi.gateway);
|
||||||
|
subnet.fromString(config.wifi.subnet);
|
||||||
|
WiFi.config(local_ip, gateway, subnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFi.begin(config.wifi.ssid.c_str(), config.wifi.password.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
void setup()
|
void setup()
|
||||||
{
|
{
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
|
|
||||||
WiFi.begin(config.wifi.ssid.c_str(), config.wifi.password.c_str());
|
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info)
|
||||||
|
{
|
||||||
|
switch (event)
|
||||||
|
{
|
||||||
|
case ARDUINO_EVENT_WIFI_STA_CONNECTED:
|
||||||
|
Serial.println("WiFi connected.");
|
||||||
|
break;
|
||||||
|
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
|
||||||
|
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
|
||||||
|
break;
|
||||||
|
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
||||||
|
Serial.printf("WiFi disconnected, reason: %d\n", info.wifi_sta_disconnected.reason);
|
||||||
|
break;
|
||||||
|
} });
|
||||||
|
|
||||||
|
wifiBegin();
|
||||||
|
|
||||||
MQTT_PUB_TOPIC = config.mqtt.topic + config.dev_name;
|
MQTT_PUB_TOPIC = config.mqtt.topic + config.dev_name;
|
||||||
|
|
||||||
@@ -432,6 +508,22 @@ void setup()
|
|||||||
void loop()
|
void loop()
|
||||||
{
|
{
|
||||||
BleEvent ev;
|
BleEvent ev;
|
||||||
|
static uint32_t last_wifi_check = 0;
|
||||||
|
|
||||||
|
if (millis() - last_wifi_check >= WIFI_TEST_INTERVAL)
|
||||||
|
{
|
||||||
|
last_wifi_check = millis();
|
||||||
|
|
||||||
|
wl_status_t status = WiFi.status();
|
||||||
|
|
||||||
|
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL)
|
||||||
|
{
|
||||||
|
Serial.printf("WiFi failed (status %d), retrying...\n", status);
|
||||||
|
WiFi.disconnect();
|
||||||
|
delay(100);
|
||||||
|
wifiBegin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (xQueueReceive(ble_queue, &ev, 0))
|
while (xQueueReceive(ble_queue, &ev, 0))
|
||||||
processEvent(ev);
|
processEvent(ev);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
#define MQTT_TOPIC_CONFIG_KEY "mq.topic"
|
#define MQTT_TOPIC_CONFIG_KEY "mq.topic"
|
||||||
#define WIFI_SSID_CONFIG_KEY "wf.ssid"
|
#define WIFI_SSID_CONFIG_KEY "wf.ssid"
|
||||||
#define WIFI_PASS_CONFIG_KEY "wf.pass"
|
#define WIFI_PASS_CONFIG_KEY "wf.pass"
|
||||||
|
#define WIFI_IP_CONFIG_KEY "wf.ip"
|
||||||
|
#define WIFI_GW_CONFIG_KEY "wf.gw"
|
||||||
|
#define WIFI_SUBNET_CONFIG_KEY "wf.subnet"
|
||||||
#define DEV_NAME_CONFIG_KEY "dev.name"
|
#define DEV_NAME_CONFIG_KEY "dev.name"
|
||||||
#define TAG_NAME_CONFIG_KEY "tag.name"
|
#define TAG_NAME_CONFIG_KEY "tag.name"
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
#define UUID_CACHE_RETRY_TIMEOUT 5 * 60 * 1000 // ms
|
#define UUID_CACHE_RETRY_TIMEOUT 5 * 60 * 1000 // ms
|
||||||
#define UUID_CACHE_CLEAN_TIMEOUT 60 * 60 * 1000 // ms
|
#define UUID_CACHE_CLEAN_TIMEOUT 60 * 60 * 1000 // ms
|
||||||
#define API_URL "http://192.168.1.2"
|
#define API_URL "http://192.168.1.2"
|
||||||
#define UUID_CHECK_ENDPOINT "/check"
|
#define UUID_CHECK_ENDPOINT "/lookup/check"
|
||||||
#define TAG_NAME "X6TAG"
|
#define TAG_NAME "X6TAG"
|
||||||
#define MQTT_HOST "192.168.1.2"
|
#define MQTT_HOST "192.168.1.2"
|
||||||
#define MQTT_PORT 1883
|
#define MQTT_PORT 1883
|
||||||
@@ -35,7 +38,11 @@
|
|||||||
#define MQTT_TOPIC "scanners/"
|
#define MQTT_TOPIC "scanners/"
|
||||||
#define WIFI_SSID "COSMOTE-489882"
|
#define WIFI_SSID "COSMOTE-489882"
|
||||||
#define WIFI_PASS "x32hbh54673ngccdsfa9"
|
#define WIFI_PASS "x32hbh54673ngccdsfa9"
|
||||||
|
#define WIFI_IP "0.0.0.0"
|
||||||
|
#define WIFI_GW "0.0.0.0"
|
||||||
|
#define WIFI_SUBNET "255.255.255.0"
|
||||||
#define DEV_NAME "572b29cb-6a93-480e-adf0-c5c44e9a58df"
|
#define DEV_NAME "572b29cb-6a93-480e-adf0-c5c44e9a58df"
|
||||||
|
#define WIFI_TEST_INTERVAL 5 * 1000 // ms
|
||||||
|
|
||||||
struct Config
|
struct Config
|
||||||
{
|
{
|
||||||
@@ -66,6 +73,9 @@ struct Config
|
|||||||
{
|
{
|
||||||
String ssid;
|
String ssid;
|
||||||
String password;
|
String password;
|
||||||
|
String ip;
|
||||||
|
String gateway;
|
||||||
|
String subnet;
|
||||||
} wifi;
|
} wifi;
|
||||||
|
|
||||||
String dev_name;
|
String dev_name;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "nimble/nimble_port_freertos.h"
|
#include "nimble/nimble_port_freertos.h"
|
||||||
#include "services/gap/ble_svc_gap.h"
|
#include "services/gap/ble_svc_gap.h"
|
||||||
#include "driver/usb_serial_jtag.h"
|
#include "driver/usb_serial_jtag.h"
|
||||||
|
#include "driver/gpio.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
|
|
||||||
static config_t config;
|
static config_t config;
|
||||||
@@ -36,8 +37,8 @@ static void start_adv(void)
|
|||||||
|
|
||||||
params.conn_mode = BLE_GAP_CONN_MODE_NON;
|
params.conn_mode = BLE_GAP_CONN_MODE_NON;
|
||||||
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||||
params.itvl_min = 0x80; // ~100ms
|
params.itvl_min = config.adv_interval;
|
||||||
params.itvl_max = 0x80;
|
params.itvl_max = config.adv_interval;
|
||||||
|
|
||||||
rc = ble_gap_adv_start(
|
rc = ble_gap_adv_start(
|
||||||
BLE_ADDR_PUBLIC,
|
BLE_ADDR_PUBLIC,
|
||||||
@@ -54,6 +55,7 @@ static void start_adv(void)
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGI(config.name, "advertising started");
|
ESP_LOGI(config.name, "advertising started");
|
||||||
|
gpio_set_level(GPIO_NUM_15, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ static esp_err_t save_config()
|
|||||||
if (err != ESP_OK)
|
if (err != ESP_OK)
|
||||||
goto exit;
|
goto exit;
|
||||||
|
|
||||||
err = nvs_set_u32(nvs, "adv_int", config.adv_interval);
|
err = nvs_set_u16(nvs, "adv_int", config.adv_interval);
|
||||||
|
|
||||||
if (err != ESP_OK)
|
if (err != ESP_OK)
|
||||||
goto exit;
|
goto exit;
|
||||||
@@ -177,7 +179,7 @@ static esp_err_t load_config()
|
|||||||
|
|
||||||
nvs_get_str(nvs, "name", config.name, &len);
|
nvs_get_str(nvs, "name", config.name, &len);
|
||||||
|
|
||||||
nvs_get_u32(nvs, "adv_int", &config.adv_interval);
|
nvs_get_u16(nvs, "adv_int", &config.adv_interval);
|
||||||
|
|
||||||
nvs_close(nvs);
|
nvs_close(nvs);
|
||||||
|
|
||||||
@@ -219,9 +221,9 @@ void serial_task(void *arg)
|
|||||||
}
|
}
|
||||||
else if (strncmp(line, "set adv_interval ", 17) == 0)
|
else if (strncmp(line, "set adv_interval ", 17) == 0)
|
||||||
{
|
{
|
||||||
uint32_t interval = atoi(line + 17);
|
float interval = atof(line + 17);
|
||||||
if (interval > 0)
|
if (interval > 0)
|
||||||
config.adv_interval = interval;
|
config.adv_interval = (uint16_t)(interval / 0.625f);
|
||||||
}
|
}
|
||||||
else if (strcmp(line, "show uuid") == 0)
|
else if (strcmp(line, "show uuid") == 0)
|
||||||
{
|
{
|
||||||
@@ -235,7 +237,7 @@ void serial_task(void *arg)
|
|||||||
}
|
}
|
||||||
else if (strcmp(line, "show adv_interval") == 0)
|
else if (strcmp(line, "show adv_interval") == 0)
|
||||||
{
|
{
|
||||||
int len = snprintf(buf, sizeof(buf), "%lu\n", (unsigned long)config.adv_interval);
|
int len = snprintf(buf, sizeof(buf), "%.3f\n", config.adv_interval * 0.625f);
|
||||||
usb_serial_jtag_write_bytes(buf, len, 20 / portTICK_PERIOD_MS);
|
usb_serial_jtag_write_bytes(buf, len, 20 / portTICK_PERIOD_MS);
|
||||||
}
|
}
|
||||||
else if (strcmp(line, "save") == 0)
|
else if (strcmp(line, "save") == 0)
|
||||||
@@ -293,6 +295,10 @@ void serial_task(void *arg)
|
|||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
|
gpio_reset_pin(GPIO_NUM_15);
|
||||||
|
gpio_set_direction(GPIO_NUM_15, GPIO_MODE_OUTPUT);
|
||||||
|
gpio_set_level(GPIO_NUM_15, 1);
|
||||||
|
|
||||||
ESP_ERROR_CHECK(nvs_flash_init());
|
ESP_ERROR_CHECK(nvs_flash_init());
|
||||||
|
|
||||||
usb_serial_jtag_driver_config_t cfg = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT();
|
usb_serial_jtag_driver_config_t cfg = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT();
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
#define DEFAULT_UUID "12345678-9abc-def0-1234-56789abcdef0"
|
#define DEFAULT_UUID "12345678-9abc-def0-1234-56789abcdef0"
|
||||||
#define DEFAULT_NAME "X6TAG"
|
#define DEFAULT_NAME "X6TAG"
|
||||||
#define DEFAULT_ADV_INTERVAL 0x80
|
#define DEFAULT_ADV_INTERVAL 800
|
||||||
|
|
||||||
typedef struct
|
typedef struct
|
||||||
{
|
{
|
||||||
ble_uuid128_t uuid;
|
ble_uuid128_t uuid;
|
||||||
char uuid_str[37];
|
char uuid_str[37];
|
||||||
char name[32];
|
char name[32];
|
||||||
uint32_t adv_interval;
|
uint16_t adv_interval;
|
||||||
} config_t;
|
} config_t;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -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