35 Commits
Author SHA1 Message Date
adminandGitHub 8e4c1e99e1 architecture image 2026-06-27 15:35:45 +03:00
adminandGitHub 0222f34621 pitch presentation 2026-06-27 14:53:44 +03:00
adminandGitHub 97dbdade45 one-slide display 2026-06-22 23:48:31 +03:00
adminandGitHub 4ab27bbabc Final report 2026-06-22 22:45:32 +03:00
admin 5c7af613ff fix camera timeout 2026-06-22 22:05:08 +03:00
adminandGitHub 6040ce4c1d docker compose for secondary services 2026-06-22 22:03:54 +03:00
adminandGitHub 733e76a4bd Final report 2026-06-22 22:01:14 +03:00
admin ff7d0d4ee1 influxdb implementation 2026-06-22 18:55:32 +03:00
admin 60277dfbd7 format 2026-06-22 18:53:46 +03:00
admin 67695e8d47 add influxdb 2026-06-22 18:52:47 +03:00
admin f9a14fa2f1 add bin dir copy 2026-06-19 16:42:20 +03:00
admin 2ae3674869 duplicate 2026-06-19 16:42:10 +03:00
admin 40c7fbda9c iot ips 2026-06-18 19:07:04 +03:00
admin cbb89defe1 publish bundler files with express 2026-06-18 19:03:10 +03:00
admin b4f66069d2 final dockerfile 2026-06-18 19:02:39 +03:00
admin 31fc14310b update using env vars 2026-06-18 19:01:28 +03:00
admin 0576e1becf bundler config for prod build 2026-06-18 19:01:12 +03:00
admin 2f7454147a install dotenv and bundler 2026-06-18 19:00:11 +03:00
admin 51b06ae868 upload makefile 2026-06-18 18:59:55 +03:00
admin 734d1a29ad updated dockerignore 2026-06-18 18:59:40 +03:00
admin a16e8cc4ea added password to redis 2026-06-18 18:59:16 +03:00
admin d3ced437ec static wifi config & wifi-reconnect 2026-06-03 23:22:34 +03:00
Kostas Drakontidis 691fc3318e update default values 2026-06-03 02:21:14 +03:00
Kostas Drakontidis ccb71ecbd9 update help command 2026-06-03 02:20:50 +03:00
Kostas Drakontidis 1d50ec3791 debug msg 2026-06-03 02:00:42 +03:00
Kostas Drakontidis 368806f5ed debug msg & signal lost better handling 2026-06-03 02:00:35 +03:00
Kostas Drakontidis 65e088f8e8 redis ttl fix & potential bug fix 2026-06-03 02:00:13 +03:00
Kostas Drakontidis 657c1db80d fine tuning & camera scan interval 2026-06-03 01:58:50 +03:00
Kostas Drakontidis 9cf8ddd921 change adv_interval 2026-06-03 01:54:41 +03:00
Kostas Drakontidis 600dfbe2a4 debug led & adv_interval fix 2026-06-02 23:43:13 +03:00
Kostas DrakontidisandGitHub 5b1c8e83a8 Integrate camera scripts and update progress details 2026-06-01 10:43:23 +03:00
Kostas DrakontidisandGitHub 52da5fa2cb Increase verifyTimeout to 5 minutes 2026-06-01 02:33:25 +03:00
Kostas DrakontidisandGitHub 1075c7c7ad Merge pull request #25 from dkwstas/prettier
prettier format
2026-06-01 02:31:28 +03:00
Kostas Drakontidis 3221e95d0e prettier format 2026-06-01 02:30:59 +03:00
Kostas DrakontidisandGitHub a7974c78f3 Merge pull request #24 from dkwstas/vision_integration
Vision integration
2026-06-01 02:25:54 +03:00
42 changed files with 1143 additions and 625 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 556 KiB

