Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221e95d0e | ||
|
|
a7974c78f3 | ||
|
|
98bffc4117 | ||
|
|
c7a0d921ea | ||
|
|
a788ea2642 | ||
|
|
2fb15443ef | ||
|
|
e3d8f41f02 | ||
|
|
83c9dbb9fa | ||
|
|
3c7e419ffa | ||
|
|
94d7b76c52 | ||
|
|
afbf67187f | ||
|
|
5004e86bee | ||
|
|
6a7e705f1f | ||
|
|
0f6728da40 | ||
|
|
7e1a7d0eca | ||
|
|
1ac5ddbf33 | ||
|
|
8dffdc9bf9 | ||
|
|
23116f6a19 | ||
|
|
2badcb92e2 | ||
|
|
8abae7b9ba | ||
|
|
1bce98e083 | ||
|
|
92b3c72e3e | ||
|
|
41cf0ad7ca | ||
|
|
80ff81b1e0 | ||
|
|
46016d966d | ||
|
|
c2b947a197 | ||
|
|
66ab3fa6ca | ||
|
|
c8da529ce3 | ||
|
|
93c65a948b | ||
|
|
124ef542b0 | ||
|
|
31f8dc2f98 | ||
|
|
77f53435fe | ||
|
|
565900c9d4 | ||
|
|
a3d28e8cd5 | ||
|
|
9d024de5a6 | ||
|
|
18932a3f4d | ||
|
|
e4868f6fdc | ||
|
|
d4cf7f5b48 | ||
|
|
c8f07e22d9 | ||
|
|
22f4d777a4 | ||
|
|
3977e8d17b | ||
|
|
a50c48a92d | ||
|
|
96233d2ae5 | ||
|
|
86cc46ea9b | ||
|
|
d5738d460c | ||
|
|
80f7c89852 | ||
|
|
744d145ec2 | ||
|
|
a775109b36 | ||
|
|
e349ef92b7 | ||
|
|
095da18f73 |
+50
-9
@@ -1,21 +1,62 @@
|
|||||||
# System Architecture
|
# System Architecture
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Describe the project at a high level.
|
OfficeSense is a Smart Office Presence system that detects user presence and identifies the room where each user is located using Bluetooth Low Energy (BLE) technology. Users carry BLE tags that periodically broadcast pseudonymized identifiers, while BLE scanners installed in different rooms detect these signals and forward the data to a Raspberry Pi for processing.
|
||||||
|
|
||||||
|
The system follows a privacy-by-design approach by using identifiers instead of personal data during communication. In addition to BLE-based presence detection, a camera connected to the Raspberry Pi performs face-recognition-based authentication to verify that the detected BLE tag belongs to the actual user.
|
||||||
|
|
||||||
|
The architecture combines edge computing, real-time processing, and lightweight storage technologies to provide low-latency monitoring, room occupancy tracking, and secure user authentication through real-time dashboards.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
- sensors / input devices
|
- BLE Tags (Seeed Studio XIAO ESP32-C6)
|
||||||
- processing node
|
- BLE Scanners (Waveshare ESP32-S3 Zero)
|
||||||
- communication
|
- USB Camera
|
||||||
- storage
|
- Processing Node (Raspberry Pi)
|
||||||
- dashboard / app
|
- Communication (BLE, WiFi, MQTT, HTTP, NGSI-LD)
|
||||||
- actuators / notifications
|
- Storage (SQLite, Redis, Optional InfluxDB)
|
||||||
|
- Dashboard (Node-RED, AdminJS, Optional Grafana)
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
Describe how data moves through the system.
|
1. A BLE tag periodically broadcasts a pseudonymized UUID.
|
||||||
|
2. Nearby BLE scanners receive the signal and measure RSSI values.
|
||||||
|
3. The scanners:
|
||||||
|
- validate UUIDs,
|
||||||
|
- apply EMA filtering to RSSI values,
|
||||||
|
- cache validation responses,
|
||||||
|
- package the data and publish to the MQTT broker over WiFi.
|
||||||
|
4. The Raspberry Pi receives MQTT messages and processes them.
|
||||||
|
5. The system estimates the user’s room based on strongest RSSI values.
|
||||||
|
6. When a new presence event occurs:
|
||||||
|
- the camera is triggered,
|
||||||
|
- image frames are captured,
|
||||||
|
- the face recognition service verifies the user identity.
|
||||||
|
7. The system compares:
|
||||||
|
- BLE tag identity
|
||||||
|
- face recognition identity
|
||||||
|
8. The authentication result and room location are stored in Redis.
|
||||||
|
9. Persistent information is stored in SQLite.
|
||||||
|
10. Backend APIs provide data to dashboards for real-time visualization.
|
||||||
|
11. If no BLE signal is received for a predefined timeout period, the user is automatically marked as absent.
|
||||||
|
|
||||||
## Decisions and Tradeoffs
|
## Decisions and Tradeoffs
|
||||||
Explain key design choices.
|
- Edge Computing with Raspberry Pi
|
||||||
|
- Reduces latency
|
||||||
|
- Minimizes cloud dependency
|
||||||
|
- Improves privacy and reliability
|
||||||
|
- Hybrid Storage Architecture
|
||||||
|
- SQLite for reliable persistent storage
|
||||||
|
- Redis for fast real-time operations
|
||||||
|
- Event-Driven Processing
|
||||||
|
- BLE detections trigger processing only when needed
|
||||||
|
- Reduces unnecessary computation
|
||||||
|
- Privacy-by-Design
|
||||||
|
- Uses pseudonymized UUIDs instead of direct user identities
|
||||||
|
- MQTT Communication
|
||||||
|
- Lightweight and suitable for IoT environments
|
||||||
|
- Supports asynchronous communication
|
||||||
|
|
||||||
|
- RSSI-based localization is less accurate than more advanced positioning systems
|
||||||
|
- Face recognition increases security but also adds computational overhead
|
||||||
|
|
||||||
## Validation Plan
|
## Validation Plan
|
||||||
Describe how you will test the system.
|
Describe how you will test the system.
|
||||||
|
|||||||
+41
-9
@@ -1,7 +1,5 @@
|
|||||||
# Weekly Progress Log
|
# Weekly Progress Log
|
||||||
|
|
||||||
Update this file every week.
|
|
||||||
|
|
||||||
## Week 1
|
## Week 1
|
||||||
### Completed
|
### Completed
|
||||||
- Repository created
|
- Repository created
|
||||||
@@ -18,19 +16,53 @@ Update this file every week.
|
|||||||
- Finalize architecture
|
- Finalize architecture
|
||||||
- Create first Issues
|
- Create first Issues
|
||||||
|
|
||||||
### Team Contribution
|
|
||||||
- Student 1:
|
|
||||||
- Student 2:
|
|
||||||
|
|
||||||
## Week 2
|
## Week 2
|
||||||
### Completed
|
### Completed
|
||||||
|
- Full BLE Tag code
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
|
|
||||||
### Problems / Risks
|
### Problems / Risks
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
- UUID modification during Runtime
|
||||||
|
|
||||||
### Team Contribution
|
## Week 3
|
||||||
- Student 1:
|
### Completed
|
||||||
- Student 2:
|
- Full BLE Scanner code
|
||||||
|
- SQLite schema with Prisma ORM
|
||||||
|
- UUID lookup for public API
|
||||||
|
- Connected and tested functionality of all equipment
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- Core script to receive MQTT data, process and store in Redis
|
||||||
|
- NGSI-LD data for public API
|
||||||
|
- Admin API with CRUD functions
|
||||||
|
|
||||||
|
### Problems / Risks
|
||||||
|
- WiFi won't reconnect
|
||||||
|
- no auth to use public API
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- WiFi self-healing and MQTT reconnection & testing
|
||||||
|
|
||||||
|
## Week 4
|
||||||
|
### Completed
|
||||||
|
- Tag & Scanner CLI for configuration & persistent config
|
||||||
|
- Core script (MQTT, location estimation, Store in Redis)
|
||||||
|
- NGSI-LD public api
|
||||||
|
- public dashboard (Node-RED)
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- Admin API with CRUD functions
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Add verification with camera in core script
|
||||||
|
|
||||||
|
## Week 5
|
||||||
|
### Completed
|
||||||
|
- camera scripts
|
||||||
|
- adminjs configuration
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- connect camera scripts to core script and adminjs
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "f1398f7c64b1d0ea",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "OfficeSense Flow",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "",
|
||||||
|
"env": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20bb112ec6e856de",
|
||||||
|
"type": "ui-table",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"group": "05cf402c93866d4e",
|
||||||
|
"name": "",
|
||||||
|
"label": "",
|
||||||
|
"order": 1,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"maxrows": 0,
|
||||||
|
"passthru": false,
|
||||||
|
"autocols": true,
|
||||||
|
"showSearch": true,
|
||||||
|
"deselect": true,
|
||||||
|
"selectionType": "none",
|
||||||
|
"columns": [],
|
||||||
|
"mobileBreakpoint": "sm",
|
||||||
|
"mobileBreakpointType": "defaults",
|
||||||
|
"action": "replace",
|
||||||
|
"className": "",
|
||||||
|
"x": 690,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0a5259b54388cdfc",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"name": "",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "payload"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "3",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": 0.1,
|
||||||
|
"topic": "",
|
||||||
|
"payload": "",
|
||||||
|
"payloadType": "date",
|
||||||
|
"x": 70,
|
||||||
|
"y": 400,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"4030b9e7bcab5435",
|
||||||
|
"f37401387ca7a595"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4030b9e7bcab5435",
|
||||||
|
"type": "http request",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"name": "",
|
||||||
|
"method": "GET",
|
||||||
|
"ret": "obj",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": "http://192.168.1.2/livedata/entities",
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"insecureHTTPParser": false,
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"headers": [],
|
||||||
|
"x": 270,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"4aee360198b34403"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f37401387ca7a595",
|
||||||
|
"type": "http request",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"name": "",
|
||||||
|
"method": "GET",
|
||||||
|
"ret": "obj",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": "http://192.168.1.2/livedata/entities?type=room",
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"insecureHTTPParser": false,
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"headers": [],
|
||||||
|
"x": 270,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"448ba65b518d534d"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "448ba65b518d534d",
|
||||||
|
"type": "function",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"name": "ParseNGSI",
|
||||||
|
"func": "msg.payload = msg.payload.map(r => {\n return {\n Room: r.name?.value,\n Occupancy: r.occupancy?.value\n };\n});\n\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"timeout": 0,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 490,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"586c57be87bf8bc6",
|
||||||
|
"49042a0c5086d4b8"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "586c57be87bf8bc6",
|
||||||
|
"type": "ui-table",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"group": "a98ec11a60f808be",
|
||||||
|
"name": "",
|
||||||
|
"label": "",
|
||||||
|
"order": 1,
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"maxrows": 0,
|
||||||
|
"passthru": false,
|
||||||
|
"autocols": true,
|
||||||
|
"showSearch": true,
|
||||||
|
"deselect": true,
|
||||||
|
"selectionType": "none",
|
||||||
|
"columns": [],
|
||||||
|
"mobileBreakpoint": "sm",
|
||||||
|
"mobileBreakpointType": "defaults",
|
||||||
|
"action": "replace",
|
||||||
|
"className": "",
|
||||||
|
"x": 690,
|
||||||
|
"y": 440,
|
||||||
|
"wires": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4aee360198b34403",
|
||||||
|
"type": "function",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"name": "ParseNGSI",
|
||||||
|
"func": "const entities = msg.payload;\n\nconst users = entities.filter(e => e.type === \"User\");\nconst rooms = entities.filter(e => e.type === \"Room\");\n\nconst roomMap = {};\nfor (const r of rooms) {\n roomMap[r.id.replace(\"urn:ngsi-ld:Room:\", \"\")] = r;\n}\n\nmsg.payload = users.map(u => {\n\n const roomId = u.locatedIn?.object;\n const room = roomMap[roomId];\n\n return {\n Name: u.name?.value,\n Room: room ? room.name?.value : \"Unknown\",\n RSSI: u.rssi?.value,\n Auth: u.authenticationStatus?.value,\n Time: new Date(u.observedAt?.value)\n .toLocaleString(\"el-GR\", { timeZone: \"Europe/Athens\" })\n };\n});\n\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"timeout": 0,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 490,
|
||||||
|
"y": 360,
|
||||||
|
"wires": [
|
||||||
|
[
|
||||||
|
"20bb112ec6e856de"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "49042a0c5086d4b8",
|
||||||
|
"type": "ui-chart",
|
||||||
|
"z": "f1398f7c64b1d0ea",
|
||||||
|
"group": "a98ec11a60f808be",
|
||||||
|
"name": "",
|
||||||
|
"label": "Room Occupancy Chart",
|
||||||
|
"order": 2,
|
||||||
|
"chartType": "bar",
|
||||||
|
"category": "",
|
||||||
|
"categoryType": "none",
|
||||||
|
"xAxisLabel": "Room",
|
||||||
|
"xAxisProperty": "Room",
|
||||||
|
"xAxisPropertyType": "property",
|
||||||
|
"xAxisType": "category",
|
||||||
|
"xAxisFormat": "",
|
||||||
|
"xAxisFormatType": "auto",
|
||||||
|
"xmin": "",
|
||||||
|
"xmax": "",
|
||||||
|
"yAxisLabel": "Occupancy",
|
||||||
|
"yAxisProperty": "Occupancy",
|
||||||
|
"yAxisPropertyType": "property",
|
||||||
|
"ymin": "0",
|
||||||
|
"ymax": "10",
|
||||||
|
"bins": 10,
|
||||||
|
"action": "replace",
|
||||||
|
"stackSeries": false,
|
||||||
|
"pointShape": "circle",
|
||||||
|
"pointRadius": 4,
|
||||||
|
"showLegend": true,
|
||||||
|
"removeOlder": 1,
|
||||||
|
"removeOlderUnit": "3600",
|
||||||
|
"removeOlderPoints": "",
|
||||||
|
"colors": [
|
||||||
|
"#0095ff",
|
||||||
|
"#ff0000",
|
||||||
|
"#ff7f0e",
|
||||||
|
"#2ca02c",
|
||||||
|
"#a347e1",
|
||||||
|
"#d62728",
|
||||||
|
"#ff9896",
|
||||||
|
"#9467bd",
|
||||||
|
"#c5b0d5"
|
||||||
|
],
|
||||||
|
"textColor": [
|
||||||
|
"#666666"
|
||||||
|
],
|
||||||
|
"textColorDefault": true,
|
||||||
|
"gridColor": [
|
||||||
|
"#e5e5e5"
|
||||||
|
],
|
||||||
|
"gridColorDefault": true,
|
||||||
|
"width": 6,
|
||||||
|
"height": 8,
|
||||||
|
"className": "",
|
||||||
|
"interpolation": "linear",
|
||||||
|
"x": 750,
|
||||||
|
"y": 520,
|
||||||
|
"wires": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "05cf402c93866d4e",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Users",
|
||||||
|
"page": "fc203945d260d5ec",
|
||||||
|
"width": 6,
|
||||||
|
"height": 1,
|
||||||
|
"order": 1,
|
||||||
|
"showTitle": true,
|
||||||
|
"className": "",
|
||||||
|
"visible": "true",
|
||||||
|
"disabled": "false",
|
||||||
|
"groupType": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a98ec11a60f808be",
|
||||||
|
"type": "ui-group",
|
||||||
|
"name": "Rooms",
|
||||||
|
"page": "fc203945d260d5ec",
|
||||||
|
"width": 6,
|
||||||
|
"height": 1,
|
||||||
|
"order": 2,
|
||||||
|
"showTitle": true,
|
||||||
|
"className": "",
|
||||||
|
"visible": "true",
|
||||||
|
"disabled": "false",
|
||||||
|
"groupType": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc203945d260d5ec",
|
||||||
|
"type": "ui-page",
|
||||||
|
"name": "OfficeSense",
|
||||||
|
"ui": "7d0f4d8165214cca",
|
||||||
|
"path": "/officesense",
|
||||||
|
"icon": "home",
|
||||||
|
"layout": "grid",
|
||||||
|
"theme": "f49a05b173b6cac3",
|
||||||
|
"breakpoints": [
|
||||||
|
{
|
||||||
|
"name": "Default",
|
||||||
|
"px": "0",
|
||||||
|
"cols": "3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tablet",
|
||||||
|
"px": "576",
|
||||||
|
"cols": "6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Small Desktop",
|
||||||
|
"px": "768",
|
||||||
|
"cols": "9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Desktop",
|
||||||
|
"px": "1024",
|
||||||
|
"cols": "12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"order": 1,
|
||||||
|
"className": "",
|
||||||
|
"visible": "true",
|
||||||
|
"disabled": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7d0f4d8165214cca",
|
||||||
|
"type": "ui-base",
|
||||||
|
"name": "My Dashboard",
|
||||||
|
"path": "/dashboard",
|
||||||
|
"appIcon": "",
|
||||||
|
"includeClientData": true,
|
||||||
|
"acceptsClientConfig": [
|
||||||
|
"ui-notification",
|
||||||
|
"ui-control"
|
||||||
|
],
|
||||||
|
"showPathInSidebar": false,
|
||||||
|
"headerContent": "page",
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"titleBarStyle": "default",
|
||||||
|
"showReconnectNotification": true,
|
||||||
|
"notificationDisplayTime": 1,
|
||||||
|
"showDisconnectNotification": true,
|
||||||
|
"allowInstall": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f49a05b173b6cac3",
|
||||||
|
"type": "ui-theme",
|
||||||
|
"name": "Default Theme",
|
||||||
|
"colors": {
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"primary": "#0094ce",
|
||||||
|
"bgPage": "#eeeeee",
|
||||||
|
"groupBg": "#ffffff",
|
||||||
|
"groupOutline": "#cccccc"
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"density": "default",
|
||||||
|
"pagePadding": "12px",
|
||||||
|
"groupGap": "12px",
|
||||||
|
"groupBorderRadius": "4px",
|
||||||
|
"widgetGap": "12px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2372b8eb214a55ee",
|
||||||
|
"type": "global-config",
|
||||||
|
"env": [],
|
||||||
|
"modules": {
|
||||||
|
"@flowfuse/node-red-dashboard": "1.30.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,4 +2,6 @@ node_modules
|
|||||||
# Keep environment variables out of version control
|
# 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
|
||||||
|
}
|
||||||
Generated
+6988
-1160
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,27 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.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",
|
|
||||||
"@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",
|
||||||
"dotenv": "^17.4.2",
|
"@prisma/client": "^6.19.3",
|
||||||
"express": "^5.2.1"
|
"adminjs": "^7.8.17",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
|
"mqtt": "^5.15.1",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"redis": "^5.12.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// This file was generated by Prisma, and assumes you have installed the following:
|
|
||||||
// npm install --save-dev prisma dotenv
|
|
||||||
import "dotenv/config";
|
|
||||||
import { defineConfig } from "prisma/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "prisma/schema.prisma",
|
|
||||||
migrations: {
|
|
||||||
path: "prisma/migrations",
|
|
||||||
},
|
|
||||||
datasource: {
|
|
||||||
url: process.env["DATABASE_URL"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Room" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"nameame" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"faceEmbedding" BLOB
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("faceEmbedding", "firstName", "id", "lastName") SELECT "faceEmbedding", "firstName", "id", "lastName" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `nameame` on the `Room` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `name` to the `Room` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Room" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Room" ("id") SELECT "id" FROM "Room";
|
||||||
|
DROP TABLE "Room";
|
||||||
|
ALTER TABLE "new_Room" RENAME TO "Room";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"faceEmbedding" TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("faceEmbedding", "firstName", "id", "lastName") SELECT "faceEmbedding", "firstName", "id", "lastName" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Tag" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Tag" ("id", "userId") SELECT "id", "userId" FROM "Tag";
|
||||||
|
DROP TABLE "Tag";
|
||||||
|
ALTER TABLE "new_Tag" RENAME TO "Tag";
|
||||||
|
CREATE UNIQUE INDEX "Tag_userId_key" ON "Tag"("userId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
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 {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getEntities, getEntitiesOfType, getEntity } from "./livedata.controller.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/entities", getEntities, getEntitiesOfType);
|
||||||
|
router.get("/entities/:urn", getEntity);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import {
|
||||||
|
getActiveUserData,
|
||||||
|
getRedisRoomData,
|
||||||
|
type RedisRoomData,
|
||||||
|
type RedisUserData,
|
||||||
|
} from "./livedata.repository.js";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
urn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Relationship = {
|
||||||
|
type: "Relationship";
|
||||||
|
object: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Property<T = any> = {
|
||||||
|
type: "Property";
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: {
|
||||||
|
type: "Property";
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User extends Entity {
|
||||||
|
type: "User";
|
||||||
|
locatedIn: Relationship;
|
||||||
|
rssi: Property<number>;
|
||||||
|
authenticationStatus: Property<string>;
|
||||||
|
observedAt: Property<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Room extends Entity {
|
||||||
|
type: "Room";
|
||||||
|
occupancy: Property<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToNGSIUser(user: RedisUserData): User {
|
||||||
|
return {
|
||||||
|
id: `urn:ngsi-ld:User:${user.userID}`,
|
||||||
|
type: "User",
|
||||||
|
name: {
|
||||||
|
type: "Property",
|
||||||
|
value: user.name,
|
||||||
|
},
|
||||||
|
locatedIn: {
|
||||||
|
type: "Relationship",
|
||||||
|
object: user.room,
|
||||||
|
},
|
||||||
|
rssi: {
|
||||||
|
type: "Property",
|
||||||
|
value: user.rssi,
|
||||||
|
},
|
||||||
|
authenticationStatus: {
|
||||||
|
type: "Property",
|
||||||
|
value: user.verified ? "verified" : "unverified",
|
||||||
|
},
|
||||||
|
observedAt: {
|
||||||
|
type: "Property",
|
||||||
|
value: new Date(user.timestamp).toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
|
||||||
|
const roomName = await prisma.room.findUnique({
|
||||||
|
where: { id: room.roomID },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!roomName?.name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `urn:ngsi-ld:Room:${room.roomID}`,
|
||||||
|
type: "Room",
|
||||||
|
name: {
|
||||||
|
type: "Property",
|
||||||
|
value: roomName.name,
|
||||||
|
},
|
||||||
|
occupancy: {
|
||||||
|
type: "Property",
|
||||||
|
value: room.occupancy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNGSIUsers(): Promise<User[]> {
|
||||||
|
let users: User[] = [];
|
||||||
|
|
||||||
|
const result: RedisUserData[] = await getActiveUserData();
|
||||||
|
|
||||||
|
for (const r of result) {
|
||||||
|
const user = convertToNGSIUser(r);
|
||||||
|
|
||||||
|
users.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNGSIRooms(): Promise<Room[]> {
|
||||||
|
let rooms: Room[] = [];
|
||||||
|
|
||||||
|
const result: RedisRoomData[] = await getRedisRoomData();
|
||||||
|
|
||||||
|
for (const r of result) {
|
||||||
|
const room = await convertToNGSIRoom(r);
|
||||||
|
if (room == null) continue;
|
||||||
|
|
||||||
|
rooms.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntities(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (Object.keys(req.query).length > 0) return next();
|
||||||
|
|
||||||
|
let entities: Entity[] = [];
|
||||||
|
|
||||||
|
entities.push(...(await getNGSIUsers()));
|
||||||
|
entities.push(...(await getNGSIRooms()));
|
||||||
|
|
||||||
|
return res.status(200).send(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntitiesOfType(req: Request, res: Response) {
|
||||||
|
const { type } = req.query;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return res.status(200).send(await getNGSIUsers());
|
||||||
|
case "room":
|
||||||
|
return res.status(200).send(await getNGSIRooms());
|
||||||
|
default:
|
||||||
|
return res.sendStatus(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntity(req: Request<Params>, res: Response) {
|
||||||
|
const { urn } = req.params;
|
||||||
|
|
||||||
|
if (!urn || !urn.startsWith("urn:ngsi-ld:")) return res.sendStatus(400);
|
||||||
|
|
||||||
|
const entity = urn.slice(12);
|
||||||
|
|
||||||
|
if (!entity) return res.sendStatus(400);
|
||||||
|
|
||||||
|
if (entity.toLowerCase().startsWith("room:")) {
|
||||||
|
const roomID = entity.slice(5);
|
||||||
|
|
||||||
|
if (!roomID) return res.sendStatus(400);
|
||||||
|
|
||||||
|
const data = await getRedisRoomData(roomID);
|
||||||
|
|
||||||
|
if (!data.length) return res.sendStatus(404);
|
||||||
|
|
||||||
|
return res.status(200).send(await convertToNGSIRoom(data[0]!));
|
||||||
|
} else if (entity.toLowerCase().startsWith("user:")) {
|
||||||
|
const userID = entity.slice(5);
|
||||||
|
|
||||||
|
if (!userID) return res.sendStatus(400);
|
||||||
|
|
||||||
|
const data = await getActiveUserData(userID);
|
||||||
|
|
||||||
|
if (!data.length) return res.sendStatus(404);
|
||||||
|
|
||||||
|
return res.status(200).send(convertToNGSIUser(data[0]!));
|
||||||
|
} else return res.sendStatus(400);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
import { getRedis } from "../../redis/redis.js";
|
||||||
|
|
||||||
|
export interface RedisUserData {
|
||||||
|
userID: string;
|
||||||
|
name: string;
|
||||||
|
rssi: number;
|
||||||
|
room: string;
|
||||||
|
verified: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisRoomData {
|
||||||
|
roomID: string;
|
||||||
|
occupancy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveUserData(userID?: string): Promise<RedisUserData[]> {
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
if (!userID) {
|
||||||
|
let cursor = "0";
|
||||||
|
let result: RedisUserData[] = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
const res = await redis.scan(cursor, {
|
||||||
|
MATCH: "user:*",
|
||||||
|
COUNT: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor = res.cursor;
|
||||||
|
|
||||||
|
const values = await Promise.all(res.keys.map((key) => redis.get(key)));
|
||||||
|
|
||||||
|
result.push(...values.filter((v): v is string => v !== null).map((v) => JSON.parse(v)));
|
||||||
|
} while (cursor !== "0");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _userID = await redis.get(`_user:${userID}`);
|
||||||
|
|
||||||
|
if (!_userID) return [];
|
||||||
|
|
||||||
|
const res = await redis.get(`user:${_userID}`);
|
||||||
|
|
||||||
|
if (!res) return [];
|
||||||
|
|
||||||
|
const resObj: RedisUserData = JSON.parse(res);
|
||||||
|
|
||||||
|
return [resObj];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]> {
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
if (!roomID) {
|
||||||
|
let cursor = "0";
|
||||||
|
let result: RedisRoomData[] = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
const res = await redis.scan(cursor, {
|
||||||
|
MATCH: "room:*",
|
||||||
|
COUNT: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor = res.cursor;
|
||||||
|
|
||||||
|
const values = await Promise.all(
|
||||||
|
res.keys.map(async (key) => ({
|
||||||
|
roomID: key,
|
||||||
|
occupancy: await redis.get(key),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
...values
|
||||||
|
.filter((v) => v.occupancy !== null)
|
||||||
|
.map((v) => ({
|
||||||
|
roomID: v.roomID.slice(5),
|
||||||
|
occupancy: Number(v.occupancy),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} while (cursor !== "0");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await redis.get(`room:${roomID}`);
|
||||||
|
|
||||||
|
if (!res) return [];
|
||||||
|
|
||||||
|
return [{ roomID: roomID, occupancy: Number(res) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoomIDs(): Promise<{ id: string }[]> {
|
||||||
|
return await prisma.room.findMany({
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
export async function existsByUUID(uuid: string): Promise<boolean> {
|
export async function existsByUUID(uuid: string): Promise<boolean> {
|
||||||
const user = await prisma.user.findUnique({
|
const tag = await prisma.tag.findUnique({
|
||||||
where: { id: uuid }
|
where: { id: uuid },
|
||||||
});
|
});
|
||||||
|
|
||||||
return !!user;
|
return !!tag;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import AdminJS from "adminjs";
|
||||||
|
import AdminJSExpress from "@adminjs/express";
|
||||||
|
import * as AdminJSPrisma from "@adminjs/prisma";
|
||||||
|
import session from "express-session";
|
||||||
|
import { componentLoader, Components } from "./components.js";
|
||||||
|
|
||||||
|
import userResource from "./resources/adminjs.user.resource.js";
|
||||||
|
import tagResource from "./resources/adminjs.tag.resource.js";
|
||||||
|
import roomResource from "./resources/adminjs.room.resource.js";
|
||||||
|
|
||||||
|
AdminJS.registerAdapter({
|
||||||
|
Database: AdminJSPrisma.Database,
|
||||||
|
Resource: AdminJSPrisma.Resource,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAdmin() {
|
||||||
|
const admin = new AdminJS({
|
||||||
|
rootPath: "/admin",
|
||||||
|
resources: [userResource, tagResource, roomResource],
|
||||||
|
branding: { companyName: "OfficeSense", logo: false },
|
||||||
|
dashboard: { component: Components.Dashboard },
|
||||||
|
componentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
|
await admin.watch();
|
||||||
|
|
||||||
|
const router = AdminJSExpress.buildAuthenticatedRouter(
|
||||||
|
admin,
|
||||||
|
{
|
||||||
|
authenticate: async (email, password) => {
|
||||||
|
if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASSWORD) {
|
||||||
|
return { email };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
cookieName: "adminjs",
|
||||||
|
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { admin, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionMiddleware = session({
|
||||||
|
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { createAdmin, sessionMiddleware };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentLoader } from "adminjs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const componentLoader = new ComponentLoader();
|
||||||
|
|
||||||
|
export const Components = {
|
||||||
|
Dashboard: componentLoader.add(
|
||||||
|
"Dashboard",
|
||||||
|
path.join(__dirname, "./components/Dashboard.tsx"),
|
||||||
|
"components"
|
||||||
|
),
|
||||||
|
UploadFace: componentLoader.add(
|
||||||
|
"UploadFace",
|
||||||
|
path.join(__dirname, "./components/UploadFace.tsx"),
|
||||||
|
"components"
|
||||||
|
),
|
||||||
|
FaceEmbeddingField: componentLoader.add(
|
||||||
|
"FaceEmbeddingField",
|
||||||
|
path.join(__dirname, "./components/FaceEmbeddingField.tsx"),
|
||||||
|
"components"
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Box, H2, Text, Loader } from "@adminjs/design-system";
|
||||||
|
import { ApiClient } from "adminjs";
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
realUserId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
pseudoId: string;
|
||||||
|
pseudoName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updated, setUpdated] = useState<string>("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/admin/map", { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setSessions(data);
|
||||||
|
setUpdated(new Date().toLocaleTimeString());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initials = (first: string, last: string) =>
|
||||||
|
((first?.[0] ?? "") + (last?.[0] ?? "")).toUpperCase() || "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box padding="xl">
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
marginBottom="xl"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<H2>OfficeSense Admin Dashboard</H2>
|
||||||
|
<Text color="grey60">Live Redis sessions mapped to registered users</Text>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Text color="grey60" fontSize="sm" marginRight="md">
|
||||||
|
Updated {updated}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
onClick={load}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Loader />
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Text color="grey60">No active sessions.</Text>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "12px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "13px" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#fafafa" }}>
|
||||||
|
{["User", "Real UUID", "Pseudo UUID", "Pseudo name"].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#9ca3af",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
borderBottom: "1px solid #f3f4f6",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.realUserId}
|
||||||
|
style={{ borderBottom: "1px solid #f9fafb" }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: "12px 16px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#eff6ff",
|
||||||
|
color: "#2563eb",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials(s.firstName, s.lastName)}
|
||||||
|
</div>
|
||||||
|
{s.firstName} {s.lastName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.realUserId}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.pseudoId}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "12px 16px" }}>{s.pseudoName ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { BasePropertyProps } from "adminjs";
|
||||||
|
import { Label } from "@adminjs/design-system";
|
||||||
|
|
||||||
|
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
|
||||||
|
const has = !!record?.params?.faceEmbedding;
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<Label>{property.label}</Label>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
background: has ? "#dcfce7" : "#fee2e2",
|
||||||
|
color: has ? "#16a34a" : "#dc2626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{has ? "SET" : "UNSET"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaceEmbeddingField;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { Box, H3, Text, Button, MessageBox } from "@adminjs/design-system";
|
||||||
|
import { ActionProps, useRecord } from "adminjs";
|
||||||
|
|
||||||
|
const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [base64, setBase64] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<{ message: string; type: "success" | "error" } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
setPreview(result);
|
||||||
|
setBase64(result.split(",")[1]); // strip data:image/jpeg;base64,
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!base64) return;
|
||||||
|
setLoading(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/api/resources/User/records/${record?.params.id}/uploadFace`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ base64 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setNotice(data.notice);
|
||||||
|
} catch (e: any) {
|
||||||
|
setNotice({ message: e.message, type: "error" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box padding="xl">
|
||||||
|
<H3 marginBottom="md">Upload face image</H3>
|
||||||
|
<Text color="grey60" marginBottom="lg">
|
||||||
|
Upload a JPEG photo of the user to generate and store their face embedding.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<MessageBox
|
||||||
|
message={notice.message}
|
||||||
|
variant={notice.type === "success" ? "success" : "danger"}
|
||||||
|
marginBottom="lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginBottom="lg">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
onChange={handleFile}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => inputRef.current?.click()} variant="outlined">
|
||||||
|
Choose JPEG
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<Box marginBottom="lg">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} disabled={!base64 || loading} variant="contained">
|
||||||
|
{loading ? "Processing…" : "Save embedding"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadFace;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { prisma } from "../../../lib/prisma.js";
|
||||||
|
import { getModelByName } from "@adminjs/prisma";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
resource: {
|
||||||
|
model: getModelByName("Room"),
|
||||||
|
client: prisma,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
navigation: { icon: "Map" },
|
||||||
|
listProperties: ["id", "name"],
|
||||||
|
showProperties: ["id", "name"],
|
||||||
|
editProperties: ["name"],
|
||||||
|
filterProperties: ["name"],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from "../../../lib/prisma.js";
|
||||||
|
import { getModelByName } from "@adminjs/prisma";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
resource: {
|
||||||
|
model: getModelByName("Tag"),
|
||||||
|
client: prisma,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
navigation: { icon: "Tag" },
|
||||||
|
listProperties: ["id", "user"],
|
||||||
|
showProperties: ["id", "user"],
|
||||||
|
editProperties: ["user"],
|
||||||
|
filterProperties: ["user"],
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
reference: "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { prisma } from "../../../lib/prisma.js";
|
||||||
|
import { getModelByName } from "@adminjs/prisma";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { Components } from "../components.js";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const execPath = "../../../../bin";
|
||||||
|
const binaryPath = path.join(__dirname, execPath, "extractEmbeddings");
|
||||||
|
|
||||||
|
async function extractEmbedding(base64: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(binaryPath, [], {
|
||||||
|
cwd: path.join(__dirname, execPath),
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (d) => (stdout += d));
|
||||||
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
|
proc.stdin.on("error", (err) => {});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
reject(new Error(`Failed to start binary: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
console.log("extractEmbeddings exited with code", code);
|
||||||
|
console.log("stdout:", stdout);
|
||||||
|
console.log("stderr:", stderr);
|
||||||
|
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`));
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
if (!result.success) return reject(new Error(result.error));
|
||||||
|
resolve(JSON.stringify(result.descriptor));
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Failed to parse output: ${stdout}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stdin.write(base64);
|
||||||
|
proc.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
resource: {
|
||||||
|
model: getModelByName("User"),
|
||||||
|
client: prisma,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
titleProperty: "id",
|
||||||
|
navigation: { icon: "User" },
|
||||||
|
listProperties: ["id", "firstName", "lastName"],
|
||||||
|
showProperties: ["id", "firstName", "lastName", "faceEmbedding"],
|
||||||
|
editProperties: ["firstName", "lastName"],
|
||||||
|
filterProperties: ["firstName", "lastName"],
|
||||||
|
properties: {
|
||||||
|
faceEmbedding: {
|
||||||
|
isVisible: { list: false, show: true, edit: false, filter: false },
|
||||||
|
type: "string",
|
||||||
|
components: {
|
||||||
|
list: Components.FaceEmbeddingField,
|
||||||
|
show: Components.FaceEmbeddingField,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
uploadFace: {
|
||||||
|
actionType: "record",
|
||||||
|
icon: "Camera",
|
||||||
|
label: "Upload face",
|
||||||
|
showInDrawer: true,
|
||||||
|
handler: async (request: any, response: any, context: any) => {
|
||||||
|
const { record, currentAdmin } = context;
|
||||||
|
if (request.method === "post") {
|
||||||
|
const { base64 } = request.payload;
|
||||||
|
if (!base64) {
|
||||||
|
return {
|
||||||
|
record: record.toJSON(currentAdmin),
|
||||||
|
notice: { message: "No image provided", type: "error" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const embedding = await extractEmbedding(base64);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: record.params.id },
|
||||||
|
data: { faceEmbedding: embedding },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
record: record.toJSON(currentAdmin),
|
||||||
|
notice: { message: "Face embedding saved!", type: "success" },
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
record: record.toJSON(currentAdmin),
|
||||||
|
notice: { message: `Failed: ${e.message}`, type: "error" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { record: record.toJSON(currentAdmin) };
|
||||||
|
},
|
||||||
|
component: Components.UploadFace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { generateMap } from "./mapper.repository.js";
|
||||||
|
|
||||||
|
export function checkSession(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!(req.session as any)?.adminUser) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRedisMapping(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
res.status(200).send(await generateMap());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: "Failed to fetch sessions" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { getRedis } from "../../redis/redis.js";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
|
export async function generateMap() {
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
const keys = await redis.keys("user:*");
|
||||||
|
|
||||||
|
const sessions = await Promise.all(
|
||||||
|
keys.map(async (key) => {
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
const session = JSON.parse(raw!);
|
||||||
|
const realUserId = key.replace("user:", "");
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: realUserId },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
realUserId,
|
||||||
|
firstName: user?.firstName ?? "Unknown",
|
||||||
|
lastName: user?.lastName ?? "Unknown",
|
||||||
|
pseudoId: session.userID,
|
||||||
|
pseudoName: session.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getRedisMapping, checkSession } from "./mapper.controller.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/map", checkSession, getRedisMapping);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
interface Config {
|
||||||
|
api: {
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
mqtt: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
topic: string;
|
||||||
|
};
|
||||||
|
redis: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
core: {
|
||||||
|
hysteresis: number;
|
||||||
|
candidateHysteresis: number;
|
||||||
|
debounceMS: number;
|
||||||
|
minSamples: number;
|
||||||
|
transitionTTL: number;
|
||||||
|
transitionCleanupInterval: number;
|
||||||
|
lossThreshold: number;
|
||||||
|
userTTL: number;
|
||||||
|
verifyTimeout: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
api: {
|
||||||
|
address: "0.0.0.0",
|
||||||
|
port: 80,
|
||||||
|
},
|
||||||
|
mqtt: {
|
||||||
|
host: "192.168.1.2",
|
||||||
|
port: 1883,
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
topic: "scanners/+",
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: "192.168.1.2",
|
||||||
|
port: 6379,
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
hysteresis: 6,
|
||||||
|
candidateHysteresis: 3,
|
||||||
|
debounceMS: 3000,
|
||||||
|
minSamples: 4,
|
||||||
|
transitionTTL: 5 * 60 * 1000,
|
||||||
|
transitionCleanupInterval: 60 * 1000,
|
||||||
|
lossThreshold: 5000,
|
||||||
|
userTTL: 3 * 60 * 1000,
|
||||||
|
verifyTimeout: 30 * 1000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { getRedis } from "../redis/redis.js";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { RoomTransition, transitions } from "./transition.js";
|
||||||
|
import config from "../config/config.js";
|
||||||
|
import { type RedisUserData } from "../api/livedata/livedata.repository.js";
|
||||||
|
|
||||||
|
const adjectives = ["Crazy", "Silent", "Dark", "Fast", "Lucky", "Wild", "Epic"];
|
||||||
|
|
||||||
|
const nouns = ["Tiger", "Wolf", "Falcon", "Shadow", "Ninja", "Dragon", "Phoenix"];
|
||||||
|
|
||||||
|
const running = new Set<string>();
|
||||||
|
const userRooms = new Map<string, string>();
|
||||||
|
const userPseudo = new Map<string, string>();
|
||||||
|
|
||||||
|
export async function updateRoomOccupancyListener(message: string, channel: string) {
|
||||||
|
console.log(`[Redis] Key ${message} expired.`);
|
||||||
|
|
||||||
|
const id = message.startsWith("user:") ? message.slice(5) : message;
|
||||||
|
const lastRoomID = userRooms.get(id);
|
||||||
|
const userPseudoID = userPseudo.get(id);
|
||||||
|
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
if (!lastRoomID) {
|
||||||
|
console.log("[!] Key not found in local map");
|
||||||
|
} else {
|
||||||
|
await redis.decr(`room:${lastRoomID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.del(`_user:${userPseudoID}`);
|
||||||
|
|
||||||
|
userRooms.delete(id);
|
||||||
|
userPseudo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNickname() {
|
||||||
|
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||||
|
|
||||||
|
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
|
||||||
|
const number = Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
return `${adjective}${noun}${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUser(): { pseudoID: string; psuedoName: string } {
|
||||||
|
return {
|
||||||
|
pseudoID: crypto.randomUUID(),
|
||||||
|
psuedoName: generateNickname(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeData(
|
||||||
|
roomID: string,
|
||||||
|
metrics: {
|
||||||
|
tagID: string;
|
||||||
|
rssi: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (running.has(metrics.tagID)) {
|
||||||
|
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
running.add(metrics.tagID);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`\n=======================================================\n` +
|
||||||
|
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
||||||
|
`=======================================================\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const userID = (
|
||||||
|
await prisma.tag.findUnique({
|
||||||
|
where: { id: metrics.tagID },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
)?.userId;
|
||||||
|
|
||||||
|
console.log(`[SQLite] Resolved USER_ID: ${userID}`);
|
||||||
|
|
||||||
|
if (!userID) return;
|
||||||
|
|
||||||
|
const redis = getRedis();
|
||||||
|
const res = await redis.get(`user:${userID}`);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
const user = newUser();
|
||||||
|
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
`user:${userID}`,
|
||||||
|
JSON.stringify({
|
||||||
|
userID: user.pseudoID,
|
||||||
|
name: user.psuedoName,
|
||||||
|
rssi: metrics.rssi,
|
||||||
|
room: roomID,
|
||||||
|
verified: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
{ PX: config.core.userTTL }
|
||||||
|
);
|
||||||
|
|
||||||
|
await redis.set(`_user:${user.pseudoID}`, userID);
|
||||||
|
|
||||||
|
await redis.incr(`room:${roomID}`);
|
||||||
|
|
||||||
|
userRooms.set(userID, roomID);
|
||||||
|
userPseudo.set(userID, user.pseudoID);
|
||||||
|
|
||||||
|
console.log("[Redis] Stored new user in redis.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resObj: RedisUserData = JSON.parse(res);
|
||||||
|
console.log(`[Redis] User exists in redis in ROOM_ID: ${resObj["room"]}`);
|
||||||
|
|
||||||
|
if (roomID == resObj["room"]) {
|
||||||
|
console.log("[Redis] Same room, updating redis...");
|
||||||
|
resObj["rssi"] = metrics.rssi;
|
||||||
|
resObj["timestamp"] = Date.now();
|
||||||
|
|
||||||
|
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transition = transitions.get(userID);
|
||||||
|
|
||||||
|
if (!transition) {
|
||||||
|
console.log("[!] Starting new transition");
|
||||||
|
transition = new RoomTransition();
|
||||||
|
transitions.set(userID, transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])
|
||||||
|
) {
|
||||||
|
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
||||||
|
|
||||||
|
await redis.decr(`room:${resObj["room"]}`);
|
||||||
|
await redis.incr(`room:${roomID}`);
|
||||||
|
|
||||||
|
userRooms.set(userID, roomID);
|
||||||
|
|
||||||
|
resObj["rssi"] = metrics.rssi;
|
||||||
|
resObj["room"] = roomID;
|
||||||
|
resObj["timestamp"] = Date.now();
|
||||||
|
resObj["verified"] = false;
|
||||||
|
|
||||||
|
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
||||||
|
} else console.log("[!] Transition declined");
|
||||||
|
} finally {
|
||||||
|
running.delete(metrics.tagID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
|
||||||
|
import { initRedis, initSubRedis } from "../redis/redis.js";
|
||||||
|
import { initCamera } from "./camera.js";
|
||||||
|
import * as mqtt from "./mqtt.js";
|
||||||
|
import { cleanupWorker } from "./transition.js";
|
||||||
|
|
||||||
|
export async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const redis = await initRedis();
|
||||||
|
|
||||||
|
await redis.flushDb();
|
||||||
|
console.log(`[!] Flushed Redis.`);
|
||||||
|
|
||||||
|
const rooms = await getRoomIDs();
|
||||||
|
for (const { id } of rooms) await redis.set(`room:${id}`, 0);
|
||||||
|
|
||||||
|
await initSubRedis();
|
||||||
|
await mqtt.start();
|
||||||
|
|
||||||
|
cleanupWorker();
|
||||||
|
|
||||||
|
await initCamera();
|
||||||
|
|
||||||
|
console.log("[!] Core started.");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("[!] Bootstrap failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { getRedis } from "../redis/redis.js";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import config from "../config/config.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const execPath = "../../bin";
|
||||||
|
const binaryPath = path.join(__dirname, execPath, "cameraSession");
|
||||||
|
|
||||||
|
type InputPacket = {
|
||||||
|
uuid: string;
|
||||||
|
descriptor: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultPacket = {
|
||||||
|
uuid: string;
|
||||||
|
verified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUnverifiedUserKeys(): Promise<string[]> {
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
const keys = await redis.keys("user:*");
|
||||||
|
|
||||||
|
const unverified: string[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(async (key) => {
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
if (!session.verified) unverified.push(key.replace("user:", ""));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return unverified;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnverifiedUsersWithEmbeddings(): Promise<
|
||||||
|
{ uuid: string; descriptor: number[] }[]
|
||||||
|
> {
|
||||||
|
const userIds = await getUnverifiedUserKeys();
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: userIds },
|
||||||
|
faceEmbedding: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true, faceEmbedding: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map((user) => ({
|
||||||
|
uuid: user.id,
|
||||||
|
descriptor: JSON.parse(user.faceEmbedding!),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cameraSession(packets: InputPacket[]): Promise<ResultPacket[] | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(binaryPath, [], {
|
||||||
|
cwd: path.join(__dirname, execPath),
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (d) => (stdout += d));
|
||||||
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
proc.stdin.on("error", () => {});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
reject(new Error(`Failed to start binary: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
console.log("cameraSession exited with code", code);
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
if (trimmed === "no face found.") return resolve(null);
|
||||||
|
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`));
|
||||||
|
try {
|
||||||
|
const results: ResultPacket[] = JSON.parse(trimmed);
|
||||||
|
resolve(results);
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Failed to parse output: ${trimmed}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const packet of packets) {
|
||||||
|
proc.stdin.write(JSON.stringify(packet) + "\n");
|
||||||
|
}
|
||||||
|
proc.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUnverified = async (uuid: string) => {
|
||||||
|
const redis = getRedis();
|
||||||
|
const key = `user:${uuid}`;
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
session.verified = false;
|
||||||
|
await redis.set(key, JSON.stringify(session));
|
||||||
|
console.log(`[Camera] Unverified user ${uuid} due to timeout`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const redis = getRedis();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packets = await getUnverifiedUsersWithEmbeddings();
|
||||||
|
if (packets.length > 0) {
|
||||||
|
const results = await cameraSession(packets);
|
||||||
|
if (results) {
|
||||||
|
await Promise.all(
|
||||||
|
results.map(async (r) => {
|
||||||
|
if (!r.verified) return;
|
||||||
|
const key = `user:${r.uuid}`;
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
session.verified = true;
|
||||||
|
await redis.set(key, JSON.stringify(session));
|
||||||
|
|
||||||
|
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout);
|
||||||
|
|
||||||
|
console.log(`[Camera] Verified user ${r.uuid}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("camera run error:", e);
|
||||||
|
}
|
||||||
|
setTimeout(run, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { run as initCamera };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import mqtt, { MqttClient } from "mqtt";
|
||||||
|
import config from "../config/config.js";
|
||||||
|
import { analyzeData } from "./analyze.js";
|
||||||
|
|
||||||
|
function initMqtt(): Promise<MqttClient> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = mqtt.connect(`mqtt://${config.mqtt.host}:${config.mqtt.port}`, {
|
||||||
|
username: config.mqtt.username,
|
||||||
|
password: config.mqtt.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once("connect", () => {
|
||||||
|
console.log("[MQTT] MQTT connected.");
|
||||||
|
|
||||||
|
client.subscribe(config.mqtt.topic, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log("[MQTT] MQTT subscribed.");
|
||||||
|
resolve(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start() {
|
||||||
|
try {
|
||||||
|
const mqttClient = await initMqtt();
|
||||||
|
|
||||||
|
mqttClient.on("message", (topic, message) => {
|
||||||
|
analyzeData(topic.split("/").pop()!, JSON.parse(message.toString()));
|
||||||
|
});
|
||||||
|
|
||||||
|
mqttClient.on("error", (err) => {
|
||||||
|
console.log(err.message);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log("[MQTT] MQTT connection failed:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import config from "../config/config.js";
|
||||||
|
|
||||||
|
export const transitions = new Map<string, RoomTransition>();
|
||||||
|
|
||||||
|
export function cleanupWorker() {
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [userID, transition] of transitions) {
|
||||||
|
const inactiveFor = now - transition.lastSeen;
|
||||||
|
|
||||||
|
if (inactiveFor > 5 * config.core.transitionTTL) transitions.delete(userID);
|
||||||
|
}
|
||||||
|
}, config.core.transitionCleanupInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate = {
|
||||||
|
roomID: string | null;
|
||||||
|
since: number | null;
|
||||||
|
samples: number;
|
||||||
|
rssi: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RoomTransition {
|
||||||
|
candidate: Candidate;
|
||||||
|
lastSeen: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.lastSeen = Date.now();
|
||||||
|
this.candidate = { roomID: null, since: null, samples: 0, rssi: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldTransitionTo(
|
||||||
|
roomID: string,
|
||||||
|
candidateRSSI: number,
|
||||||
|
currRSSI: number,
|
||||||
|
lastCurrentSeen: number
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
this.lastSeen = now;
|
||||||
|
|
||||||
|
const stronger = candidateRSSI > currRSSI + config.core.hysteresis;
|
||||||
|
|
||||||
|
const signalLost = now - lastCurrentSeen > config.core.lossThreshold;
|
||||||
|
|
||||||
|
console.log(`[TRANSITION] stronger = ${stronger}`);
|
||||||
|
console.log(`[TRANSITION] signalLost = ${signalLost}`);
|
||||||
|
|
||||||
|
if (!stronger && !signalLost) {
|
||||||
|
this.reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noCandidate = this.candidate.roomID == null;
|
||||||
|
const betterCandidate =
|
||||||
|
this.candidate.roomID != roomID &&
|
||||||
|
this.candidate.rssi !== null &&
|
||||||
|
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
|
||||||
|
|
||||||
|
console.log(`[TRANSITION] noCandidate = ${noCandidate}`);
|
||||||
|
console.log(`[TRANSITION] betterCandidate = ${betterCandidate}`);
|
||||||
|
|
||||||
|
if (noCandidate || betterCandidate) {
|
||||||
|
this.candidate.roomID = roomID;
|
||||||
|
this.candidate.since = now;
|
||||||
|
this.candidate.samples = 1;
|
||||||
|
this.candidate.rssi = candidateRSSI;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.candidate.samples++;
|
||||||
|
this.candidate.rssi = candidateRSSI;
|
||||||
|
|
||||||
|
console.log(`[TRANSITION] samples = ${this.candidate.samples}`);
|
||||||
|
console.log(`[TRANSITION] candidateRSSI = ${this.candidate.rssi}`);
|
||||||
|
|
||||||
|
const enoughTime = now - (this.candidate.since ?? now) >= config.core.debounceMS;
|
||||||
|
const enoughConfirmations = this.candidate.samples >= config.core.minSamples;
|
||||||
|
|
||||||
|
console.log(`[TRANSITION] enoughTime = ${enoughTime}`);
|
||||||
|
console.log(`[TRANSITION] enoughConfirmations = ${enoughConfirmations}`);
|
||||||
|
console.log(`[TRANSITION] signalLost = ${signalLost}`);
|
||||||
|
|
||||||
|
if ((enoughTime && enoughConfirmations) || signalLost) {
|
||||||
|
this.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.candidate.roomID = null;
|
||||||
|
this.candidate.since = null;
|
||||||
|
this.candidate.samples = 0;
|
||||||
|
this.candidate.rssi = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import lookupRoutes from "./api/lookup/lookup.routes.js";
|
import lookupRouter from "./api/lookup/lookup.routes.js";
|
||||||
|
import livedataRouter from "./api/livedata/livadata.routes.js";
|
||||||
|
import mapperRouter from "./api/mapper/mapper.routes.js";
|
||||||
|
import { createAdmin, sessionMiddleware } from "./api/management/adminjs.routes.js";
|
||||||
|
import config from "./config/config.js";
|
||||||
|
import { bootstrap } from "./core/bootstrap.js";
|
||||||
|
|
||||||
const app = express();
|
(async () => {
|
||||||
const PORT = 80;
|
const app = express();
|
||||||
const ADDRESS = "0.0.0.0";
|
|
||||||
|
|
||||||
app.use("/", lookupRoutes);
|
const { admin, router: adminRouter } = await createAdmin();
|
||||||
|
|
||||||
app.listen(PORT, ADDRESS, () => {
|
app.use(sessionMiddleware);
|
||||||
console.log(`Server listening on ${ADDRESS}:${PORT}`);
|
|
||||||
});
|
app.use("/lookup", lookupRouter);
|
||||||
|
app.use("/livedata", livedataRouter);
|
||||||
|
app.use("/admin", mapperRouter);
|
||||||
|
app.use("/admin", adminRouter);
|
||||||
|
|
||||||
|
app.listen(config.api.port, config.api.address, () => {
|
||||||
|
console.log(`Server listening on ${config.api.address}:${config.api.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import "dotenv/config";
|
import { 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 };
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createClient, type RedisClientType } from "redis";
|
||||||
|
import config from "../config/config.js";
|
||||||
|
import { updateRoomOccupancyListener } from "../core/analyze.js";
|
||||||
|
|
||||||
|
let redisClient: RedisClientType;
|
||||||
|
let subRedisClient: RedisClientType;
|
||||||
|
|
||||||
|
export async function initSubRedis(): Promise<void> {
|
||||||
|
subRedisClient = createClient({
|
||||||
|
url: `redis://${config.redis.host}:${config.redis.port}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
subRedisClient.on("error", (err) => {
|
||||||
|
console.log("[Redis] SubRedis error:", err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
await subRedisClient.connect();
|
||||||
|
|
||||||
|
console.log("[Redis] SubRedis connected.");
|
||||||
|
|
||||||
|
subRedisClient.pSubscribe("__keyevent@0__:expired", updateRoomOccupancyListener);
|
||||||
|
|
||||||
|
console.log("[Redis] SubRedis listener set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initRedis(): Promise<RedisClientType> {
|
||||||
|
redisClient = createClient({
|
||||||
|
url: `redis://${config.redis.host}:${config.redis.port}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on("error", (err) => {
|
||||||
|
console.log("[Redis] Redis error:", err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
await redisClient.connect();
|
||||||
|
|
||||||
|
console.log("[Redis] Redis connected.");
|
||||||
|
|
||||||
|
return redisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedis(): RedisClientType {
|
||||||
|
if (!redisClient) {
|
||||||
|
throw new Error("[Redis] Redis not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return redisClient;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ board_build.partitions = default.csv
|
|||||||
build_flags =
|
build_flags =
|
||||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
-DBOARD_HAS_PSRAM
|
-DBOARD_HAS_PSRAM
|
||||||
|
-DCORE_DEBUG_LEVEL=0
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
h2zero/NimBLE-Arduino
|
h2zero/NimBLE-Arduino
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
#include "freertos/queue.h"
|
#include "freertos/queue.h"
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <PubSubClient.h>
|
#include <PubSubClient.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
#include "esp_system.h"
|
||||||
|
|
||||||
|
Preferences prefs;
|
||||||
|
Config config;
|
||||||
CB cb;
|
CB cb;
|
||||||
char MQTT_PUB_TOPIC[sizeof(MQTT_TOPIC) + sizeof(DEV_NAME) - 1];
|
String MQTT_PUB_TOPIC;
|
||||||
|
String serial_buffer = "";
|
||||||
std::unordered_map<std::string, Tag> tags;
|
std::unordered_map<std::string, Tag> tags;
|
||||||
std::unordered_map<std::string, UUIDCacheEntry> uuid_cache;
|
std::unordered_map<std::string, UUIDCacheEntry> uuid_cache;
|
||||||
SemaphoreHandle_t uuid_mtx;
|
SemaphoreHandle_t uuid_mtx;
|
||||||
@@ -27,7 +32,7 @@ void cleanupCache()
|
|||||||
{
|
{
|
||||||
uint32_t age = now - it->second.last_check;
|
uint32_t age = now - it->second.last_check;
|
||||||
|
|
||||||
if (age > UUID_CACHE_CLEAN_TIMEOUT)
|
if (age > config.interval.uuid_cache_clean_timeout)
|
||||||
{
|
{
|
||||||
Serial.printf("Erasing %s from cache after being inactive for %dms.\n",
|
Serial.printf("Erasing %s from cache after being inactive for %dms.\n",
|
||||||
it->first.c_str(),
|
it->first.c_str(),
|
||||||
@@ -47,7 +52,7 @@ void cleanupTags()
|
|||||||
|
|
||||||
for (auto it = tags.begin(); it != tags.end();)
|
for (auto it = tags.begin(); it != tags.end();)
|
||||||
{
|
{
|
||||||
if ((now - it->second.getLastSeen()) >= TAG_TIMEOUT)
|
if ((now - it->second.getLastSeen()) >= config.interval.tag_timeout)
|
||||||
{
|
{
|
||||||
Serial.printf("Erasing %s from memory after being inactive for %dms.\n",
|
Serial.printf("Erasing %s from memory after being inactive for %dms.\n",
|
||||||
it->first.c_str(),
|
it->first.c_str(),
|
||||||
@@ -69,7 +74,7 @@ void httpTask(void *)
|
|||||||
{
|
{
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
|
|
||||||
String url = UUID_CHECK_ENDPOINT + String(ev.uuid);
|
String url = config.api.url + config.api.uuid_check_endpoint + String("/") + String(ev.uuid);
|
||||||
|
|
||||||
http.setTimeout(3000);
|
http.setTimeout(3000);
|
||||||
http.setReuse(false);
|
http.setReuse(false);
|
||||||
@@ -82,6 +87,19 @@ void httpTask(void *)
|
|||||||
|
|
||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
|
|
||||||
|
if (code < 0)
|
||||||
|
{
|
||||||
|
xSemaphoreTake(uuid_mtx, portMAX_DELAY);
|
||||||
|
uuid_cache[ev.uuid] = {
|
||||||
|
UUIDState::RETRY,
|
||||||
|
millis()};
|
||||||
|
|
||||||
|
xSemaphoreGive(uuid_mtx);
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
bool valid = (code == 200);
|
bool valid = (code == 200);
|
||||||
|
|
||||||
http.end();
|
http.end();
|
||||||
@@ -110,8 +128,8 @@ void mqttTask(void *)
|
|||||||
{
|
{
|
||||||
while (!mqtt.connected())
|
while (!mqtt.connected())
|
||||||
{
|
{
|
||||||
Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", MQTT_HOST, MQTT_PORT);
|
// Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
|
||||||
if (mqtt.connect(DEV_NAME, MQTT_USER, MQTT_PASS))
|
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.");
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
}
|
}
|
||||||
@@ -121,11 +139,11 @@ void mqttTask(void *)
|
|||||||
char payload[128];
|
char payload[128];
|
||||||
|
|
||||||
snprintf(payload, sizeof(payload),
|
snprintf(payload, sizeof(payload),
|
||||||
"{\"uuid\":\"%s\",\"rssi\":%.2f}",
|
"{\"tagID\":\"%s\",\"rssi\":%.2f}",
|
||||||
ev.uuid,
|
ev.uuid,
|
||||||
ev.ema);
|
ev.ema);
|
||||||
|
|
||||||
bool ok = mqtt.publish(MQTT_PUB_TOPIC, payload);
|
bool ok = mqtt.publish(MQTT_PUB_TOPIC.c_str(), payload);
|
||||||
|
|
||||||
if (!ok)
|
if (!ok)
|
||||||
Serial.println("Failed to publish on MQTT.");
|
Serial.println("Failed to publish on MQTT.");
|
||||||
@@ -143,7 +161,7 @@ void processEvent(const BleEvent &event)
|
|||||||
|
|
||||||
uint32_t now = millis();
|
uint32_t now = millis();
|
||||||
|
|
||||||
if (it == uuid_cache.end())
|
if (it == uuid_cache.end() || (it->second.state == UUIDState::RETRY))
|
||||||
{
|
{
|
||||||
uuid_cache[event.uuid] = {UUIDState::PENDING, now};
|
uuid_cache[event.uuid] = {UUIDState::PENDING, now};
|
||||||
|
|
||||||
@@ -158,7 +176,7 @@ void processEvent(const BleEvent &event)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (it->second.state == UUIDState::INVALID &&
|
if (it->second.state == UUIDState::INVALID &&
|
||||||
now - it->second.last_check > UUID_CACHE_RETRY_TIMEOUT)
|
now - it->second.last_check > config.interval.uuid_cache_retry_timeout)
|
||||||
{
|
{
|
||||||
it->second.state = UUIDState::PENDING;
|
it->second.state = UUIDState::PENDING;
|
||||||
it->second.last_check = now;
|
it->second.last_check = now;
|
||||||
@@ -202,9 +220,9 @@ void CB::onResult(const NimBLEAdvertisedDevice *d)
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
NimBLEUUID uuid = d->getServiceUUID(0);
|
NimBLEUUID uuid = d->getServiceUUID(0);
|
||||||
const std::string &name = d->getName();
|
const String name(d->getName().c_str());
|
||||||
|
|
||||||
if ((uuid.bitSize() != 128) || (name != TAG_NAME))
|
if ((uuid.bitSize() != 128) || (name != config.tag_name))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
BleEvent event;
|
BleEvent event;
|
||||||
@@ -215,23 +233,179 @@ void CB::onResult(const NimBLEAdvertisedDevice *d)
|
|||||||
xQueueSend(ble_queue, &event, pdMS_TO_TICKS(5));
|
xQueueSend(ble_queue, &event, pdMS_TO_TICKS(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void saveConfig()
|
||||||
|
{
|
||||||
|
prefs.begin("config", false);
|
||||||
|
|
||||||
|
prefs.putUInt(CLEANUP_INTERVAL_CONFIG_KEY, config.interval.cleanup);
|
||||||
|
prefs.putUInt(TAG_TIMEOUT_CONFIG_KEY, config.interval.tag_timeout);
|
||||||
|
prefs.putUInt(UUID_CACHE_RETRY_TIMEOUT_CONFIG_KEY, config.interval.uuid_cache_retry_timeout);
|
||||||
|
prefs.putUInt(UUID_CACHE_CLEAN_TIMEOUT_CONFIG_KEY, config.interval.uuid_cache_clean_timeout);
|
||||||
|
|
||||||
|
prefs.putString(API_URL_CONFIG_KEY, config.api.url);
|
||||||
|
prefs.putString(UUID_CHECK_ENDPOINT_CONFIG_KEY, config.api.uuid_check_endpoint);
|
||||||
|
|
||||||
|
prefs.putString(MQTT_HOST_CONFIG_KEY, config.mqtt.host);
|
||||||
|
prefs.putUShort(MQTT_PORT_CONFIG_KEY, config.mqtt.port);
|
||||||
|
prefs.putString(MQTT_USER_CONFIG_KEY, config.mqtt.username);
|
||||||
|
prefs.putString(MQTT_PASS_CONFIG_KEY, config.mqtt.password);
|
||||||
|
prefs.putString(MQTT_TOPIC_CONFIG_KEY, config.mqtt.topic);
|
||||||
|
|
||||||
|
prefs.putString(WIFI_SSID_CONFIG_KEY, config.wifi.ssid);
|
||||||
|
prefs.putString(WIFI_PASS_CONFIG_KEY, config.wifi.password);
|
||||||
|
|
||||||
|
prefs.putString(DEV_NAME_CONFIG_KEY, config.dev_name);
|
||||||
|
prefs.putString(TAG_NAME_CONFIG_KEY, config.tag_name);
|
||||||
|
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadConfig()
|
||||||
|
{
|
||||||
|
prefs.begin("config", true);
|
||||||
|
|
||||||
|
config.interval.cleanup = prefs.getUInt(CLEANUP_INTERVAL_CONFIG_KEY, CLEANUP_INTERVAL);
|
||||||
|
config.interval.tag_timeout = prefs.getUInt(TAG_TIMEOUT_CONFIG_KEY, TAG_TIMEOUT);
|
||||||
|
config.interval.uuid_cache_retry_timeout = prefs.getUInt(UUID_CACHE_RETRY_TIMEOUT_CONFIG_KEY, UUID_CACHE_RETRY_TIMEOUT);
|
||||||
|
config.interval.uuid_cache_clean_timeout = prefs.getUInt(UUID_CACHE_CLEAN_TIMEOUT_CONFIG_KEY, UUID_CACHE_CLEAN_TIMEOUT);
|
||||||
|
|
||||||
|
config.api.url = prefs.getString(API_URL_CONFIG_KEY, API_URL);
|
||||||
|
config.api.uuid_check_endpoint = prefs.getString(UUID_CHECK_ENDPOINT_CONFIG_KEY, UUID_CHECK_ENDPOINT);
|
||||||
|
|
||||||
|
config.mqtt.host = prefs.getString(MQTT_HOST_CONFIG_KEY, MQTT_HOST);
|
||||||
|
config.mqtt.port = prefs.getUShort(MQTT_PORT_CONFIG_KEY, MQTT_PORT);
|
||||||
|
config.mqtt.username = prefs.getString(MQTT_USER_CONFIG_KEY, MQTT_USER);
|
||||||
|
config.mqtt.password = prefs.getString(MQTT_PASS_CONFIG_KEY, MQTT_PASS);
|
||||||
|
config.mqtt.topic = prefs.getString(MQTT_TOPIC_CONFIG_KEY, MQTT_TOPIC);
|
||||||
|
|
||||||
|
config.wifi.ssid = prefs.getString(WIFI_SSID_CONFIG_KEY, WIFI_SSID);
|
||||||
|
config.wifi.password = prefs.getString(WIFI_PASS_CONFIG_KEY, WIFI_PASS);
|
||||||
|
|
||||||
|
config.dev_name = prefs.getString(DEV_NAME_CONFIG_KEY, DEV_NAME);
|
||||||
|
config.tag_name = prefs.getString(TAG_NAME_CONFIG_KEY, TAG_NAME);
|
||||||
|
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleCommand(String line)
|
||||||
|
{
|
||||||
|
if (line.startsWith("set "))
|
||||||
|
{
|
||||||
|
int p1 = line.indexOf(' ', 4);
|
||||||
|
String key = line.substring(4, p1);
|
||||||
|
String value = line.substring(p1 + 1);
|
||||||
|
|
||||||
|
if (key.length() == 0 || value.length() == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (key == CLEANUP_INTERVAL_CONFIG_KEY)
|
||||||
|
config.interval.cleanup = (uint32_t)value.toInt();
|
||||||
|
else if (key == TAG_TIMEOUT_CONFIG_KEY)
|
||||||
|
config.interval.tag_timeout = (uint32_t)value.toInt();
|
||||||
|
else if (key == UUID_CACHE_RETRY_TIMEOUT_CONFIG_KEY)
|
||||||
|
config.interval.uuid_cache_retry_timeout = (uint32_t)value.toInt();
|
||||||
|
else if (key == UUID_CACHE_CLEAN_TIMEOUT_CONFIG_KEY)
|
||||||
|
config.interval.uuid_cache_clean_timeout = (uint32_t)value.toInt();
|
||||||
|
else if (key == API_URL_CONFIG_KEY)
|
||||||
|
config.api.url = value;
|
||||||
|
else if (key == UUID_CHECK_ENDPOINT_CONFIG_KEY)
|
||||||
|
config.api.uuid_check_endpoint = value;
|
||||||
|
else if (key == MQTT_HOST_CONFIG_KEY)
|
||||||
|
config.mqtt.host = value;
|
||||||
|
else if (key == MQTT_PORT_CONFIG_KEY)
|
||||||
|
config.mqtt.port = value.toInt();
|
||||||
|
else if (key == MQTT_USER_CONFIG_KEY)
|
||||||
|
config.mqtt.username = value;
|
||||||
|
else if (key == MQTT_PASS_CONFIG_KEY)
|
||||||
|
config.mqtt.password = value;
|
||||||
|
else if (key == MQTT_TOPIC_CONFIG_KEY)
|
||||||
|
config.mqtt.topic = value;
|
||||||
|
else if (key == WIFI_SSID_CONFIG_KEY)
|
||||||
|
config.wifi.ssid = value;
|
||||||
|
else if (key == WIFI_PASS_CONFIG_KEY)
|
||||||
|
config.wifi.password = value;
|
||||||
|
else if (key == DEV_NAME_CONFIG_KEY)
|
||||||
|
config.dev_name = value;
|
||||||
|
else if (key == TAG_NAME_CONFIG_KEY)
|
||||||
|
config.tag_name = value;
|
||||||
|
}
|
||||||
|
else if (line == "save")
|
||||||
|
saveConfig();
|
||||||
|
else if (line.startsWith("show "))
|
||||||
|
{
|
||||||
|
String key = line.substring(5);
|
||||||
|
|
||||||
|
if (key.length() == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (key == CLEANUP_INTERVAL_CONFIG_KEY)
|
||||||
|
Serial.println(config.interval.cleanup);
|
||||||
|
else if (key == TAG_TIMEOUT_CONFIG_KEY)
|
||||||
|
Serial.println(config.interval.tag_timeout);
|
||||||
|
else if (key == UUID_CACHE_RETRY_TIMEOUT_CONFIG_KEY)
|
||||||
|
Serial.println(config.interval.uuid_cache_retry_timeout);
|
||||||
|
else if (key == UUID_CACHE_CLEAN_TIMEOUT_CONFIG_KEY)
|
||||||
|
Serial.println(config.interval.uuid_cache_clean_timeout);
|
||||||
|
else if (key == API_URL_CONFIG_KEY)
|
||||||
|
Serial.println(config.api.url);
|
||||||
|
else if (key == UUID_CHECK_ENDPOINT_CONFIG_KEY)
|
||||||
|
Serial.println(config.api.uuid_check_endpoint);
|
||||||
|
else if (key == MQTT_HOST_CONFIG_KEY)
|
||||||
|
Serial.println(config.mqtt.host);
|
||||||
|
else if (key == MQTT_PORT_CONFIG_KEY)
|
||||||
|
Serial.println(config.mqtt.port);
|
||||||
|
else if (key == MQTT_USER_CONFIG_KEY)
|
||||||
|
Serial.println(config.mqtt.username);
|
||||||
|
else if (key == MQTT_PASS_CONFIG_KEY)
|
||||||
|
Serial.println(config.mqtt.password);
|
||||||
|
else if (key == MQTT_TOPIC_CONFIG_KEY)
|
||||||
|
Serial.println(config.mqtt.topic);
|
||||||
|
else if (key == WIFI_SSID_CONFIG_KEY)
|
||||||
|
Serial.println(config.wifi.ssid);
|
||||||
|
else if (key == WIFI_PASS_CONFIG_KEY)
|
||||||
|
Serial.println(config.wifi.password);
|
||||||
|
else if (key == DEV_NAME_CONFIG_KEY)
|
||||||
|
Serial.println(config.dev_name);
|
||||||
|
else if (key == TAG_NAME_CONFIG_KEY)
|
||||||
|
Serial.println(config.tag_name);
|
||||||
|
}
|
||||||
|
else if (line == "reset")
|
||||||
|
{
|
||||||
|
prefs.begin("config", false);
|
||||||
|
prefs.clear();
|
||||||
|
prefs.end();
|
||||||
|
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
else if (line == "reboot")
|
||||||
|
{
|
||||||
|
Serial.println("Rebooting...");
|
||||||
|
Serial.flush();
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
else if (line == "help")
|
||||||
|
{
|
||||||
|
Serial.println("Commands:");
|
||||||
|
Serial.println(" set <key> <value> - Set a configuration value");
|
||||||
|
Serial.println(" show <key> - Show a configuration value");
|
||||||
|
Serial.println(" save - Save configuration to non-volatile storage");
|
||||||
|
Serial.println(" reset - Reset configuration to defaults");
|
||||||
|
Serial.println(" reboot - Reboot the device");
|
||||||
|
Serial.println(" help - Show this help message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setup()
|
void setup()
|
||||||
{
|
{
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
|
|
||||||
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
loadConfig();
|
||||||
|
|
||||||
while (WiFi.status() != WL_CONNECTED)
|
WiFi.begin(config.wifi.ssid.c_str(), config.wifi.password.c_str());
|
||||||
{
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.println("\nWiFi connected");
|
MQTT_PUB_TOPIC = config.mqtt.topic + config.dev_name;
|
||||||
|
|
||||||
snprintf(MQTT_PUB_TOPIC, sizeof(MQTT_PUB_TOPIC), "%s%s", MQTT_TOPIC, DEV_NAME);
|
mqtt.setServer(config.mqtt.host.c_str(), config.mqtt.port);
|
||||||
|
|
||||||
mqtt.setServer(MQTT_HOST, MQTT_PORT);
|
|
||||||
mqtt.setKeepAlive(60);
|
mqtt.setKeepAlive(60);
|
||||||
mqtt.setSocketTimeout(5);
|
mqtt.setSocketTimeout(5);
|
||||||
|
|
||||||
@@ -244,7 +418,7 @@ void setup()
|
|||||||
xTaskCreatePinnedToCore(mqttTask, "mqtt", 4096, NULL, 1, NULL, 1);
|
xTaskCreatePinnedToCore(mqttTask, "mqtt", 4096, NULL, 1, NULL, 1);
|
||||||
xTaskCreatePinnedToCore(httpTask, "http", 4096, NULL, 1, NULL, 1);
|
xTaskCreatePinnedToCore(httpTask, "http", 4096, NULL, 1, NULL, 1);
|
||||||
|
|
||||||
NimBLEDevice::init(DEV_NAME);
|
NimBLEDevice::init(config.dev_name.c_str());
|
||||||
|
|
||||||
NimBLEScan *scan = NimBLEDevice::getScan();
|
NimBLEScan *scan = NimBLEDevice::getScan();
|
||||||
|
|
||||||
@@ -264,10 +438,45 @@ void loop()
|
|||||||
|
|
||||||
static uint32_t last_cleanup = 0;
|
static uint32_t last_cleanup = 0;
|
||||||
|
|
||||||
if (millis() - last_cleanup >= CLEANUP_INTERVAL)
|
if (millis() - last_cleanup >= config.interval.cleanup)
|
||||||
{
|
{
|
||||||
cleanupTags();
|
cleanupTags();
|
||||||
cleanupCache();
|
cleanupCache();
|
||||||
last_cleanup = millis();
|
last_cleanup = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (Serial.available())
|
||||||
|
{
|
||||||
|
char c = Serial.read();
|
||||||
|
|
||||||
|
if (c == '\r')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (c == '\n')
|
||||||
|
{
|
||||||
|
Serial.print(c);
|
||||||
|
serial_buffer.trim();
|
||||||
|
|
||||||
|
if (serial_buffer.length() > 0)
|
||||||
|
handleCommand(serial_buffer);
|
||||||
|
|
||||||
|
serial_buffer = "";
|
||||||
|
|
||||||
|
Serial.printf("%s> ", config.dev_name.c_str());
|
||||||
|
}
|
||||||
|
else if (c == '\b')
|
||||||
|
{
|
||||||
|
if (serial_buffer.length() > 0)
|
||||||
|
{
|
||||||
|
serial_buffer.remove(serial_buffer.length() - 1);
|
||||||
|
|
||||||
|
Serial.print("\b \b");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serial.print(c);
|
||||||
|
serial_buffer += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,29 @@
|
|||||||
#include <NimBLEDevice.h>
|
#include <NimBLEDevice.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#define CLEANUP_INTERVAL 5 * 1000 // ms
|
#define CLEANUP_INTERVAL_CONFIG_KEY "int.cln"
|
||||||
#define TAG_TIMEOUT 60 * 1000 // ms
|
#define TAG_TIMEOUT_CONFIG_KEY "int.tagto"
|
||||||
#define UUID_CACHE_RETRY_TIMEOUT 5 * 60 * 1000 // ms
|
#define UUID_CACHE_RETRY_TIMEOUT_CONFIG_KEY "int.urtry"
|
||||||
|
#define UUID_CACHE_CLEAN_TIMEOUT_CONFIG_KEY "int.ucclean"
|
||||||
|
#define API_URL_CONFIG_KEY "api.url"
|
||||||
|
#define UUID_CHECK_ENDPOINT_CONFIG_KEY "api.uuidchk"
|
||||||
|
#define MQTT_HOST_CONFIG_KEY "mq.host"
|
||||||
|
#define MQTT_PORT_CONFIG_KEY "mq.port"
|
||||||
|
#define MQTT_USER_CONFIG_KEY "mq.user"
|
||||||
|
#define MQTT_PASS_CONFIG_KEY "mq.pass"
|
||||||
|
#define MQTT_TOPIC_CONFIG_KEY "mq.topic"
|
||||||
|
#define WIFI_SSID_CONFIG_KEY "wf.ssid"
|
||||||
|
#define WIFI_PASS_CONFIG_KEY "wf.pass"
|
||||||
|
#define DEV_NAME_CONFIG_KEY "dev.name"
|
||||||
|
#define TAG_NAME_CONFIG_KEY "tag.name"
|
||||||
|
|
||||||
|
#define CLEANUP_INTERVAL 5 * 1000 // ms
|
||||||
|
#define TAG_TIMEOUT 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 UUID_CHECK_ENDPOINT "/check"
|
||||||
#define TAG_NAME "X6TAG"
|
#define TAG_NAME "X6TAG"
|
||||||
#define UUID_CHECK_ENDPOINT "http://192.168.1.2/check/"
|
|
||||||
#define MQTT_HOST "192.168.1.2"
|
#define MQTT_HOST "192.168.1.2"
|
||||||
#define MQTT_PORT 1883
|
#define MQTT_PORT 1883
|
||||||
#define MQTT_USER "user"
|
#define MQTT_USER "user"
|
||||||
@@ -18,7 +35,42 @@
|
|||||||
#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 DEV_NAME "Scanner_Room_A"
|
#define DEV_NAME "572b29cb-6a93-480e-adf0-c5c44e9a58df"
|
||||||
|
|
||||||
|
struct Config
|
||||||
|
{
|
||||||
|
struct Interval
|
||||||
|
{
|
||||||
|
uint32_t cleanup;
|
||||||
|
uint32_t tag_timeout;
|
||||||
|
uint32_t uuid_cache_retry_timeout;
|
||||||
|
uint32_t uuid_cache_clean_timeout;
|
||||||
|
} interval;
|
||||||
|
|
||||||
|
struct API
|
||||||
|
{
|
||||||
|
String url;
|
||||||
|
String uuid_check_endpoint;
|
||||||
|
} api;
|
||||||
|
|
||||||
|
struct MQTT
|
||||||
|
{
|
||||||
|
String host;
|
||||||
|
uint16_t port;
|
||||||
|
String username;
|
||||||
|
String password;
|
||||||
|
String topic;
|
||||||
|
} mqtt;
|
||||||
|
|
||||||
|
struct WiFi
|
||||||
|
{
|
||||||
|
String ssid;
|
||||||
|
String password;
|
||||||
|
} wifi;
|
||||||
|
|
||||||
|
String dev_name;
|
||||||
|
String tag_name;
|
||||||
|
};
|
||||||
|
|
||||||
struct BleEvent
|
struct BleEvent
|
||||||
{
|
{
|
||||||
@@ -40,6 +92,7 @@ struct HttpEvent
|
|||||||
enum class UUIDState
|
enum class UUIDState
|
||||||
{
|
{
|
||||||
PENDING,
|
PENDING,
|
||||||
|
RETRY,
|
||||||
VALID,
|
VALID,
|
||||||
INVALID
|
INVALID
|
||||||
};
|
};
|
||||||
|
|||||||
+249
-22
@@ -1,21 +1,13 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
|
|
||||||
#include "nimble/nimble_port.h"
|
#include "nimble/nimble_port.h"
|
||||||
#include "nimble/nimble_port_freertos.h"
|
#include "nimble/nimble_port_freertos.h"
|
||||||
|
|
||||||
#include "host/ble_hs.h"
|
|
||||||
#include "services/gap/ble_svc_gap.h"
|
#include "services/gap/ble_svc_gap.h"
|
||||||
|
#include "driver/usb_serial_jtag.h"
|
||||||
|
#include "main.h"
|
||||||
|
|
||||||
static const char *TAG = "BLE";
|
static config_t config;
|
||||||
|
|
||||||
static const ble_uuid128_t service_uuid =
|
|
||||||
BLE_UUID128_INIT(
|
|
||||||
0xF0, 0xDE, 0xBC, 0x9A,
|
|
||||||
0x78, 0x56, 0x34, 0x12,
|
|
||||||
0xF0, 0xDE, 0xBC, 0x9A,
|
|
||||||
0x78, 0x56, 0x34, 0x12);
|
|
||||||
|
|
||||||
static void start_adv(void)
|
static void start_adv(void)
|
||||||
{
|
{
|
||||||
@@ -24,19 +16,18 @@ static void start_adv(void)
|
|||||||
|
|
||||||
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||||
|
|
||||||
fields.uuids128 = (ble_uuid128_t *)&service_uuid;
|
fields.name = (uint8_t *)config.name;
|
||||||
|
fields.name_len = strlen(config.name);
|
||||||
|
fields.name_is_complete = 1;
|
||||||
|
|
||||||
|
fields.uuids128 = (ble_uuid128_t *)&config.uuid;
|
||||||
fields.num_uuids128 = 1;
|
fields.num_uuids128 = 1;
|
||||||
fields.uuids128_is_complete = 1;
|
fields.uuids128_is_complete = 1;
|
||||||
|
|
||||||
uint8_t mfg_data[5] = {0x34, 0x34, 0x34, 0x34, 0x34};
|
|
||||||
|
|
||||||
fields.mfg_data = mfg_data;
|
|
||||||
fields.mfg_data_len = sizeof(mfg_data);
|
|
||||||
|
|
||||||
int rc = ble_gap_adv_set_fields(&fields);
|
int rc = ble_gap_adv_set_fields(&fields);
|
||||||
if (rc != 0)
|
if (rc != 0)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "adv set fields failed: %d", rc);
|
ESP_LOGE(config.name, "adv set fields failed: %d", rc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,17 +49,17 @@ static void start_adv(void)
|
|||||||
|
|
||||||
if (rc != 0)
|
if (rc != 0)
|
||||||
{
|
{
|
||||||
ESP_LOGE(TAG, "adv start failed: %d", rc);
|
ESP_LOGE(config.name, "adv start failed: %d", rc);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ESP_LOGI(TAG, "advertising started");
|
ESP_LOGI(config.name, "advertising started");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void on_sync(void)
|
static void on_sync(void)
|
||||||
{
|
{
|
||||||
ESP_LOGI(TAG, "BLE synced");
|
ESP_LOGI(config.name, "BLE synced");
|
||||||
|
|
||||||
start_adv();
|
start_adv();
|
||||||
}
|
}
|
||||||
@@ -78,15 +69,251 @@ static void host_task(void *param)
|
|||||||
nimble_port_run();
|
nimble_port_run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool uuid_from_string(const char *str, ble_uuid128_t *uuid)
|
||||||
|
{
|
||||||
|
unsigned int b[16];
|
||||||
|
|
||||||
|
int rc = sscanf(str,
|
||||||
|
"%02x%02x%02x%02x-"
|
||||||
|
"%02x%02x-"
|
||||||
|
"%02x%02x-"
|
||||||
|
"%02x%02x-"
|
||||||
|
"%02x%02x%02x%02x%02x%02x",
|
||||||
|
|
||||||
|
&b[15], &b[14], &b[13], &b[12],
|
||||||
|
&b[11], &b[10],
|
||||||
|
&b[9], &b[8],
|
||||||
|
&b[7], &b[6],
|
||||||
|
&b[5], &b[4], &b[3],
|
||||||
|
&b[2], &b[1], &b[0]);
|
||||||
|
|
||||||
|
if (rc != 16)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uuid->u.type = BLE_UUID_TYPE_128;
|
||||||
|
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
uuid->value[i] = (uint8_t)b[i];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t reset_config()
|
||||||
|
{
|
||||||
|
nvs_handle_t nvs;
|
||||||
|
|
||||||
|
esp_err_t err = nvs_open("config", NVS_READWRITE, &nvs);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
|
||||||
|
err = nvs_erase_all(nvs);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
goto exit;
|
||||||
|
|
||||||
|
err = nvs_commit(nvs);
|
||||||
|
|
||||||
|
exit:
|
||||||
|
nvs_close(nvs);
|
||||||
|
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t save_config()
|
||||||
|
{
|
||||||
|
nvs_handle_t nvs;
|
||||||
|
|
||||||
|
esp_err_t err = nvs_open("config", NVS_READWRITE, &nvs);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
|
||||||
|
err = nvs_set_str(nvs, "uuid", config.uuid_str);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
goto exit;
|
||||||
|
|
||||||
|
err = nvs_set_str(nvs, "name", config.name);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
goto exit;
|
||||||
|
|
||||||
|
err = nvs_set_u32(nvs, "adv_int", config.adv_interval);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
goto exit;
|
||||||
|
|
||||||
|
err = nvs_commit(nvs);
|
||||||
|
|
||||||
|
exit:
|
||||||
|
nvs_close(nvs);
|
||||||
|
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t load_config()
|
||||||
|
{
|
||||||
|
nvs_handle_t nvs;
|
||||||
|
|
||||||
|
esp_err_t err = nvs_open("config", NVS_READONLY, &nvs);
|
||||||
|
|
||||||
|
strcpy(config.uuid_str, DEFAULT_UUID);
|
||||||
|
strcpy(config.name, DEFAULT_NAME);
|
||||||
|
config.adv_interval = DEFAULT_ADV_INTERVAL;
|
||||||
|
|
||||||
|
uuid_from_string(config.uuid_str, &config.uuid);
|
||||||
|
|
||||||
|
if (err != ESP_OK)
|
||||||
|
return err;
|
||||||
|
|
||||||
|
size_t len;
|
||||||
|
|
||||||
|
len = sizeof(config.uuid_str);
|
||||||
|
|
||||||
|
nvs_get_str(nvs, "uuid", config.uuid_str, &len);
|
||||||
|
|
||||||
|
len = sizeof(config.name);
|
||||||
|
|
||||||
|
nvs_get_str(nvs, "name", config.name, &len);
|
||||||
|
|
||||||
|
nvs_get_u32(nvs, "adv_int", &config.adv_interval);
|
||||||
|
|
||||||
|
nvs_close(nvs);
|
||||||
|
|
||||||
|
uuid_from_string(config.uuid_str, &config.uuid);
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void serial_task(void *arg)
|
||||||
|
{
|
||||||
|
char line[128];
|
||||||
|
char buf[64];
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
while (1)
|
||||||
|
{
|
||||||
|
uint8_t ch;
|
||||||
|
|
||||||
|
int len = usb_serial_jtag_read_bytes(&ch, 1, portMAX_DELAY);
|
||||||
|
|
||||||
|
if (len > 0)
|
||||||
|
{
|
||||||
|
if (ch == '\n' || ch == '\r')
|
||||||
|
{
|
||||||
|
usb_serial_jtag_write_bytes("\n", 1, 20 / portTICK_PERIOD_MS);
|
||||||
|
line[pos] = '\0';
|
||||||
|
|
||||||
|
if (pos > 0)
|
||||||
|
{
|
||||||
|
if (strncmp(line, "set uuid ", 9) == 0)
|
||||||
|
{
|
||||||
|
if (uuid_from_string(line + 9, &config.uuid))
|
||||||
|
strcpy(config.uuid_str, line + 9);
|
||||||
|
}
|
||||||
|
else if (strncmp(line, "set name ", 9) == 0)
|
||||||
|
{
|
||||||
|
strncpy(config.name, line + 9, sizeof(config.name) - 1);
|
||||||
|
config.name[sizeof(config.name) - 1] = '\0';
|
||||||
|
}
|
||||||
|
else if (strncmp(line, "set adv_interval ", 17) == 0)
|
||||||
|
{
|
||||||
|
uint32_t interval = atoi(line + 17);
|
||||||
|
if (interval > 0)
|
||||||
|
config.adv_interval = interval;
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "show uuid") == 0)
|
||||||
|
{
|
||||||
|
usb_serial_jtag_write_bytes(config.uuid_str, strlen(config.uuid_str), 20 / portTICK_PERIOD_MS);
|
||||||
|
usb_serial_jtag_write_bytes("\n", 1, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "show name") == 0)
|
||||||
|
{
|
||||||
|
usb_serial_jtag_write_bytes(config.name, strlen(config.name), 20 / portTICK_PERIOD_MS);
|
||||||
|
usb_serial_jtag_write_bytes("\n", 1, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "show adv_interval") == 0)
|
||||||
|
{
|
||||||
|
int len = snprintf(buf, sizeof(buf), "%lu\n", (unsigned long)config.adv_interval);
|
||||||
|
usb_serial_jtag_write_bytes(buf, len, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "save") == 0)
|
||||||
|
save_config();
|
||||||
|
else if (strcmp(line, "reset") == 0)
|
||||||
|
{
|
||||||
|
reset_config();
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "reboot") == 0)
|
||||||
|
{
|
||||||
|
usb_serial_jtag_write_bytes("Rebooting...\n", 13, 20 / portTICK_PERIOD_MS);
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "help") == 0)
|
||||||
|
{
|
||||||
|
usb_serial_jtag_write_bytes("Commands:\n"
|
||||||
|
" set uuid <uuid>\n"
|
||||||
|
" set name <name>\n"
|
||||||
|
" set adv_interval <ms>\n"
|
||||||
|
" show uuid\n"
|
||||||
|
" show name\n"
|
||||||
|
" show adv_interval\n"
|
||||||
|
" save\n"
|
||||||
|
" reset\n"
|
||||||
|
" reboot\n"
|
||||||
|
" help\n",
|
||||||
|
145, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = 0;
|
||||||
|
int len = snprintf(buf, sizeof(buf), "%s> ", config.name);
|
||||||
|
usb_serial_jtag_write_bytes(buf, len, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
else if (ch == 0x08)
|
||||||
|
{
|
||||||
|
if (pos > 0)
|
||||||
|
{
|
||||||
|
pos--;
|
||||||
|
usb_serial_jtag_write_bytes("\b \b", 3, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (pos < sizeof(line) - 1)
|
||||||
|
{
|
||||||
|
line[pos++] = ch;
|
||||||
|
usb_serial_jtag_write_bytes((char *)&ch, 1, 20 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
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_install(&cfg);
|
||||||
|
|
||||||
|
load_config();
|
||||||
|
|
||||||
nimble_port_init();
|
nimble_port_init();
|
||||||
|
|
||||||
ble_svc_gap_init();
|
ble_svc_gap_init();
|
||||||
ble_svc_gap_device_name_set("XIAO_C6");
|
ble_svc_gap_device_name_set("X6TAG");
|
||||||
|
|
||||||
ble_hs_cfg.sync_cb = on_sync;
|
ble_hs_cfg.sync_cb = on_sync;
|
||||||
|
|
||||||
|
xTaskCreate(
|
||||||
|
serial_task,
|
||||||
|
"serial_task",
|
||||||
|
4096,
|
||||||
|
NULL,
|
||||||
|
5,
|
||||||
|
NULL);
|
||||||
|
|
||||||
nimble_port_freertos_init(host_task);
|
nimble_port_freertos_init(host_task);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#ifndef MAIN_H
|
||||||
|
#define MAIN_H
|
||||||
|
|
||||||
|
#include "host/ble_hs.h"
|
||||||
|
|
||||||
|
#define DEFAULT_UUID "12345678-9abc-def0-1234-56789abcdef0"
|
||||||
|
#define DEFAULT_NAME "X6TAG"
|
||||||
|
#define DEFAULT_ADV_INTERVAL 0x80
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
ble_uuid128_t uuid;
|
||||||
|
char uuid_str[37];
|
||||||
|
char name[32];
|
||||||
|
uint32_t adv_interval;
|
||||||
|
} config_t;
|
||||||
|
|
||||||
|
#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