diff --git a/src/officesense_pi/src/config/config.ts b/src/officesense_pi/src/config/config.ts new file mode 100644 index 0000000..f45d078 --- /dev/null +++ b/src/officesense_pi/src/config/config.ts @@ -0,0 +1,57 @@ +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; + }; +} + +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 + }, +} + +export default config; \ No newline at end of file diff --git a/src/officesense_pi/src/core/analyze.ts b/src/officesense_pi/src/core/analyze.ts new file mode 100644 index 0000000..4462be7 --- /dev/null +++ b/src/officesense_pi/src/core/analyze.ts @@ -0,0 +1,122 @@ +import { getRedis } from "./redis.js"; +import { prisma } from "../lib/prisma.js"; +import { RoomTransition, transitions } from "./transition.js"; +import config from "../config/config.js"; + +const adjectives = [ + "Crazy", + "Silent", + "Dark", + "Fast", + "Lucky", + "Wild", + "Epic" +]; + +const nouns = [ + "Tiger", + "Wolf", + "Falcon", + "Shadow", + "Ninja", + "Dragon", + "Phoenix" +]; + +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 +}) { + 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}`); + + // trigger camera + + await redis.set(`user:${userID}`, JSON.stringify({ + userID: user.pseudoID, + name: user.psuedoName, + rssi: metrics.rssi, + room: roomID, + verified: false, + timestamp: Date.now() + }), { PX: config.core.userTTL }); + + console.log("[Redis] Stored new user in redis."); + + return; + } + + let resObj = 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"])) { + // trigger camera + + console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`); + + resObj["rssi"] = metrics.rssi; + resObj["room"] = roomID; + resObj["timestamp"] = Date.now() + + + await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL }); + } else + console.log("[!] Transition declined"); +} \ No newline at end of file diff --git a/src/officesense_pi/src/core/bootstrap.ts b/src/officesense_pi/src/core/bootstrap.ts new file mode 100644 index 0000000..a089428 --- /dev/null +++ b/src/officesense_pi/src/core/bootstrap.ts @@ -0,0 +1,16 @@ +import { initRedis } from "./redis.js"; +import * as mqtt from "./mqtt.js"; +import { cleanupWorker } from "./transition.js"; + +export async function bootstrap() { + try { + await initRedis(); + await mqtt.start(); + cleanupWorker(); + + console.log("Core started."); + } catch (err: any) { + console.log("Bootstrap failed:", err.message); + process.exit(1); + } +} \ No newline at end of file diff --git a/src/officesense_pi/src/core/mqtt.ts b/src/officesense_pi/src/core/mqtt.ts new file mode 100644 index 0000000..11fe6a3 --- /dev/null +++ b/src/officesense_pi/src/core/mqtt.ts @@ -0,0 +1,48 @@ +import mqtt, { MqttClient } from "mqtt"; +import config from "../config/config.js"; +import { analyzeData } from "./analyze.js"; + +function initMqtt(): Promise { + 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 connected."); + + client.subscribe(config.mqtt.topic, (err) => { + if (err) { + reject(err); + } else { + console.log("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 connection failed:", err.message); + } +} diff --git a/src/officesense_pi/src/core/redis.ts b/src/officesense_pi/src/core/redis.ts new file mode 100644 index 0000000..c9b1944 --- /dev/null +++ b/src/officesense_pi/src/core/redis.ts @@ -0,0 +1,28 @@ +import { createClient, type RedisClientType } from "redis"; +import config from "../config/config.js"; + +let redisClient: RedisClientType; + +export async function initRedis(): Promise { + redisClient = createClient({ + url: `redis://${config.redis.host}:${config.redis.port}` + }); + + redisClient.on("error", (err) => { + console.log("Redis error:", err.message); + }); + + await redisClient.connect(); + + console.log("Redis connected."); + + return redisClient; +} + +export function getRedis(): RedisClientType { + if (!redisClient) { + throw new Error("Redis not initialized"); + } + + return redisClient; +} \ No newline at end of file diff --git a/src/officesense_pi/src/core/transition.ts b/src/officesense_pi/src/core/transition.ts new file mode 100644 index 0000000..f7c33d0 --- /dev/null +++ b/src/officesense_pi/src/core/transition.ts @@ -0,0 +1,94 @@ +import config from "../config/config.js"; + +export const transitions = new Map(); + +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; + } +} \ No newline at end of file diff --git a/src/officesense_pi/src/index.ts b/src/officesense_pi/src/index.ts index 8844cdd..e0e57d8 100644 --- a/src/officesense_pi/src/index.ts +++ b/src/officesense_pi/src/index.ts @@ -1,12 +1,17 @@ import express from "express"; import lookupRoutes from "./api/lookup/lookup.routes.js"; +import config from "./config/config.js"; +import { bootstrap } from "./core/bootstrap.js"; -const app = express(); -const PORT = 80; -const ADDRESS = "0.0.0.0"; +(async () => { + const app = express(); -app.use("/", lookupRoutes); + app.use("/", lookupRoutes); -app.listen(PORT, ADDRESS, () => { - console.log(`Server listening on ${ADDRESS}:${PORT}`); -}); + app.listen(config.api.port, config.api.address, () => { + console.log(`Server listening on ${config.api.address}:${config.api.port}`); + }); + + bootstrap(); + +})()