+5 -1
View File
@@ -63,6 +63,10 @@
### Completed
- camera scripts
- adminjs configuration
- integrate camera scripts to system
### In Progress
- connect camera scripts to core script and adminjs
- ~connect camera scripts to core script and adminjs~
### Next Steps
- Debugging and refactor
+43
View File
@@ -0,0 +1,43 @@
---
services:
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
nodered:
image: nodered/node-red:latest
container_name: nodered
restart: unless-stopped
ports:
- "1880:1880"
volumes:
- nodered_data:/data
redis:
image: redis:7
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "/etc/redis/redis.conf"]
volumes:
- ./redis/data:/data
- ./redis/redis.conf:/etc/redis/redis.conf
influxdb:
image: influxdb:2
container_name: influxdb
restart: unless-stopped
ports:
- "8086:8086"
volumes:
- ./influxdb/data:/var/lib/influxdb2
- ./influxdb/config:/etc/influxdb2
volumes:
nodered_data:
external: true
+9
View File
@@ -1,3 +1,12 @@
node_modules
.git
npm-debug.log
dist
.env*
*.log
.gitignore
README.md
*.md
.prettierrc*
data
+61 -5
View File
@@ -1,19 +1,75 @@
FROM node:24-slim
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
FROM node:24-slim AS builder
WORKDIR /app
RUN apt-get update -y && apt-get install -y openssl
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package*.json ./
# Install all deps (including devDependencies for build)
RUN npm ci
COPY . .
# Generate Prisma client (needed for TypeScript types)
COPY prisma ./prisma
RUN npx prisma generate
# Copy source
COPY . .
# Pre-bundle AdminJS components at build time
RUN npm run bundle:adminjs && cp .adminjs/components.bundle.js .adminjs/bundle.js
# Compile TypeScript
RUN npm run build
# ─── Stage 2: Production ──────────────────────────────────────────────────────
FROM node:24-slim AS production
WORKDIR /app
# Install native runtime libs
RUN apt-get update && apt-get install -y --no-install-recommends \
libdlib19.1 \
libjpeg62-turbo \
libblas3 \
libopenblas0 \
libatlas3-base \
liblapack3 \
openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package*.json ./
# Install production deps only
RUN npm ci --omit=dev
# Copy compiled JS from builder
COPY --from=builder /app/dist ./dist
# AdminJS ComponentLoader validates source file paths exist at startup
COPY --from=builder /app/src ./src
# Copy pre-built AdminJS bundle
COPY --from=builder /app/.adminjs ./.adminjs
# Copy pre-built Go binaries
COPY --from=builder /app/bin ./bin
# Copy Prisma schema + generated client
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma ./prisma
# Set permissions for non-root user
RUN chown -R node:node /app/.adminjs /app/dist /app/src /app/bin
EXPOSE 80
USER node
ENV NODE_ENV=production
ENV ADMIN_JS_SKIP_BUNDLE="true"
CMD ["node", "dist/index.js"]
+44
View File
@@ -0,0 +1,44 @@
build:
docker build -t officesense:latest .
run:
docker run --rm -it --init -p 80:80 \
-e ADMIN_EMAIL=admin@officesense.com \
-e ADMIN_PASSWORD=supersecret \
-e ADMIN_COOKIE_SECRET=some-long-random-string \
-e NODE_ENV=production \
-e DATABASE_URL=file:/app/data/officesense.sqlite \
--device /dev/video0:/dev/video0 \
-v $(shell pwd)/data:/app/data \
officesense:latest
dev:
docker run --rm -it --init -p 80:80 \
-v $(shell pwd):/app \
-w /app \
-e ADMIN_EMAIL=admin@officesense.com \
-e ADMIN_PASSWORD=supersecret \
-e ADMIN_COOKIE_SECRET=some-long-random-string \
-e NODE_ENV=development \
--device /dev/video0:/dev/video0 \
node:24-slim \
bash -c "apt-get update && apt-get install -y libdlib19.1 libjpeg62-turbo libblas3 libopenblas0 libatlas3-base liblapack3 && node --run dev"
migrate:
docker run --rm \
-e DATABASE_URL=file:/app/data/officesense.sqlite \
-v $(shell pwd)/data:/app/data \
officesense:latest \
npx prisma migrate deploy
sh:
docker run --rm -it -v $(shell pwd):/app -w /app node:24-slim sh
format:
docker run --rm -it --init \
-v $(shell pwd):/app \
-w /app \
node:24-slim \
bash -c "node --run format"
.PHONY: build run dev migrate sh format
+31
View File
@@ -11,8 +11,10 @@
"dependencies": {
"@adminjs/express": "^6.1.1",
"@adminjs/prisma": "^5.0.4",
"@influxdata/influxdb-client": "^1.35.0",
"@prisma/client": "^6.19.3",
"adminjs": "^7.8.17",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0",
"mqtt": "^5.15.1",
@@ -20,6 +22,7 @@
"redis": "^5.12.1"
},
"devDependencies": {
"@adminjs/bundler": "^3.0.0",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "^25.7.0",
@@ -28,6 +31,16 @@
"typescript": "^6.0.3"
}
},
"node_modules/@adminjs/bundler": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@adminjs/bundler/-/bundler-3.0.0.tgz",
"integrity": "sha512-s8ItuELPQEXDIEI2jc5TB2iA1jGtNOdZrWrweJvIeE20ORE50m1Ws6uS+bLsAuZCGh6dHOxCWOKFZ/wDQg4TKg==",
"dev": true,
"license": "SEE LICENSE IN LICENSE",
"peerDependencies": {
"adminjs": "^7.0.0"
}
},
"node_modules/@adminjs/design-system": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@adminjs/design-system/-/design-system-4.1.1.tgz",
@@ -2364,6 +2377,12 @@
"react": ">=0.14.0"
}
},
"node_modules/@influxdata/influxdb-client": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz",
"integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -4906,6 +4925,18 @@
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+4
View File
@@ -8,12 +8,14 @@
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"bundle:adminjs": "tsx src/api/management/bundler.ts",
"build": "tsc",
"start": "node dist/index.js",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
},
"devDependencies": {
"@adminjs/bundler": "^3.0.0",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "^25.7.0",
@@ -24,8 +26,10 @@
"dependencies": {
"@adminjs/express": "^6.1.1",
"@adminjs/prisma": "^5.0.4",
"@influxdata/influxdb-client": "^1.35.0",
"@prisma/client": "^6.19.3",
"adminjs": "^7.8.17",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.19.0",
"mqtt": "^5.15.1",
@@ -1,5 +1,10 @@
import type { Request, Response, NextFunction } from "express";
import { getActiveUserData, getRedisRoomData, type RedisRoomData, type RedisUserData } from "./livedata.repository.js";
import {
getActiveUserData,
getRedisRoomData,
type RedisRoomData,
type RedisUserData,
} from "./livedata.repository.js";
import { prisma } from "../../lib/prisma.js";
interface Params {
@@ -44,11 +49,11 @@ function convertToNGSIUser(user: RedisUserData): User {
type: "User",
name: {
type: "Property",
value: user.name
value: user.name,
},
locatedIn: {
type: "Relationship",
object: user.room
object: user.room,
},
rssi: {
type: "Property",
@@ -56,20 +61,20 @@ function convertToNGSIUser(user: RedisUserData): User {
},
authenticationStatus: {
type: "Property",
value: user.verified ? "verified" : "unverified"
value: user.verified ? "verified" : "unverified",
},
observedAt: {
type: "Property",
value: (new Date(user.timestamp)).toISOString()
}
}
value: new Date(user.timestamp).toISOString(),
},
};
}
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
const roomName = await prisma.room.findUnique({
where: { id: room.roomID },
select: { name: true }
})
select: { name: true },
});
if (!roomName?.name) return null;
@@ -78,13 +83,13 @@ async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
type: "Room",
name: {
type: "Property",
value: roomName.name
value: roomName.name,
},
occupancy: {
type: "Property",
value: room.occupancy
}
}
value: room.occupancy,
},
};
}
async function getNGSIUsers(): Promise<User[]> {
@@ -121,8 +126,8 @@ export async function getEntities(req: Request, res: Response, next: NextFunctio
let entities: Entity[] = [];
entities.push(...await getNGSIUsers());
entities.push(...await getNGSIRooms());
entities.push(...(await getNGSIUsers()));
entities.push(...(await getNGSIRooms()));
return res.status(200).send(entities);
}
@@ -131,8 +136,10 @@ export async function getEntitiesOfType(req: Request, res: Response) {
const { type } = req.query;
switch (type) {
case "user": return res.status(200).send(await getNGSIUsers());
case "room": return res.status(200).send(await getNGSIRooms());
case "user":
return res.status(200).send(await getNGSIUsers());
case "room":
return res.status(200).send(await getNGSIRooms());
default:
return res.sendStatus(400);
}
@@ -167,6 +174,5 @@ export async function getEntity(req: Request<Params>, res: Response) {
if (!data.length) return res.sendStatus(404);
return res.status(200).send(convertToNGSIUser(data[0]!));
} else
return res.sendStatus(400);
} else return res.sendStatus(400);
}
@@ -30,15 +30,9 @@ export async function getActiveUserData(userID?: string): Promise<RedisUserData[
cursor = res.cursor;
const values = await Promise.all(
res.keys.map((key) => redis.get(key))
);
result.push(...values
.filter((v): v is string => v !== null)
.map((v) => JSON.parse(v))
);
const values = await Promise.all(res.keys.map((key) => redis.get(key)));
result.push(...values.filter((v): v is string => v !== null).map((v) => JSON.parse(v)));
} while (cursor !== "0");
return result;
@@ -79,14 +73,14 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
}))
);
result.push(...values
result.push(
...values
.filter((v) => v.occupancy !== null)
.map((v) => ({
roomID: v.roomID.slice(5),
occupancy: Number(v.occupancy),
}))
);
} while (cursor !== "0");
return result;
@@ -99,8 +93,8 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
return [{ roomID: roomID, occupancy: Number(res) }];
}
export async function getRoomIDs(): Promise<{ id: string; }[]> {
export async function getRoomIDs(): Promise<{ id: string }[]> {
return await prisma.room.findMany({
select: { id: true }
select: { id: true },
});
}
@@ -2,7 +2,7 @@ import { prisma } from "../../lib/prisma.js";
export async function existsByUUID(uuid: string): Promise<boolean> {
const tag = await prisma.tag.findUnique({
where: { id: uuid }
where: { id: uuid },
});
return !!tag;
@@ -2,7 +2,7 @@ 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 { componentLoader, Components } from "./components.bundler.js";
import userResource from "./resources/adminjs.user.resource.js";
import tagResource from "./resources/adminjs.tag.resource.js";
@@ -11,43 +11,41 @@ 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,
resources: [userResource, tagResource, roomResource],
dashboard: { component: Components.Dashboard },
});
await admin.watch();
if (process.env.NODE_ENV === "development") await admin.watch();
else await admin.initialize();
const router = AdminJSExpress.buildAuthenticatedRouter(
admin,
{
authenticate: async (email, password) => {
if (
email === process.env.ADMIN_EMAIL &&
password === process.env.ADMIN_PASSWORD
) {
return { email }
if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASSWORD) {
return { email };
}
return null
return null;
},
cookieName: 'adminjs',
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
cookieName: "adminjs",
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
},
null,
{
resave: false,
saveUninitialized: false,
secret: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
}
)
);
return { admin, router }
return { admin, router };
}
const sessionMiddleware = session({
@@ -56,4 +54,4 @@ const sessionMiddleware = session({
saveUninitialized: false,
});
export { createAdmin, sessionMiddleware }
export { createAdmin, sessionMiddleware };
@@ -0,0 +1,9 @@
import { bundle } from "@adminjs/bundler";
import { componentLoader } from "./components.bundler.js";
void (async () => {
await bundle({
componentLoader,
destinationDir: "./.adminjs",
});
})();
@@ -0,0 +1,15 @@
import { ComponentLoader } from "adminjs";
import path from "path";
import * as url from "url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
export const componentLoader = new ComponentLoader();
const add = (filePath: string, componentName: string): string =>
componentLoader.add(componentName, path.join(__dirname, filePath));
export const Components = {
Dashboard: add("components/Dashboard", "Dashboard"),
UploadFace: add("components/UploadFace", "UploadFace"),
FaceEmbeddingField: add("components/FaceEmbeddingField", "FaceEmbeddingField"),
};
@@ -1,13 +0,0 @@
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'),
}
@@ -1,61 +1,68 @@
import React, { useEffect, useState } from 'react'
import { Box, H2, Text, Loader } from '@adminjs/design-system'
import { ApiClient } from 'adminjs'
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
}
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 [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())
const res = await fetch("/admin/map", { credentials: "include" });
const data = await res.json();
setSessions(data);
setUpdated(new Date().toLocaleTimeString());
} catch (e) {
console.error(e)
console.error(e);
} finally {
setLoading(false)
}
setLoading(false);
}
};
useEffect(() => {
load()
const interval = setInterval(load, 10000)
return () => clearInterval(interval)
}, [])
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, []);
const initials = (first: string, last: string) =>
((first?.[0] ?? '') + (last?.[0] ?? '')).toUpperCase() || '?'
((first?.[0] ?? "") + (last?.[0] ?? "")).toUpperCase() || "?";
return (
<Box padding="xl">
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom="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>
<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',
padding: "6px 14px",
borderRadius: "6px",
border: "1px solid #e5e7eb",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
}}
>
Refresh
@@ -70,44 +77,89 @@ const Dashboard = () => {
) : (
<Box
style={{
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '12px',
overflow: 'hidden',
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: "12px",
overflow: "hidden",
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<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 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,
}}>
{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>
<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>
@@ -115,7 +167,7 @@ const Dashboard = () => {
</Box>
)}
</Box>
)
}
);
};
export default Dashboard
export default Dashboard;
@@ -1,26 +1,28 @@
import React from 'react'
import { BasePropertyProps } from 'adminjs'
import { Label } from '@adminjs/design-system'
import React from "react";
import type { BasePropertyProps } from "adminjs";
import { Label } from "@adminjs/design-system";
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
const has = !!record?.params?.faceEmbedding
const has = !!record?.params?.faceEmbedding;
return (
<div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: "16px" }}>
<Label>{property.label}</Label>
<div style={{ marginTop: 6 }}>
<span style={{
fontSize: '12px',
<span
style={{
fontSize: "12px",
fontWeight: 600,
padding: '3px 10px',
borderRadius: '4px',
background: has ? '#dcfce7' : '#fee2e2',
color: has ? '#16a34a' : '#dc2626',
}}>
{has ? 'SET' : 'UNSET'}
padding: "3px 10px",
borderRadius: "4px",
background: has ? "#dcfce7" : "#fee2e2",
color: has ? "#16a34a" : "#dc2626",
}}
>
{has ? "SET" : "UNSET"}
</span>
</div>
</div>
)
}
);
};
export default FaceEmbeddingField
export default FaceEmbeddingField;
@@ -1,48 +1,50 @@
import React, { useState, useRef } from 'react'
import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system'
import { ActionProps, useRecord } from 'adminjs'
import React, { useState, useRef } from "react";
import { Box, H3, Text, Button, MessageBox } from "@adminjs/design-system";
import type { ActionProps } from "adminjs";
const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
const [preview, setPreview] = useState<string | null>(null)
const [base64, setBase64] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [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()
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 result = reader.result as string;
setPreview(result);
setBase64(result.split(",")[1] ?? null); // strip data:image/jpeg;base64,
};
reader.readAsDataURL(file);
};
const handleSubmit = async () => {
if (!base64) return
setLoading(true)
setNotice(null)
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',
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ base64 }),
}
)
const data = await res.json()
setNotice(data.notice)
);
const data = await res.json();
setNotice(data.notice);
} catch (e: any) {
setNotice({ message: e.message, type: 'error' })
setNotice({ message: e.message, type: "error" });
} finally {
setLoading(false)
}
setLoading(false);
}
};
return (
<Box padding="xl">
@@ -54,7 +56,7 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
{notice && (
<MessageBox
message={notice.message}
variant={notice.type === 'success' ? 'success' : 'danger'}
variant={notice.type === "success" ? "success" : "danger"}
marginBottom="lg"
/>
)}
@@ -65,7 +67,7 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
type="file"
accept="image/jpeg"
onChange={handleFile}
style={{ display: 'none' }}
style={{ display: "none" }}
/>
<Button onClick={() => inputRef.current?.click()} variant="outlined">
Choose JPEG
@@ -77,20 +79,22 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
<img
src={preview}
alt="Preview"
style={{ width: 160, height: 160, objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }}
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 onClick={handleSubmit} disabled={!base64 || loading} variant="contained">
{loading ? "Processing…" : "Save embedding"}
</Button>
</Box>
)
}
);
};
export default UploadFace
export default UploadFace;
@@ -13,4 +13,4 @@ export default {
editProperties: ["name"],
filterProperties: ["name"],
},
}
};
@@ -18,4 +18,4 @@ export default {
},
},
},
}
};
@@ -1,48 +1,48 @@
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'
import { Components } from "../components.bundler.js";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const execPath = '../../../../bin';
const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings')
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 = ''
cwd: path.join(__dirname, execPath),
});
let stdout = "";
let stderr = "";
proc.stdout.on('data', (d) => stdout += d)
proc.stderr.on('data', (d) => stderr += d)
proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on("data", (d) => (stderr += d));
proc.stdin.on('error', (err) => { });
proc.stdin.on("error", (err) => {});
proc.on('error', (err) => {
reject(new Error(`Failed to start binary: ${err.message}`))
})
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}`))
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))
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}`))
reject(new Error(`Failed to parse output: ${stdout}`));
}
})
});
proc.stdin.write(base64)
proc.stdin.end()
})
proc.stdin.write(base64);
proc.stdin.end();
});
}
export default {
@@ -69,41 +69,41 @@ export default {
},
actions: {
uploadFace: {
actionType: 'record',
icon: 'Camera',
label: 'Upload face',
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
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' },
}
notice: { message: "No image provided", type: "error" },
};
}
try {
const embedding = await extractEmbedding(base64)
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' },
}
notice: { message: "Face embedding saved!", type: "success" },
};
} catch (e: any) {
return {
record: record.toJSON(currentAdmin),
notice: { message: `Failed: ${e.message}`, type: 'error' },
notice: { message: `Failed: ${e.message}`, type: "error" },
};
}
}
}
return { record: record.toJSON(currentAdmin) }
return { record: record.toJSON(currentAdmin) };
},
component: Components.UploadFace,
},
},
},
}
};
@@ -3,7 +3,7 @@ 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' })
return res.status(401).json({ error: "Unauthorized" });
}
next();
}
@@ -12,6 +12,6 @@ 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' })
res.status(500).json({ error: "Failed to fetch sessions" });
}
}
@@ -4,26 +4,28 @@ import { prisma } from "../../lib/prisma.js";
export async function generateMap() {
const redis = getRedis();
const keys = await redis.keys('user:*')
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 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 }
})
select: { id: true, firstName: true, lastName: true },
});
return {
realUserId,
firstName: user?.firstName ?? 'Unknown',
lastName: user?.lastName ?? 'Unknown',
firstName: user?.firstName ?? "Unknown",
lastName: user?.lastName ?? "Unknown",
pseudoId: session.userID,
pseudoName: session.name
}
}))
pseudoName: session.name,
};
})
);
return sessions;
}
+40 -19
View File
@@ -1,3 +1,6 @@
import * as dotenv from "dotenv";
dotenv.config();
interface Config {
api: {
address: string;
@@ -13,6 +16,13 @@ interface Config {
redis: {
host: string;
port: number;
password: string;
};
influx: {
url: string;
token: string;
org: string;
bucket: string;
};
core: {
hysteresis: number;
@@ -24,36 +34,47 @@ interface Config {
lossThreshold: number;
userTTL: number;
verifyTimeout: number;
cameraInterval: number;
};
}
const config: Config = {
api: {
address: "0.0.0.0",
port: 80,
address: process.env.API_ADDRESS ?? "0.0.0.0",
port: parseInt(process.env.API_PORT ?? "80"),
},
mqtt: {
host: "192.168.1.2",
port: 1883,
username: "user",
password: "pass",
topic: "scanners/+",
host: process.env.MQTT_HOST ?? "10.24.4.13",
port: parseInt(process.env.MQTT_PORT ?? "1883"),
username: process.env.MQTT_USERNAME ?? "user",
password: process.env.MQTT_PASSWORD ?? "pass",
topic: process.env.MQTT_TOPIC ?? "scanners/+",
},
redis: {
host: "192.168.1.2",
port: 6379,
host: process.env.REDIS_HOST ?? "10.24.4.13",
port: parseInt(process.env.REDIS_PORT ?? "6379"),
password: process.env.REDIS_PASSWORD ?? "redispassword",
},
influx: {
url: process.env.INFLUX_URL ?? "http://10.24.4.13:8086",
token: process.env.INFLUX_TOKEN ?? "",
org: process.env.INFLUX_ORG ?? "officesense",
bucket: process.env.INFLUX_BUCKET ?? "officesense",
},
core: {
hysteresis: 6,
candidateHysteresis: 3,
debounceMS: 3000,
minSamples: 4,
transitionTTL: 5 * 60 * 1000,
transitionCleanupInterval: 60 * 1000,
lossThreshold: 5000,
userTTL: 3 * 60 * 1000,
verifyTimeout: 30 * 1000
hysteresis: parseInt(process.env.CORE_HYSTERESIS ?? "6"),
candidateHysteresis: parseInt(process.env.CORE_CANDIDATE_HYSTERESIS ?? "5"),
debounceMS: parseInt(process.env.CORE_DEBOUNCE_MS ?? String(3 * 1000)),
minSamples: parseInt(process.env.CORE_MIN_SAMPLES ?? "4"),
transitionTTL: parseInt(process.env.CORE_TRANSITION_TTL ?? String(5 * 60 * 1000)),
transitionCleanupInterval: parseInt(
process.env.CORE_TRANSITION_CLEANUP_INTERVAL ?? String(60 * 1000)
),
lossThreshold: parseInt(process.env.CORE_LOSS_THRESHOLD ?? String(8 * 1000)),
userTTL: parseInt(process.env.CORE_USER_TTL ?? String(3 * 60 * 1000)),
verifyTimeout: parseInt(process.env.CORE_VERIFY_TIMEOUT ?? String(5 * 60 * 1000)),
cameraInterval: parseInt(process.env.CORE_CAMERA_INTERVAL ?? String(5 * 60 * 1000)),
},
}
};
export default config;
+77 -51
View File
@@ -3,31 +3,45 @@ 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";
import { getInflux } from "../influx/influx.js";
import { Point } from "@influxdata/influxdb-client";
const adjectives = [
"Crazy",
"Silent",
"Dark",
"Fast",
"Lucky",
"Wild",
"Epic"
];
const nouns = [
"Tiger",
"Wolf",
"Falcon",
"Shadow",
"Ninja",
"Dragon",
"Phoenix"
];
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>();
async function writeRoomEvent(
event: "enter" | "leave",
pseudoID: string,
roomID: string,
rssi: number
) {
try {
const room = await prisma.room.findUnique({
where: { id: roomID },
select: { name: true },
});
const roomName = room?.name ?? roomID;
const point = new Point("room_events")
.tag("pseudo_id", pseudoID)
.tag("room_id", roomID)
.tag("room_name", roomName)
.tag("event", event)
.floatField("rssi", rssi);
getInflux().writePoint(point);
console.log(`[InfluxDB] Wrote '${event}' event for pseudo ${pseudoID} in room ${roomName}`);
} catch (err) {
console.error("[InfluxDB] Failed to write event:", err);
}
}
export async function updateRoomOccupancyListener(message: string, channel: string) {
console.log(`[Redis] Key ${message} expired.`);
@@ -37,10 +51,11 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
const redis = getRedis();
if (!lastRoomID) {
console.log("[!] Key not found in local map")
} else {
if (!lastRoomID) console.log("[!] Key not found in local map");
else {
await redis.decr(`room:${lastRoomID}`);
if (userPseudoID) await writeRoomEvent("leave", userPseudoID, lastRoomID, 0);
}
await redis.del(`_user:${userPseudoID}`);
@@ -50,39 +65,39 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
}
function generateNickname() {
const adjective =
adjectives[Math.floor(Math.random() * adjectives.length)];
const noun =
nouns[Math.floor(Math.random() * nouns.length)];
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 } {
function newUser(): { pseudoID: string; psuedoName: string } {
return {
pseudoID: crypto.randomUUID(),
psuedoName: generateNickname()
psuedoName: generateNickname(),
};
}
export async function analyzeData(roomID: string, metrics: {
tagID: string,
rssi: number
}) {
export async function analyzeData(
roomID: string,
metrics: {
tagID: string;
rssi: number;
}
) {
if (running.has(metrics.tagID)) {
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`);
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} ROOM_ID: ${roomID} already in process.`);
return;
}
running.add(metrics.tagID);
try {
console.log(`\n=======================================================\n` +
console.log(
`\n=======================================================\n` +
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
`=======================================================\n`);
`=======================================================\n`
);
const userID = (
await prisma.tag.findUnique({
@@ -93,8 +108,7 @@ export async function analyzeData(roomID: string, metrics: {
console.log(`[SQLite] Resolved USER_ID: ${userID}`);
if (!userID)
return;
if (!userID) return;
const redis = getRedis();
const res = await redis.get(`user:${userID}`);
@@ -103,24 +117,28 @@ export async function analyzeData(roomID: string, metrics: {
const user = newUser();
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
await redis.set(`user:${userID}`, JSON.stringify({
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 });
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.");
await writeRoomEvent("enter", user.pseudoID, roomID, metrics.rssi);
console.log("[Redis] Stored new user in redis.");
return;
}
@@ -130,10 +148,9 @@ export async function analyzeData(roomID: string, metrics: {
if (roomID == resObj["room"]) {
console.log("[Redis] Same room, updating redis...");
resObj["rssi"] = metrics.rssi;
resObj["timestamp"] = Date.now()
resObj["timestamp"] = Date.now();
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
return;
}
@@ -145,22 +162,31 @@ export async function analyzeData(roomID: string, metrics: {
transitions.set(userID, transition);
}
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) {
if (
transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])
) {
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
await redis.decr(`room:${resObj["room"]}`);
const previousRoomID = resObj["room"];
const pseudoID = resObj["userID"];
await redis.decr(`room:${previousRoomID}`);
await redis.incr(`room:${roomID}`);
userRooms.set(userID, roomID);
resObj["rssi"] = metrics.rssi;
resObj["room"] = roomID;
resObj["timestamp"] = Date.now()
resObj["timestamp"] = Date.now();
resObj["verified"] = false;
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
} else
await writeRoomEvent("leave", pseudoID, previousRoomID, metrics.rssi);
await writeRoomEvent("enter", pseudoID, roomID, metrics.rssi);
} else {
console.log("[!] Transition declined");
}
} finally {
running.delete(metrics.tagID);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { initRedis, initSubRedis } from "../redis/redis.js";
import { initCamera } from "./camera.js";
import * as mqtt from "./mqtt.js";
import { cleanupWorker } from "./transition.js";
import { initInflux } from "../influx/influx.js";
export async function bootstrap() {
try {
@@ -12,12 +13,13 @@ export async function bootstrap() {
console.log(`[!] Flushed Redis.`);
const rooms = await getRoomIDs();
for (const { id } of rooms)
await redis.set(`room:${id}`, 0);
for (const { id } of rooms) await redis.set(`room:${id}`, 0);
await initSubRedis();
await mqtt.start();
initInflux();
cleanupWorker();
await initCamera();
+81 -75
View File
@@ -1,43 +1,47 @@
import { fileURLToPath } from 'url'
import path from 'path'
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';
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')
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const execPath = "../../bin";
const binaryPath = path.join(__dirname, execPath, "cameraSession");
type InputPacket = {
uuid: string
descriptor: number[]
}
uuid: string;
descriptor: number[];
};
type ResultPacket = {
uuid: string
verified: boolean
}
uuid: string;
verified: boolean;
};
export async function getUnverifiedUserKeys(): Promise<string[]> {
const redis = getRedis()
const redis = getRedis();
const keys = await redis.keys('user:*')
const keys = await redis.keys("user:*");
const unverified: string[] = []
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:', ''))
}))
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
return unverified;
}
export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string, descriptor: number[] }[]> {
const userIds = await getUnverifiedUserKeys()
export async function getUnverifiedUsersWithEmbeddings(): Promise<
{ uuid: string; descriptor: number[] }[]
> {
const userIds = await getUnverifiedUserKeys();
const users = await prisma.user.findMany({
where: {
@@ -45,88 +49,90 @@ export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string
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 = ''
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.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("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}`))
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)
const results: ResultPacket[] = JSON.parse(trimmed);
resolve(results);
} catch (e) {
reject(new Error(`Failed to parse output: ${trimmed}`))
reject(new Error(`Failed to parse output: ${trimmed}`));
}
})
});
for (const packet of packets) {
proc.stdin.write(JSON.stringify(packet) + '\n')
proc.stdin.write(JSON.stringify(packet) + "\n");
}
proc.stdin.end()
})
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 redis = getRedis();
const key = `user:${uuid}`;
const raw = await redis.get(key);
if (!raw) return;
const session = JSON.parse(raw);
session.verified = false;
await redis.set(key, JSON.stringify(session), { KEEPTTL: true, XX: true });
console.log(`[Camera] Unverified user ${uuid} due to timeout`);
};
const run = async () => {
const redis = getRedis();
try {
const packets = await getUnverifiedUsersWithEmbeddings()
const packets = await getUnverifiedUsersWithEmbeddings();
if (packets.length > 0) {
const results = await cameraSession(packets)
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))
await Promise.all(
results.map(async (r) => {
if (!r.verified) return;
const key = `user:${r.uuid}`;
const raw = await redis.get(key);
if (!raw) return;
const session = JSON.parse(raw);
session.verified = true;
await redis.set(key, JSON.stringify(session), { KEEPTTL: true, XX: true });
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout)
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout);
console.log(`[Camera] Verified user ${r.uuid}`)
}))
console.log(`[Camera] Verified user ${r.uuid}`);
})
);
}
}
} catch (e) {
console.error('camera run error:', e)
console.error("camera run error:", e);
}
setTimeout(run, 1000)
}
setTimeout(run, config.core.cameraInterval);
};
export { run as initCamera };
+4 -7
View File
@@ -4,13 +4,10 @@ import { analyzeData } from "./analyze.js";
function initMqtt(): Promise<MqttClient> {
return new Promise((resolve, reject) => {
const client = mqtt.connect(
`mqtt://${config.mqtt.host}:${config.mqtt.port}`,
{
const client = mqtt.connect(`mqtt://${config.mqtt.host}:${config.mqtt.port}`, {
username: config.mqtt.username,
password: config.mqtt.password
}
);
password: config.mqtt.password,
});
client.once("connect", () => {
console.log("[MQTT] MQTT connected.");
@@ -41,7 +38,7 @@ export async function start() {
mqttClient.on("error", (err) => {
console.log(err.message);
})
});
} catch (err: any) {
console.log("[MQTT] MQTT connection failed:", err.message);
}
+15 -7
View File
@@ -9,8 +9,7 @@ export function cleanupWorker() {
for (const [userID, transition] of transitions) {
const inactiveFor = now - transition.lastSeen;
if (inactiveFor > 5 * config.core.transitionTTL)
transitions.delete(userID);
if (inactiveFor > 5 * config.core.transitionTTL) transitions.delete(userID);
}
}, config.core.transitionCleanupInterval);
}
@@ -31,16 +30,20 @@ export class RoomTransition {
this.candidate = { roomID: null, since: null, samples: 0, rssi: null };
}
shouldTransitionTo(roomID: string, candidateRSSI: number, currRSSI: number, lastCurrentSeen: number) {
shouldTransitionTo(
roomID: string,
candidateRSSI: number,
currRSSI: number,
lastCurrentSeen: number
) {
const now = Date.now();
this.lastSeen = now;
const stronger = candidateRSSI > currRSSI + config.core.hysteresis;
const signalLost = now - lastCurrentSeen > config.core.lossThreshold;
console.log(`[TRANSITION] stronger = ${stronger}`);
console.log(`[TRANSITION] signalLost = ${signalLost}`);
console.log(`[TRANSITION] signalLost = ${signalLost} (prev=${lastCurrentSeen} now=${now})`);
if (!stronger && !signalLost) {
this.reset();
@@ -48,7 +51,8 @@ export class RoomTransition {
}
const noCandidate = this.candidate.roomID == null;
const betterCandidate = this.candidate.roomID != roomID &&
const betterCandidate =
this.candidate.roomID != roomID &&
this.candidate.rssi !== null &&
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
@@ -77,7 +81,11 @@ export class RoomTransition {
console.log(`[TRANSITION] enoughConfirmations = ${enoughConfirmations}`);
console.log(`[TRANSITION] signalLost = ${signalLost}`);
if ((enoughTime && enoughConfirmations) || signalLost) {
if (enoughTime && enoughConfirmations) {
this.reset();
return true;
}
if (signalLost && this.candidate.roomID !== null && enoughConfirmations) {
this.reset();
return true;
}
+5 -5
View File
@@ -1,8 +1,8 @@
import express from "express";
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 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";
@@ -16,6 +16,7 @@ import { bootstrap } from "./core/bootstrap.js";
app.use("/lookup", lookupRouter);
app.use("/livedata", livedataRouter);
app.use("/admin", mapperRouter);
app.use("/admin/frontend/assets", express.static("/app/.adminjs"));
app.use("/admin", adminRouter);
app.listen(config.api.port, config.api.address, () => {
@@ -23,5 +24,4 @@ import { bootstrap } from "./core/bootstrap.js";
});
bootstrap();
})()
})();
+22
View File
@@ -0,0 +1,22 @@
import { InfluxDB, type WriteApi } from "@influxdata/influxdb-client";
import config from "../config/config.js";
let writeApi: WriteApi | null = null;
export function initInflux(): void {
const client = new InfluxDB({
url: config.influx.url,
token: config.influx.token,
});
writeApi = client.getWriteApi(config.influx.org, config.influx.bucket, "ms");
writeApi.useDefaultTags({ host: "officesense-pi" });
console.log("[InfluxDB] Write API initialized.");
}
export function getInflux(): WriteApi {
if (!writeApi) throw new Error("[InfluxDB] Not initialized. Call initInflux() first.");
return writeApi;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client'
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient()
const prisma = new PrismaClient();
export { prisma };
+10 -6
View File
@@ -5,10 +5,16 @@ import { updateRoomOccupancyListener } from "../core/analyze.js";
let redisClient: RedisClientType;
let subRedisClient: RedisClientType;
const redisOptions = {
socket: {
host: config.redis.host,
port: config.redis.port,
},
password: config.redis.password,
};
export async function initSubRedis(): Promise<void> {
subRedisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}`
});
subRedisClient = createClient(redisOptions);
subRedisClient.on("error", (err) => {
console.log("[Redis] SubRedis error:", err.message);
@@ -24,9 +30,7 @@ export async function initSubRedis(): Promise<void> {
}
export async function initRedis(): Promise<RedisClientType> {
redisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}`
});
redisClient = createClient(redisOptions);
redisClient.on("error", (err) => {
console.log("[Redis] Redis error:", err.message);
+93 -1
View File
@@ -128,6 +128,11 @@ void mqttTask(void *)
{
while (!mqtt.connected())
{
if (WiFi.status() != WL_CONNECTED)
{
vTaskDelay(pdMS_TO_TICKS(1000));
continue;
}
// Serial.printf("MQTT Disconnected. Connecting to %s:%d\n", config.mqtt.host.c_str(), config.mqtt.port);
if (mqtt.connect(config.dev_name.c_str(), config.mqtt.username.c_str(), config.mqtt.password.c_str()))
Serial.println("MQTT connected.");
@@ -253,6 +258,9 @@ void saveConfig()
prefs.putString(WIFI_SSID_CONFIG_KEY, config.wifi.ssid);
prefs.putString(WIFI_PASS_CONFIG_KEY, config.wifi.password);
prefs.putString(WIFI_IP_CONFIG_KEY, config.wifi.ip);
prefs.putString(WIFI_GW_CONFIG_KEY, config.wifi.gateway);
prefs.putString(WIFI_SUBNET_CONFIG_KEY, config.wifi.subnet);
prefs.putString(DEV_NAME_CONFIG_KEY, config.dev_name);
prefs.putString(TAG_NAME_CONFIG_KEY, config.tag_name);
@@ -280,6 +288,9 @@ void loadConfig()
config.wifi.ssid = prefs.getString(WIFI_SSID_CONFIG_KEY, WIFI_SSID);
config.wifi.password = prefs.getString(WIFI_PASS_CONFIG_KEY, WIFI_PASS);
config.wifi.ip = prefs.getString(WIFI_IP_CONFIG_KEY, WIFI_IP);
config.wifi.gateway = prefs.getString(WIFI_GW_CONFIG_KEY, WIFI_GW);
config.wifi.subnet = prefs.getString(WIFI_SUBNET_CONFIG_KEY, WIFI_SUBNET);
config.dev_name = prefs.getString(DEV_NAME_CONFIG_KEY, DEV_NAME);
config.tag_name = prefs.getString(TAG_NAME_CONFIG_KEY, TAG_NAME);
@@ -324,6 +335,12 @@ void handleCommand(String line)
config.wifi.ssid = value;
else if (key == WIFI_PASS_CONFIG_KEY)
config.wifi.password = value;
else if (key == WIFI_IP_CONFIG_KEY)
config.wifi.ip = value;
else if (key == WIFI_GW_CONFIG_KEY)
config.wifi.gateway = value;
else if (key == WIFI_SUBNET_CONFIG_KEY)
config.wifi.subnet = value;
else if (key == DEV_NAME_CONFIG_KEY)
config.dev_name = value;
else if (key == TAG_NAME_CONFIG_KEY)
@@ -364,6 +381,12 @@ void handleCommand(String line)
Serial.println(config.wifi.ssid);
else if (key == WIFI_PASS_CONFIG_KEY)
Serial.println(config.wifi.password);
else if (key == WIFI_IP_CONFIG_KEY)
Serial.println(config.wifi.ip);
else if (key == WIFI_GW_CONFIG_KEY)
Serial.println(config.wifi.gateway);
else if (key == WIFI_SUBNET_CONFIG_KEY)
Serial.println(config.wifi.subnet);
else if (key == DEV_NAME_CONFIG_KEY)
Serial.println(config.dev_name);
else if (key == TAG_NAME_CONFIG_KEY)
@@ -388,6 +411,25 @@ void handleCommand(String line)
Serial.println("Commands:");
Serial.println(" set <key> <value> - Set a configuration value");
Serial.println(" show <key> - Show a configuration value");
Serial.println(" <key>:");
Serial.println(" int.cln");
Serial.println(" int.tagto");
Serial.println(" int.urtry");
Serial.println(" int.ucclean");
Serial.println(" api.url");
Serial.println(" api.uuidchk");
Serial.println(" mq.host");
Serial.println(" mq.port");
Serial.println(" mq.user");
Serial.println(" mq.pass");
Serial.println(" mq.topic");
Serial.println(" wf.ssid");
Serial.println(" wf.pass");
Serial.println(" wf.ip");
Serial.println(" wf.gw");
Serial.println(" wf.subnet");
Serial.println(" dev.name");
Serial.println(" tag.name");
Serial.println(" save - Save configuration to non-volatile storage");
Serial.println(" reset - Reset configuration to defaults");
Serial.println(" reboot - Reboot the device");
@@ -395,13 +437,47 @@ void handleCommand(String line)
}
}
void wifiBegin()
{
WiFi.mode(WIFI_STA);
WiFi.disconnect();
delay(100);
IPAddress local_ip, gateway, subnet;
local_ip.fromString(config.wifi.ip);
if (local_ip != IPAddress(0, 0, 0, 0))
{
gateway.fromString(config.wifi.gateway);
subnet.fromString(config.wifi.subnet);
WiFi.config(local_ip, gateway, subnet);
}
WiFi.begin(config.wifi.ssid.c_str(), config.wifi.password.c_str());
}
void setup()
{
Serial.begin(115200);
loadConfig();
WiFi.begin(config.wifi.ssid.c_str(), config.wifi.password.c_str());
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info)
{
switch (event)
{
case ARDUINO_EVENT_WIFI_STA_CONNECTED:
Serial.println("WiFi connected.");
break;
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
Serial.printf("WiFi disconnected, reason: %d\n", info.wifi_sta_disconnected.reason);
break;
} });
wifiBegin();
MQTT_PUB_TOPIC = config.mqtt.topic + config.dev_name;
@@ -432,6 +508,22 @@ void setup()
void loop()
{
BleEvent ev;
static uint32_t last_wifi_check = 0;
if (millis() - last_wifi_check >= WIFI_TEST_INTERVAL)
{
last_wifi_check = millis();
wl_status_t status = WiFi.status();
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL)
{
Serial.printf("WiFi failed (status %d), retrying...\n", status);
WiFi.disconnect();
delay(100);
wifiBegin();
}
}
while (xQueueReceive(ble_queue, &ev, 0))
processEvent(ev);
+11 -1
View File
@@ -18,6 +18,9 @@
#define MQTT_TOPIC_CONFIG_KEY "mq.topic"
#define WIFI_SSID_CONFIG_KEY "wf.ssid"
#define WIFI_PASS_CONFIG_KEY "wf.pass"
#define WIFI_IP_CONFIG_KEY "wf.ip"
#define WIFI_GW_CONFIG_KEY "wf.gw"
#define WIFI_SUBNET_CONFIG_KEY "wf.subnet"
#define DEV_NAME_CONFIG_KEY "dev.name"
#define TAG_NAME_CONFIG_KEY "tag.name"
@@ -26,7 +29,7 @@
#define UUID_CACHE_RETRY_TIMEOUT 5 * 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 UUID_CHECK_ENDPOINT "/lookup/check"
#define TAG_NAME "X6TAG"
#define MQTT_HOST "192.168.1.2"
#define MQTT_PORT 1883
@@ -35,7 +38,11 @@
#define MQTT_TOPIC "scanners/"
#define WIFI_SSID "COSMOTE-489882"
#define WIFI_PASS "x32hbh54673ngccdsfa9"
#define WIFI_IP "0.0.0.0"
#define WIFI_GW "0.0.0.0"
#define WIFI_SUBNET "255.255.255.0"
#define DEV_NAME "572b29cb-6a93-480e-adf0-c5c44e9a58df"
#define WIFI_TEST_INTERVAL 5 * 1000 // ms
struct Config
{
@@ -66,6 +73,9 @@ struct Config
{
String ssid;
String password;
String ip;
String gateway;
String subnet;
} wifi;
String dev_name;
+13 -7
View File
@@ -5,6 +5,7 @@
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "driver/usb_serial_jtag.h"
#include "driver/gpio.h"
#include "main.h"
static config_t config;
@@ -36,8 +37,8 @@ static void start_adv(void)
params.conn_mode = BLE_GAP_CONN_MODE_NON;
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
params.itvl_min = 0x80; // ~100ms
params.itvl_max = 0x80;
params.itvl_min = config.adv_interval;
params.itvl_max = config.adv_interval;
rc = ble_gap_adv_start(
BLE_ADDR_PUBLIC,
@@ -54,6 +55,7 @@ static void start_adv(void)
else
{
ESP_LOGI(config.name, "advertising started");
gpio_set_level(GPIO_NUM_15, 0);
}
}
@@ -139,7 +141,7 @@ static esp_err_t save_config()
if (err != ESP_OK)
goto exit;
err = nvs_set_u32(nvs, "adv_int", config.adv_interval);
err = nvs_set_u16(nvs, "adv_int", config.adv_interval);
if (err != ESP_OK)
goto exit;
@@ -177,7 +179,7 @@ static esp_err_t load_config()
nvs_get_str(nvs, "name", config.name, &len);
nvs_get_u32(nvs, "adv_int", &config.adv_interval);
nvs_get_u16(nvs, "adv_int", &config.adv_interval);
nvs_close(nvs);
@@ -219,9 +221,9 @@ void serial_task(void *arg)
}
else if (strncmp(line, "set adv_interval ", 17) == 0)
{
uint32_t interval = atoi(line + 17);
float interval = atof(line + 17);
if (interval > 0)
config.adv_interval = interval;
config.adv_interval = (uint16_t)(interval / 0.625f);
}
else if (strcmp(line, "show uuid") == 0)
{
@@ -235,7 +237,7 @@ void serial_task(void *arg)
}
else if (strcmp(line, "show adv_interval") == 0)
{
int len = snprintf(buf, sizeof(buf), "%lu\n", (unsigned long)config.adv_interval);
int len = snprintf(buf, sizeof(buf), "%.3f\n", config.adv_interval * 0.625f);
usb_serial_jtag_write_bytes(buf, len, 20 / portTICK_PERIOD_MS);
}
else if (strcmp(line, "save") == 0)
@@ -293,6 +295,10 @@ void serial_task(void *arg)
void app_main(void)
{
gpio_reset_pin(GPIO_NUM_15);
gpio_set_direction(GPIO_NUM_15, GPIO_MODE_OUTPUT);
gpio_set_level(GPIO_NUM_15, 1);
ESP_ERROR_CHECK(nvs_flash_init());
usb_serial_jtag_driver_config_t cfg = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT();
+2 -2
View File
@@ -5,14 +5,14 @@
#define DEFAULT_UUID "12345678-9abc-def0-1234-56789abcdef0"
#define DEFAULT_NAME "X6TAG"
#define DEFAULT_ADV_INTERVAL 0x80
#define DEFAULT_ADV_INTERVAL 800
typedef struct
{
ble_uuid128_t uuid;
char uuid_str[37];
char name[32];
uint32_t adv_interval;
uint16_t adv_interval;
} config_t;
#endif