Move meshtastic mqtt project to meshcore usb project
This commit is contained in:
+23
-59
@@ -1,23 +1,12 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { userQueries, activityLogQueries } = require('../database/queries');
|
||||
const logger = require('../utils/logger');
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { userQueries, activityLogQueries } from '../database/queries.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Hash password
|
||||
async function hashPassword(password) {
|
||||
export async function createUser(username, password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
async function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Create user
|
||||
async function createUser(username, password) {
|
||||
const hash = await bcrypt.hash(password, salt);
|
||||
try {
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const result = userQueries.createUser.run(username, hashedPassword);
|
||||
const result = userQueries.createUser.run(username, hash);
|
||||
logger.info(`User created: ${username}`);
|
||||
return { id: result.lastInsertRowid, username };
|
||||
} catch (error) {
|
||||
@@ -28,39 +17,24 @@ async function createUser(username, password) {
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
async function authenticateUser(username, password) {
|
||||
try {
|
||||
const user = userQueries.getUserByUsername.get(username);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`Failed login attempt for username: ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn(`Invalid password for username: ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last login
|
||||
userQueries.updateLastLogin.run(user.id);
|
||||
|
||||
logger.info(`User logged in: ${username}`);
|
||||
|
||||
// Return user without password hash
|
||||
const { password_hash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
} catch (error) {
|
||||
logger.error('Error authenticating user:', error);
|
||||
throw error;
|
||||
export async function authenticateUser(username, password) {
|
||||
const user = userQueries.getUserByUsername.get(username);
|
||||
if (!user) {
|
||||
logger.warn(`Failed login attempt for username: ${username}`);
|
||||
return null;
|
||||
}
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
logger.warn(`Invalid password for username: ${username}`);
|
||||
return null;
|
||||
}
|
||||
userQueries.updateLastLogin.run(user.id);
|
||||
logger.info(`User logged in: ${username}`);
|
||||
const { password_hash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
// Middleware to check if user is authenticated
|
||||
function requireAuth(req, res, next) {
|
||||
export function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
next();
|
||||
} else {
|
||||
@@ -68,20 +42,10 @@ function requireAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Log activity
|
||||
function logActivity(userId, action, details = null, ipAddress = null) {
|
||||
export function logActivity(userId, action, details = null, ipAddress = null) {
|
||||
try {
|
||||
activityLogQueries.logActivity.run(userId, action, details, ipAddress);
|
||||
activityLogQueries.log.run(userId, action, details, ipAddress);
|
||||
} catch (error) {
|
||||
logger.error('Error logging activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createUser,
|
||||
authenticateUser,
|
||||
requireAuth,
|
||||
logActivity
|
||||
};
|
||||
|
||||
+6
-35
@@ -1,13 +1,11 @@
|
||||
require('dotenv').config();
|
||||
import 'dotenv/config';
|
||||
|
||||
module.exports = {
|
||||
// Server configuration
|
||||
export default {
|
||||
server: {
|
||||
port: process.env.PORT || 3000,
|
||||
nodeEnv: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
secret: process.env.SESSION_SECRET || 'change-this-secret',
|
||||
resave: false,
|
||||
@@ -15,52 +13,25 @@ module.exports = {
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
}
|
||||
},
|
||||
|
||||
// MQTT configuration
|
||||
mqtt: {
|
||||
broker: process.env.MQTT_BROKER || 'mqtts://mqtt.meshtastic.org:8883',
|
||||
port: 8883,
|
||||
username: process.env.MQTT_USERNAME || 'meshdev',
|
||||
password: process.env.MQTT_PASSWORD || 'large4cats',
|
||||
topic: process.env.MQTT_TOPIC || 'msh/US/#',
|
||||
pubTopic: process.env.MQTT_PUB_TOPIC || 'msh/US/2/json/mqtt/', // The ending / is apparently critical
|
||||
options: {
|
||||
clientId: `meshtastic-dashboard-${Math.random().toString(16).substr(2, 8)}`,
|
||||
clean: true,
|
||||
reconnectPeriod: 1000,
|
||||
connectTimeout: 30 * 1000
|
||||
}
|
||||
serial: {
|
||||
port: process.env.SERIAL_PORT || 'COM3'
|
||||
},
|
||||
|
||||
// Data retention configuration
|
||||
dataRetention: {
|
||||
days: parseInt(process.env.DATA_RETENTION_DAYS) || 30,
|
||||
purgeCronSchedule: process.env.PURGE_CRON_SCHEDULE || '0 2 * * *'
|
||||
},
|
||||
|
||||
// Rate limiting configuration
|
||||
rateLimit: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
|
||||
},
|
||||
|
||||
// Logging configuration
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info'
|
||||
},
|
||||
|
||||
// Channel configuration
|
||||
channels: {
|
||||
0: process.env.CHANNEL_0_NAME || 'LongFast',
|
||||
1: process.env.CHANNEL_1_NAME || 'Aether',
|
||||
2: process.env.CHANNEL_2_NAME || 'Channel 2',
|
||||
3: process.env.CHANNEL_3_NAME || 'Channel 3',
|
||||
4: process.env.CHANNEL_4_NAME || 'Channel 4',
|
||||
5: process.env.CHANNEL_5_NAME || 'Channel 5',
|
||||
6: process.env.CHANNEL_6_NAME || 'Channel 6',
|
||||
7: process.env.CHANNEL_7_NAME || 'Channel 7'
|
||||
}
|
||||
};
|
||||
|
||||
+38
-86
@@ -1,22 +1,19 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Ensure data directory exists
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'meshtastic.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
const db = new Database(path.join(dataDir, 'meshcore.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize database schema
|
||||
function initializeDatabase() {
|
||||
// Users table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -27,108 +24,64 @@ function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Nodes table - stores information about Meshtastic nodes
|
||||
// Contacts — MeshCore peers identified by their Ed25519 public key (hex)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT UNIQUE NOT NULL,
|
||||
short_name TEXT,
|
||||
long_name TEXT,
|
||||
hardware_model TEXT,
|
||||
role TEXT,
|
||||
firmware_version TEXT,
|
||||
last_heard DATETIME,
|
||||
battery_level INTEGER,
|
||||
voltage REAL,
|
||||
channel_utilization REAL,
|
||||
air_util_tx REAL,
|
||||
pubkey TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
contact_type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
path_length INTEGER DEFAULT 255,
|
||||
last_advert INTEGER,
|
||||
battery_mv INTEGER,
|
||||
uptime_seconds INTEGER,
|
||||
last_heard DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index on node_id
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_node_id ON nodes(node_id)
|
||||
`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_contacts_pubkey ON contacts(pubkey)`);
|
||||
|
||||
// Positions table - stores GPS position data
|
||||
// Positions — GPS from contact advertisements
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
altitude INTEGER,
|
||||
precision_bits INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
|
||||
FOREIGN KEY (pubkey) REFERENCES contacts(pubkey)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for positions
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_node_id ON positions(node_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_timestamp ON positions(timestamp)
|
||||
`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_positions_pubkey ON positions(pubkey)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_positions_timestamp ON positions(timestamp)`);
|
||||
|
||||
// Messages table - stores text messages
|
||||
// Messages — direct (msg_type=0) and channel (msg_type=1)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT,
|
||||
from_node TEXT NOT NULL,
|
||||
to_node TEXT,
|
||||
channel INTEGER,
|
||||
from_pubkey TEXT NOT NULL,
|
||||
to_pubkey TEXT,
|
||||
channel_idx INTEGER,
|
||||
msg_type INTEGER NOT NULL DEFAULT 0,
|
||||
text TEXT,
|
||||
rx_time DATETIME,
|
||||
rx_snr REAL,
|
||||
rx_rssi INTEGER,
|
||||
hop_limit INTEGER,
|
||||
want_ack BOOLEAN,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (from_node) REFERENCES nodes(node_id)
|
||||
snr REAL,
|
||||
path_length INTEGER,
|
||||
ack_hash TEXT,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
sender_ts INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for messages
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_from_node ON messages(from_node)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at)
|
||||
`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_pubkey)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at)`);
|
||||
|
||||
// Telemetry table - stores device telemetry data
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS telemetry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL,
|
||||
battery_level INTEGER,
|
||||
voltage REAL,
|
||||
channel_utilization REAL,
|
||||
air_util_tx REAL,
|
||||
uptime_seconds INTEGER,
|
||||
temperature REAL,
|
||||
relative_humidity REAL,
|
||||
barometric_pressure REAL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for telemetry
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry(timestamp)
|
||||
`);
|
||||
|
||||
// Activity log table
|
||||
// Activity log
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -144,7 +97,6 @@ function initializeDatabase() {
|
||||
console.log('Database initialized successfully');
|
||||
}
|
||||
|
||||
// Initialize the database
|
||||
initializeDatabase();
|
||||
|
||||
module.exports = db;
|
||||
export default db;
|
||||
|
||||
+97
-246
@@ -1,306 +1,157 @@
|
||||
const db = require('./db');
|
||||
import db from './db.js';
|
||||
|
||||
// User queries
|
||||
const userQueries = {
|
||||
createUser: db.prepare(`
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES (?, ?)
|
||||
`),
|
||||
|
||||
getUserByUsername: db.prepare(`
|
||||
SELECT * FROM users WHERE username = ?
|
||||
`),
|
||||
|
||||
updateLastLogin: db.prepare(`
|
||||
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`),
|
||||
|
||||
getAllUsers: db.prepare(`
|
||||
SELECT id, username, created_at, last_login FROM users
|
||||
`)
|
||||
export const userQueries = {
|
||||
createUser: db.prepare(`INSERT INTO users (username, password_hash) VALUES (?, ?)`),
|
||||
getUserByUsername: db.prepare(`SELECT * FROM users WHERE username = ?`),
|
||||
updateLastLogin: db.prepare(`UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?`),
|
||||
};
|
||||
|
||||
// Node queries
|
||||
const nodeQueries = {
|
||||
upsertNode: db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_id, short_name, long_name, hardware_model, role,
|
||||
firmware_version, last_heard, battery_level, voltage,
|
||||
channel_utilization, air_util_tx, uptime_seconds, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
short_name = COALESCE(excluded.short_name, short_name),
|
||||
long_name = COALESCE(excluded.long_name, long_name),
|
||||
hardware_model = COALESCE(excluded.hardware_model, hardware_model),
|
||||
role = COALESCE(excluded.role, role),
|
||||
firmware_version = COALESCE(excluded.firmware_version, firmware_version),
|
||||
export const contactQueries = {
|
||||
upsert: db.prepare(`
|
||||
INSERT INTO contacts (pubkey, name, contact_type, flags, path_length, last_advert, last_heard, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(pubkey) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, name),
|
||||
contact_type = COALESCE(excluded.contact_type, contact_type),
|
||||
flags = COALESCE(excluded.flags, flags),
|
||||
path_length = COALESCE(excluded.path_length, path_length),
|
||||
last_advert = COALESCE(excluded.last_advert, last_advert),
|
||||
last_heard = CURRENT_TIMESTAMP,
|
||||
battery_level = COALESCE(excluded.battery_level, battery_level),
|
||||
voltage = COALESCE(excluded.voltage, voltage),
|
||||
channel_utilization = COALESCE(excluded.channel_utilization, channel_utilization),
|
||||
air_util_tx = COALESCE(excluded.air_util_tx, air_util_tx),
|
||||
uptime_seconds = COALESCE(excluded.uptime_seconds, uptime_seconds),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`),
|
||||
|
||||
getNodeById: db.prepare(`
|
||||
updateStats: db.prepare(`
|
||||
UPDATE contacts SET battery_mv = ?, uptime_seconds = ?, last_heard = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE pubkey = ?
|
||||
`),
|
||||
|
||||
updateLastHeard: db.prepare(`
|
||||
UPDATE contacts SET last_heard = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE pubkey = ?
|
||||
`),
|
||||
|
||||
getAll: db.prepare(`
|
||||
SELECT
|
||||
n.*,
|
||||
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
|
||||
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||
FROM nodes n
|
||||
WHERE n.node_id = ?
|
||||
c.*,
|
||||
(SELECT latitude FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||
(SELECT longitude FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||
(SELECT timestamp FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||
FROM contacts c
|
||||
ORDER BY COALESCE(datetime(c.last_heard), datetime('1970-01-01')) DESC
|
||||
`),
|
||||
|
||||
getAllNodes: db.prepare(`
|
||||
getByPubkey: db.prepare(`
|
||||
SELECT
|
||||
n.*,
|
||||
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
|
||||
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||
FROM nodes n
|
||||
ORDER BY
|
||||
COALESCE(datetime(n.last_heard), datetime('1970-01-01')) DESC
|
||||
c.*,
|
||||
(SELECT latitude FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||
(SELECT longitude FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||
(SELECT timestamp FROM positions WHERE pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||
FROM contacts c WHERE c.pubkey = ?
|
||||
`),
|
||||
|
||||
updateNodeLastHeard: db.prepare(`
|
||||
UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ?
|
||||
deleteOld: db.prepare(`
|
||||
DELETE FROM contacts
|
||||
WHERE datetime(last_heard) < datetime('now', '-' || ? || ' hours')
|
||||
AND pubkey NOT IN (
|
||||
SELECT DISTINCT from_pubkey FROM messages
|
||||
WHERE datetime(created_at) >= datetime('now', '-' || ? || ' hours')
|
||||
)
|
||||
`),
|
||||
|
||||
updateNullLastHeard: db.prepare(`
|
||||
UPDATE nodes
|
||||
SET last_heard = COALESCE(
|
||||
(
|
||||
SELECT MAX(latest_time)
|
||||
FROM (
|
||||
SELECT MAX(created_at) as latest_time FROM messages WHERE from_node = nodes.node_id
|
||||
UNION ALL
|
||||
SELECT MAX(timestamp) as latest_time FROM positions WHERE node_id = nodes.node_id
|
||||
UNION ALL
|
||||
SELECT MAX(timestamp) as latest_time FROM telemetry WHERE node_id = nodes.node_id
|
||||
)
|
||||
),
|
||||
created_at
|
||||
)
|
||||
WHERE last_heard IS NULL
|
||||
`),
|
||||
|
||||
getOldNodesWithData: db.prepare(`
|
||||
SELECT
|
||||
n.node_id,
|
||||
n.last_heard,
|
||||
(SELECT COUNT(*) FROM messages WHERE from_node = n.node_id) as message_count,
|
||||
(SELECT COUNT(*) FROM positions WHERE node_id = n.node_id) as position_count,
|
||||
(SELECT COUNT(*) FROM telemetry WHERE node_id = n.node_id) as telemetry_count
|
||||
FROM nodes n
|
||||
WHERE datetime(last_heard) < datetime('now', '-' || ? || ' hours') OR last_heard IS NULL
|
||||
`),
|
||||
|
||||
deleteOldNodes: db.prepare(`
|
||||
DELETE FROM nodes
|
||||
WHERE (
|
||||
datetime(last_heard) < datetime('now', '-' || ? || ' hours')
|
||||
OR (
|
||||
last_heard IS NULL
|
||||
AND node_id NOT IN (
|
||||
SELECT DISTINCT from_node FROM messages WHERE from_node IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT node_id FROM positions WHERE node_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT node_id FROM telemetry WHERE node_id IS NOT NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
AND node_id NOT IN (
|
||||
SELECT DISTINCT from_node FROM messages
|
||||
WHERE from_node IS NOT NULL
|
||||
AND datetime(created_at) >= datetime('now', '-' || ? || ' hours')
|
||||
)
|
||||
AND node_id NOT IN (
|
||||
SELECT DISTINCT node_id FROM positions
|
||||
WHERE node_id IS NOT NULL
|
||||
AND datetime(timestamp) >= datetime('now', '-' || ? || ' hours')
|
||||
)
|
||||
AND node_id NOT IN (
|
||||
SELECT DISTINCT node_id FROM telemetry
|
||||
WHERE node_id IS NOT NULL
|
||||
AND datetime(timestamp) >= datetime('now', '-' || ? || ' hours')
|
||||
)
|
||||
`)
|
||||
};
|
||||
|
||||
// Position queries
|
||||
const positionQueries = {
|
||||
insertPosition: db.prepare(`
|
||||
INSERT INTO positions (node_id, latitude, longitude, altitude, precision_bits)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
export const positionQueries = {
|
||||
insert: db.prepare(`
|
||||
INSERT INTO positions (pubkey, latitude, longitude, altitude) VALUES (?, ?, ?, ?)
|
||||
`),
|
||||
|
||||
getLatestPositions: db.prepare(`
|
||||
SELECT p.*, n.short_name, n.long_name, n.last_heard
|
||||
getLatest: db.prepare(`
|
||||
SELECT p.*, c.name, c.contact_type, c.last_heard
|
||||
FROM positions p
|
||||
LEFT JOIN nodes n ON p.node_id = n.node_id
|
||||
WHERE p.id IN (
|
||||
SELECT MAX(id) FROM positions GROUP BY node_id
|
||||
)
|
||||
LEFT JOIN contacts c ON p.pubkey = c.pubkey
|
||||
WHERE p.id IN (SELECT MAX(id) FROM positions GROUP BY pubkey)
|
||||
ORDER BY p.timestamp DESC
|
||||
`),
|
||||
|
||||
getPositionsByNode: db.prepare(`
|
||||
SELECT * FROM positions
|
||||
WHERE node_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
getByPubkey: db.prepare(`
|
||||
SELECT * FROM positions WHERE pubkey = ? ORDER BY timestamp DESC LIMIT ?
|
||||
`),
|
||||
|
||||
deleteOldPositions: db.prepare(`
|
||||
DELETE FROM positions WHERE timestamp < datetime('now', '-' || ? || ' hours')
|
||||
`),
|
||||
|
||||
getPositionTrails: db.prepare(`
|
||||
SELECT
|
||||
node_id,
|
||||
latitude,
|
||||
longitude,
|
||||
altitude,
|
||||
timestamp,
|
||||
id
|
||||
getTrails: db.prepare(`
|
||||
SELECT pubkey, latitude, longitude, altitude, timestamp, id
|
||||
FROM (
|
||||
SELECT
|
||||
node_id,
|
||||
latitude,
|
||||
longitude,
|
||||
altitude,
|
||||
timestamp,
|
||||
id,
|
||||
ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY timestamp DESC) as rn
|
||||
SELECT pubkey, latitude, longitude, altitude, timestamp, id,
|
||||
ROW_NUMBER() OVER (PARTITION BY pubkey ORDER BY timestamp DESC) as rn
|
||||
FROM positions
|
||||
) AS ranked
|
||||
WHERE rn <= ?
|
||||
ORDER BY node_id, timestamp DESC
|
||||
`)
|
||||
ORDER BY pubkey, timestamp DESC
|
||||
`),
|
||||
|
||||
deleteOld: db.prepare(`DELETE FROM positions WHERE timestamp < datetime('now', '-' || ? || ' hours')`),
|
||||
};
|
||||
|
||||
// Message queries
|
||||
const messageQueries = {
|
||||
insertMessage: db.prepare(`
|
||||
INSERT INTO messages (
|
||||
message_id, from_node, to_node, channel, text,
|
||||
rx_time, rx_snr, rx_rssi, hop_limit, want_ack
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
export const messageQueries = {
|
||||
insert: db.prepare(`
|
||||
INSERT INTO messages (from_pubkey, to_pubkey, channel_idx, msg_type, text, snr, path_length, ack_hash, sender_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
|
||||
getRecentMessages: db.prepare(`
|
||||
SELECT m.*,
|
||||
n1.short_name as from_short_name,
|
||||
n1.long_name as from_long_name,
|
||||
n2.short_name as to_short_name,
|
||||
n2.long_name as to_long_name
|
||||
FROM messages m
|
||||
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`),
|
||||
markDelivered: db.prepare(`UPDATE messages SET delivered = 1 WHERE ack_hash = ? AND delivered = 0`),
|
||||
|
||||
getRecentMessagesWithTimeLimit: db.prepare(`
|
||||
SELECT m.*,
|
||||
n1.short_name as from_short_name,
|
||||
n1.long_name as from_long_name,
|
||||
n2.short_name as to_short_name,
|
||||
n2.long_name as to_long_name
|
||||
getRecent: db.prepare(`
|
||||
SELECT m.*, c.name as from_name
|
||||
FROM messages m
|
||||
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
|
||||
LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
|
||||
WHERE m.created_at >= datetime('now', '-24 hours')
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
getMessagesByNode: db.prepare(`
|
||||
SELECT m.*,
|
||||
n1.short_name as from_short_name,
|
||||
n1.long_name as from_long_name
|
||||
getRecentDirect: db.prepare(`
|
||||
SELECT m.*, c.name as from_name
|
||||
FROM messages m
|
||||
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||
WHERE m.from_node = ? OR m.to_node = ?
|
||||
LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
|
||||
WHERE m.created_at >= datetime('now', '-24 hours')
|
||||
AND m.msg_type = 0
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
deleteOldMessages: db.prepare(`
|
||||
DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' hours')
|
||||
`)
|
||||
};
|
||||
|
||||
// Telemetry queries
|
||||
const telemetryQueries = {
|
||||
insertTelemetry: db.prepare(`
|
||||
INSERT INTO telemetry (
|
||||
node_id, battery_level, voltage, channel_utilization,
|
||||
air_util_tx, uptime_seconds, temperature, relative_humidity,
|
||||
barometric_pressure
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
|
||||
getLatestTelemetryByNode: db.prepare(`
|
||||
SELECT * FROM telemetry
|
||||
WHERE node_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`),
|
||||
|
||||
getTelemetryHistory: db.prepare(`
|
||||
SELECT * FROM telemetry
|
||||
WHERE node_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
getRecentChannel: db.prepare(`
|
||||
SELECT m.*, c.name as from_name
|
||||
FROM messages m
|
||||
LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
|
||||
WHERE m.created_at >= datetime('now', '-24 hours')
|
||||
AND m.msg_type = 1
|
||||
AND m.channel_idx = ?
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
deleteOldTelemetry: db.prepare(`
|
||||
DELETE FROM telemetry WHERE timestamp < datetime('now', '-' || ? || ' hours')
|
||||
`)
|
||||
};
|
||||
|
||||
// Activity log queries
|
||||
const activityLogQueries = {
|
||||
logActivity: db.prepare(`
|
||||
INSERT INTO activity_log (user_id, action, details, ip_address)
|
||||
VALUES (?, ?, ?, ?)
|
||||
getByContact: db.prepare(`
|
||||
SELECT m.*, c.name as from_name
|
||||
FROM messages m
|
||||
LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
|
||||
WHERE m.from_pubkey = ? OR m.to_pubkey = ?
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
getRecentActivity: db.prepare(`
|
||||
SELECT a.*, u.username
|
||||
FROM activity_log a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.timestamp DESC
|
||||
LIMIT ?
|
||||
`)
|
||||
deleteOld: db.prepare(`DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' hours')`),
|
||||
};
|
||||
|
||||
// Statistics queries
|
||||
const statsQueries = {
|
||||
export const statsQueries = {
|
||||
getMessageCount: db.prepare(`SELECT COUNT(*) as count FROM messages`),
|
||||
getNodeCount: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||
getContactCount: db.prepare(`SELECT COUNT(*) as count FROM contacts`),
|
||||
getPositionCount: db.prepare(`SELECT COUNT(*) as count FROM positions`),
|
||||
|
||||
getDbSize: () => {
|
||||
const result = db.prepare(`
|
||||
SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()
|
||||
`).get();
|
||||
const result = db.prepare(
|
||||
`SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()`
|
||||
).get();
|
||||
return result.size;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
userQueries,
|
||||
nodeQueries,
|
||||
positionQueries,
|
||||
messageQueries,
|
||||
telemetryQueries,
|
||||
activityLogQueries,
|
||||
statsQueries
|
||||
export const activityLogQueries = {
|
||||
log: db.prepare(`INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`),
|
||||
};
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
const mqtt = require('mqtt');
|
||||
const config = require('../config/config');
|
||||
const logger = require('../utils/logger');
|
||||
const { nodeQueries, positionQueries, messageQueries, telemetryQueries } = require('../database/queries');
|
||||
|
||||
class MeshtasticMQTTClient {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.connected = false;
|
||||
this.messageCallbacks = [];
|
||||
}
|
||||
|
||||
connect() {
|
||||
const { broker, username, password, topic, options } = config.mqtt;
|
||||
|
||||
logger.info(`Connecting to MQTT broker: ${broker}`);
|
||||
|
||||
// Create MQTT client
|
||||
this.client = mqtt.connect(broker, {
|
||||
...options,
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
this.client.on('connect', () => {
|
||||
this.connected = true;
|
||||
logger.info('Connected to MQTT broker');
|
||||
|
||||
// Subscribe to Meshtastic topics
|
||||
this.client.subscribe(topic, (err) => {
|
||||
if (err) {
|
||||
logger.error('Failed to subscribe to topic:', err);
|
||||
} else {
|
||||
logger.info(`Subscribed to topic: ${topic}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
logger.error('MQTT connection error:', error);
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
this.connected = false;
|
||||
logger.warn('MQTT connection closed');
|
||||
});
|
||||
|
||||
this.client.on('reconnect', () => {
|
||||
logger.info('Reconnecting to MQTT broker...');
|
||||
});
|
||||
|
||||
// Message handler
|
||||
this.client.on('message', (topic, message) => {
|
||||
this.handleMessage(topic, message);
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
handleMessage(topic, message) {
|
||||
try {
|
||||
// Parse the topic to extract information
|
||||
const topicParts = topic.split('/');
|
||||
|
||||
// Log raw message for debugging (changed to info for testing)
|
||||
logger.info(`Received MQTT message on topic: ${topic} (${message.length} bytes)`);
|
||||
|
||||
// Try to parse as JSON first (some messages might be JSON)
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(message.toString());
|
||||
this.processJsonMessage(topic, payload);
|
||||
} catch (e) {
|
||||
// If not JSON, treat as protobuf or binary data
|
||||
this.processRawMessage(topic, message);
|
||||
}
|
||||
|
||||
// Notify subscribers
|
||||
this.notifyCallbacks(topic, message);
|
||||
} catch (error) {
|
||||
logger.error('Error handling MQTT message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
processJsonMessage(topic, payload) {
|
||||
try {
|
||||
const { from, to, channel, id, sender } = payload;
|
||||
|
||||
// Extract node ID (convert to hex string if it's a number, fallback to sender if none
|
||||
const fromNode = from ? `!${from.toString(16).padStart(8, '0')}` : sender;
|
||||
const toNode = to ? `!${to.toString(16).padStart(8, '0')}` : null;
|
||||
|
||||
// Debug log for Aether channel
|
||||
if (topic.includes('Aether')) {
|
||||
logger.info(`Aether message - Type: ${payload.type}, From: ${fromNode}, Channel: ${channel}, Payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
// Handle different message types based on actual JSON structure
|
||||
if ((payload.type === 'sendtext' || payload.type === 'text') && payload.payload) {
|
||||
this.handleTextMessage(fromNode, toNode, {
|
||||
...payload,
|
||||
text: typeof payload.payload === 'string' ? payload.payload : payload.payload.text,
|
||||
id: payload.id,
|
||||
channel: payload.channel,
|
||||
rxSnr: payload.snr,
|
||||
rxRssi: payload.rssi
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type === 'position' && payload.payload) {
|
||||
const pos = payload.payload;
|
||||
this.handlePositionUpdate(fromNode, {
|
||||
latitude: pos.latitude_i / 10000000,
|
||||
longitude: pos.longitude_i / 10000000,
|
||||
altitude: pos.altitude,
|
||||
precisionBits: pos.precision_bits
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type === 'nodeinfo' && payload.payload) {
|
||||
this.handleNodeInfo(fromNode, {
|
||||
shortName: payload.payload.shortname,
|
||||
longName: payload.payload.longname,
|
||||
hardwareModel: payload.payload.hardware,
|
||||
role: payload.payload.role
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type === 'telemetry' && payload.payload) {
|
||||
this.handleTelemetry(fromNode, {
|
||||
deviceMetrics: {
|
||||
batteryLevel: payload.payload.battery_level,
|
||||
voltage: payload.payload.voltage,
|
||||
channelUtilization: payload.payload.channel_utilization,
|
||||
airUtilTx: payload.payload.air_util_tx,
|
||||
uptimeSeconds: payload.payload.uptime_seconds
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type === 'neighborinfo' && payload.payload) {
|
||||
// Just update last_seen for the node
|
||||
nodeQueries.upsertNode.run(
|
||||
fromNode,
|
||||
null, null, null, null, null,
|
||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
||||
null, null, null, null, null
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing JSON message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
processRawMessage(topic, message) {
|
||||
// For protobuf messages, we'll need to decode them
|
||||
// This is a simplified version - real implementation would use protobuf definitions
|
||||
try {
|
||||
// Extract information from topic
|
||||
const topicParts = topic.split('/');
|
||||
|
||||
// Basic message logging for debugging
|
||||
logger.debug(`Raw message length: ${message.length} bytes`);
|
||||
} catch (error) {
|
||||
logger.error('Error processing raw message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleTextMessage(fromNode, toNode, data) {
|
||||
try {
|
||||
if (!fromNode) return;
|
||||
|
||||
logger.info(`Text message from ${fromNode}: ${data.text || '(empty)'}`);
|
||||
|
||||
// Ensure node exists first (upsert)
|
||||
nodeQueries.upsertNode.run(
|
||||
fromNode,
|
||||
null, null, null, null, null,
|
||||
null, // last_heard - will use CURRENT_TIMESTAMP via COALESCE
|
||||
null, null, null, null, null
|
||||
);
|
||||
|
||||
// Insert message - ensure all values are proper types
|
||||
const messageId = data.id ? String(data.id) : null;
|
||||
const channel = typeof data.channel === 'number' ? data.channel : 0;
|
||||
const text = data.text || '';
|
||||
const rxSnr = typeof data.rxSnr === 'number' ? data.rxSnr : null;
|
||||
const rxRssi = typeof data.rxRssi === 'number' ? data.rxRssi : null;
|
||||
const hopLimit = typeof data.hopLimit === 'number' ? data.hopLimit : null;
|
||||
const wantAck = data.wantAck === true ? 1 : 0;
|
||||
|
||||
messageQueries.insertMessage.run(
|
||||
messageId,
|
||||
fromNode,
|
||||
toNode,
|
||||
channel,
|
||||
text,
|
||||
null, // rx_time - let database use CURRENT_TIMESTAMP for created_at
|
||||
rxSnr,
|
||||
rxRssi,
|
||||
hopLimit,
|
||||
wantAck
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error handling text message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handlePositionUpdate(nodeId, data) {
|
||||
try {
|
||||
if (!nodeId || !data.latitude || !data.longitude) return;
|
||||
|
||||
logger.info(`Position update from ${nodeId}: ${data.latitude}, ${data.longitude}`);
|
||||
|
||||
// Update node
|
||||
nodeQueries.upsertNode.run(
|
||||
nodeId,
|
||||
null, // short_name
|
||||
null, // long_name
|
||||
null, // hardware_model
|
||||
null, // role
|
||||
null, // firmware_version
|
||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
||||
null, // battery_level
|
||||
null, // voltage
|
||||
null, // channel_utilization
|
||||
null, // air_util_tx
|
||||
null // uptime_seconds
|
||||
);
|
||||
|
||||
// Insert position
|
||||
positionQueries.insertPosition.run(
|
||||
nodeId,
|
||||
data.latitude,
|
||||
data.longitude,
|
||||
data.altitude || null,
|
||||
data.precisionBits || null
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error handling position update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleNodeInfo(nodeId, data) {
|
||||
try {
|
||||
if (!nodeId) return;
|
||||
|
||||
logger.info(`Node info update for ${nodeId}`);
|
||||
|
||||
// Update node information
|
||||
nodeQueries.upsertNode.run(
|
||||
nodeId,
|
||||
data.shortName || data.user?.shortName || null,
|
||||
data.longName || data.user?.longName || null,
|
||||
data.hardwareModel || data.user?.hwModel || null,
|
||||
data.role || null,
|
||||
data.firmwareVersion || null,
|
||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
||||
null, // battery_level
|
||||
null, // voltage
|
||||
null, // channel_utilization
|
||||
null, // air_util_tx
|
||||
null // uptime_seconds
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error handling node info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleTelemetry(nodeId, data) {
|
||||
try {
|
||||
if (!nodeId) return;
|
||||
|
||||
logger.info(`Telemetry update from ${nodeId}`);
|
||||
|
||||
// Update node with telemetry data
|
||||
if (data.deviceMetrics) {
|
||||
const metrics = data.deviceMetrics;
|
||||
|
||||
nodeQueries.upsertNode.run(
|
||||
nodeId,
|
||||
null, // short_name
|
||||
null, // long_name
|
||||
null, // hardware_model
|
||||
null, // role
|
||||
null, // firmware_version
|
||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
||||
metrics.batteryLevel || null,
|
||||
metrics.voltage || null,
|
||||
metrics.channelUtilization || null,
|
||||
metrics.airUtilTx || null,
|
||||
metrics.uptimeSeconds || null
|
||||
);
|
||||
|
||||
// Insert telemetry record
|
||||
telemetryQueries.insertTelemetry.run(
|
||||
nodeId,
|
||||
metrics.batteryLevel || null,
|
||||
metrics.voltage || null,
|
||||
metrics.channelUtilization || null,
|
||||
metrics.airUtilTx || null,
|
||||
metrics.uptimeSeconds || null,
|
||||
null, // temperature
|
||||
null, // relative_humidity
|
||||
null // barometric_pressure
|
||||
);
|
||||
}
|
||||
|
||||
if (data.environmentMetrics) {
|
||||
const env = data.environmentMetrics;
|
||||
|
||||
telemetryQueries.insertTelemetry.run(
|
||||
nodeId,
|
||||
null, // battery_level
|
||||
null, // voltage
|
||||
null, // channel_utilization
|
||||
null, // air_util_tx
|
||||
null, // uptime_seconds
|
||||
env.temperature || null,
|
||||
env.relativeHumidity || null,
|
||||
env.barometricPressure || null
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling telemetry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a message to MQTT
|
||||
publish(topic, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.connected) {
|
||||
reject(new Error('MQTT client not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.publish(topic, message, (error) => {
|
||||
if (error) {
|
||||
logger.error('Error publishing message:', error);
|
||||
reject(error);
|
||||
} else {
|
||||
logger.info(`Published message to topic: ${topic}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send a text message
|
||||
async sendTextMessage(from, text, channel=0) {
|
||||
try {
|
||||
const message = JSON.stringify({
|
||||
from: from,
|
||||
channel: channel,
|
||||
type: 'sendtext',
|
||||
payload: text
|
||||
});
|
||||
|
||||
// Publish to the appropriate topic
|
||||
await this.publish(config.mqtt.pubTopic, message);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error sending text message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Register a callback for messages
|
||||
onMessage(callback) {
|
||||
this.messageCallbacks.push(callback);
|
||||
}
|
||||
|
||||
// Notify all callbacks
|
||||
notifyCallbacks(topic, message) {
|
||||
this.messageCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(topic, message);
|
||||
} catch (error) {
|
||||
logger.error('Error in message callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
disconnect() {
|
||||
if (this.client) {
|
||||
this.client.end();
|
||||
this.connected = false;
|
||||
logger.info('Disconnected from MQTT broker');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const mqttClient = new MeshtasticMQTTClient();
|
||||
|
||||
module.exports = mqttClient;
|
||||
+157
-188
@@ -1,219 +1,189 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authenticateUser, requireAuth, logActivity } = require('../auth/auth');
|
||||
const {
|
||||
nodeQueries,
|
||||
positionQueries,
|
||||
messageQueries,
|
||||
telemetryQueries,
|
||||
statsQueries
|
||||
} = require('../database/queries');
|
||||
const mqttClient = require('../mqtt/client');
|
||||
const logger = require('../utils/logger');
|
||||
import express from 'express';
|
||||
import { authenticateUser, requireAuth, logActivity } from '../auth/auth.js';
|
||||
import { contactQueries, positionQueries, messageQueries, statsQueries } from '../database/queries.js';
|
||||
import serialClient from '../serial/client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Login endpoint
|
||||
const router = express.Router();
|
||||
|
||||
// Auth
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
|
||||
|
||||
const user = await authenticateUser(username, password);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid username or password' });
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Set session
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
|
||||
// Log activity
|
||||
logActivity(user.id, 'login', null, req.ip);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
res.json({ success: true, user: { id: user.id, username: user.username } });
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.post('/logout', requireAuth, (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
logger.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
|
||||
if (err) return res.status(500).json({ error: 'Failed to logout' });
|
||||
logActivity(userId, 'logout', null, req.ip);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Check authentication status
|
||||
router.get('/auth/status', (req, res) => {
|
||||
if (req.session && req.session.userId) {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.session.userId,
|
||||
username: req.session.username
|
||||
}
|
||||
});
|
||||
if (req.session?.userId) {
|
||||
res.json({ authenticated: true, user: { id: req.session.userId, username: req.session.username } });
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all nodes
|
||||
router.get('/nodes', requireAuth, (req, res) => {
|
||||
// Contacts
|
||||
router.get('/contacts', requireAuth, (req, res) => {
|
||||
try {
|
||||
const nodes = nodeQueries.getAllNodes.all();
|
||||
res.json(nodes);
|
||||
res.json(contactQueries.getAll.all());
|
||||
} catch (error) {
|
||||
logger.error('Error fetching nodes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch nodes' });
|
||||
logger.error('Error fetching contacts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch contacts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific node
|
||||
router.get('/nodes/:nodeId', requireAuth, (req, res) => {
|
||||
router.get('/contacts/:pubkey', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
const node = nodeQueries.getNodeById.get(nodeId);
|
||||
|
||||
if (!node) {
|
||||
return res.status(404).json({ error: 'Node not found' });
|
||||
}
|
||||
|
||||
res.json(node);
|
||||
const contact = contactQueries.getByPubkey.get(req.params.pubkey);
|
||||
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||
res.json(contact);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching node:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch node' });
|
||||
logger.error('Error fetching contact:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch contact' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get latest positions for all nodes
|
||||
// Positions
|
||||
router.get('/positions', requireAuth, (req, res) => {
|
||||
try {
|
||||
const positions = positionQueries.getLatestPositions.all();
|
||||
res.json(positions);
|
||||
res.json(positionQueries.getLatest.all());
|
||||
} catch (error) {
|
||||
logger.error('Error fetching positions:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch positions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get position trails for all nodes (last 10 positions per node)
|
||||
// MUST come before /positions/:nodeId to avoid matching "trails" as a nodeId
|
||||
router.get('/positions/trails/all', requireAuth, (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const trails = positionQueries.getPositionTrails.all(limit);
|
||||
res.json(trails);
|
||||
res.json(positionQueries.getTrails.all(limit));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching position trails:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch position trails' });
|
||||
logger.error('Error fetching trails:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch trails' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get position history for a specific node
|
||||
router.get('/positions/:nodeId', requireAuth, (req, res) => {
|
||||
router.get('/positions/:pubkey', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const positions = positionQueries.getPositionsByNode.all(nodeId, limit);
|
||||
res.json(positions);
|
||||
res.json(positionQueries.getByPubkey.all(req.params.pubkey, limit));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching position history:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch position history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent messages (24 hours or up to 1000 messages, whichever is less)
|
||||
// Messages — optional ?type=direct|channel&channel_idx=N
|
||||
router.get('/messages', requireAuth, (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 1000, 1000);
|
||||
const messages = messageQueries.getRecentMessagesWithTimeLimit.all(limit);
|
||||
res.json(messages);
|
||||
const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
|
||||
const { type, channel_idx } = req.query;
|
||||
|
||||
let rows;
|
||||
if (type === 'direct') {
|
||||
rows = messageQueries.getRecentDirect.all(limit);
|
||||
} else if (type === 'channel' && channel_idx != null) {
|
||||
rows = messageQueries.getRecentChannel.all(parseInt(channel_idx), limit);
|
||||
} else {
|
||||
rows = messageQueries.getRecent.all(limit);
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching messages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch messages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get messages for a specific node
|
||||
router.get('/messages/node/:nodeId', requireAuth, (req, res) => {
|
||||
router.get('/messages/contact/:pubkey', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const messages = messageQueries.getMessagesByNode.all(nodeId, nodeId, limit);
|
||||
res.json(messages);
|
||||
res.json(messageQueries.getByContact.all(req.params.pubkey, req.params.pubkey, limit));
|
||||
} catch (error) {
|
||||
logger.error('Error fetching node messages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch node messages' });
|
||||
logger.error('Error fetching contact messages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch contact messages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a message
|
||||
router.post('/messages/send', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { from, text, channel } = req.body;
|
||||
const { type, to_pubkey, channel_idx, text } = req.body;
|
||||
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Message text required' });
|
||||
if (!text?.trim()) return res.status(400).json({ error: 'Message text required' });
|
||||
|
||||
let ackHash = null;
|
||||
|
||||
if (type === 'channel') {
|
||||
if (channel_idx == null) return res.status(400).json({ error: 'channel_idx required for channel messages' });
|
||||
const result = await serialClient.sendChannelMessage(parseInt(channel_idx), text);
|
||||
ackHash = result?.ackHash ? Buffer.from(result.ackHash).toString('hex') : null;
|
||||
|
||||
messageQueries.insert.run(
|
||||
'self',
|
||||
null,
|
||||
parseInt(channel_idx),
|
||||
1,
|
||||
text,
|
||||
null, null,
|
||||
ackHash,
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
} else {
|
||||
if (!to_pubkey) return res.status(400).json({ error: 'to_pubkey required for direct messages' });
|
||||
const result = await serialClient.sendDirectMessage(to_pubkey, text);
|
||||
// expectedAckCrc is a uint32 used to match the SendConfirmed push later
|
||||
ackHash = result?.expectedAckCrc != null ? String(result.expectedAckCrc) : null;
|
||||
|
||||
messageQueries.insert.run(
|
||||
'self',
|
||||
to_pubkey,
|
||||
null,
|
||||
0,
|
||||
text,
|
||||
null, null,
|
||||
ackHash,
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
}
|
||||
|
||||
await mqttClient.sendTextMessage(from, text, channel);
|
||||
|
||||
// Log activity
|
||||
logActivity(req.session.userId, 'send_message', text, req.ip);
|
||||
|
||||
res.json({ success: true });
|
||||
logActivity(req.session.userId, 'send_message', text.substring(0, 100), req.ip);
|
||||
res.json({ success: true, ackHash });
|
||||
} catch (error) {
|
||||
logger.error('Error sending message:', error);
|
||||
res.status(500).json({ error: 'Failed to send message' });
|
||||
res.status(500).json({ error: error.message || 'Failed to send message' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get telemetry for a specific node
|
||||
router.get('/telemetry/:nodeId', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const telemetry = telemetryQueries.getTelemetryHistory.all(nodeId, limit);
|
||||
res.json(telemetry);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching telemetry:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch telemetry' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dashboard statistics
|
||||
// Stats
|
||||
router.get('/stats', requireAuth, (req, res) => {
|
||||
try {
|
||||
const messageCount = statsQueries.getMessageCount.get();
|
||||
const nodeCount = statsQueries.getNodeCount.get();
|
||||
const positionCount = statsQueries.getPositionCount.get();
|
||||
const dbSize = statsQueries.getDbSize();
|
||||
|
||||
res.json({
|
||||
messages: messageCount.count,
|
||||
nodes: nodeCount.count,
|
||||
positions: positionCount.count,
|
||||
databaseSize: dbSize,
|
||||
mqttConnected: mqttClient.isConnected()
|
||||
contacts: statsQueries.getContactCount.get().count,
|
||||
messages: statsQueries.getMessageCount.get().count,
|
||||
positions: statsQueries.getPositionCount.get().count,
|
||||
databaseSize: statsQueries.getDbSize(),
|
||||
serialConnected: serialClient.isConnected()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching stats:', error);
|
||||
@@ -221,76 +191,75 @@ router.get('/stats', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Purge old data
|
||||
// Serial status & ports
|
||||
router.get('/serial/status', requireAuth, (req, res) => {
|
||||
const self = serialClient.getSelfInfo();
|
||||
res.json({
|
||||
connected: serialClient.isConnected(),
|
||||
port: process.env.SERIAL_PORT || 'COM3',
|
||||
deviceName: self?.name || self?.advName || null
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/serial/ports', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const ports = await serialClient.listPorts();
|
||||
res.json(ports);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list ports' });
|
||||
}
|
||||
});
|
||||
|
||||
// Device self info
|
||||
router.get('/device', requireAuth, (req, res) => {
|
||||
const self = serialClient.getSelfInfo();
|
||||
if (!self) return res.json({ available: false });
|
||||
|
||||
const pubkeyHex = self.publicKey
|
||||
? (Buffer.isBuffer(self.publicKey) ? self.publicKey.toString('hex') : self.publicKey)
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
available: true,
|
||||
name: self.name || null,
|
||||
pubkey: pubkeyHex,
|
||||
pubkeyPrefix: pubkeyHex ? pubkeyHex.substring(0, 12) : null,
|
||||
txPower: self.txPower ?? null,
|
||||
latitude: self.advLat != null && self.advLat !== 0 ? self.advLat / 1e6 : null,
|
||||
longitude: self.advLon != null && self.advLon !== 0 ? self.advLon / 1e6 : null,
|
||||
nodeType: self.type ?? null,
|
||||
frequency: self.radioFreq ?? null,
|
||||
radioBw: self.radioBw ?? null,
|
||||
radioSf: self.radioSf ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// Channels from device
|
||||
router.get('/channels', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const channels = await serialClient.getChannels();
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message || 'Failed to get channels' });
|
||||
}
|
||||
});
|
||||
|
||||
// Data purge
|
||||
router.post('/purge', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { hours } = req.body;
|
||||
const hoursToKeep = hours || 720; // Default to 30 days (720 hours)
|
||||
const hoursToKeep = req.body.hours || 720;
|
||||
const msgs = messageQueries.deleteOld.run(hoursToKeep);
|
||||
const pos = positionQueries.deleteOld.run(hoursToKeep);
|
||||
const contacts = contactQueries.deleteOld.run(hoursToKeep, hoursToKeep);
|
||||
|
||||
logger.info(`Starting purge of data older than ${hoursToKeep} hours`);
|
||||
logger.info(`Purged: ${msgs.changes} messages, ${pos.changes} positions, ${contacts.changes} contacts`);
|
||||
logActivity(req.session.userId, 'purge_data', `Purged data older than ${hoursToKeep}h`, req.ip);
|
||||
|
||||
// First, update any nodes with NULL last_heard based on their most recent data
|
||||
const nullLastHeardUpdated = nodeQueries.updateNullLastHeard.run();
|
||||
logger.info(`Updated ${nullLastHeardUpdated.changes} nodes with NULL last_heard`);
|
||||
|
||||
const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep);
|
||||
logger.info(`Deleted ${messagesDeleted.changes} old messages`);
|
||||
|
||||
const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep);
|
||||
logger.info(`Deleted ${positionsDeleted.changes} old positions`);
|
||||
|
||||
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep);
|
||||
logger.info(`Deleted ${telemetryDeleted.changes} old telemetry records`);
|
||||
|
||||
// Check which old nodes still have data before deleting
|
||||
const oldNodesWithData = nodeQueries.getOldNodesWithData.all(hoursToKeep);
|
||||
oldNodesWithData.forEach(node => {
|
||||
logger.info(`Old node ${node.node_id} (last_heard: ${node.last_heard}): ${node.message_count} messages, ${node.position_count} positions, ${node.telemetry_count} telemetry`);
|
||||
});
|
||||
|
||||
const nodesDeleted = nodeQueries.deleteOldNodes.run(hoursToKeep, hoursToKeep, hoursToKeep, hoursToKeep);
|
||||
logger.info(`Deleted ${nodesDeleted.changes} old nodes`);
|
||||
|
||||
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records, ${nodesDeleted.changes} nodes`);
|
||||
|
||||
// Log activity
|
||||
const timePeriod = hoursToKeep < 24
|
||||
? `${hoursToKeep} hour${hoursToKeep !== 1 ? 's' : ''}`
|
||||
: `${hoursToKeep / 24} day${hoursToKeep / 24 !== 1 ? 's' : ''}`;
|
||||
|
||||
logActivity(
|
||||
req.session.userId,
|
||||
'purge_data',
|
||||
`Purged data older than ${timePeriod}`,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: {
|
||||
messages: messagesDeleted.changes,
|
||||
positions: positionsDeleted.changes,
|
||||
telemetry: telemetryDeleted.changes,
|
||||
nodes: nodesDeleted.changes
|
||||
}
|
||||
});
|
||||
res.json({ success: true, deleted: { messages: msgs.changes, positions: pos.changes, contacts: contacts.changes } });
|
||||
} catch (error) {
|
||||
logger.error('Error purging data:', error);
|
||||
res.status(500).json({ error: 'Failed to purge data' });
|
||||
}
|
||||
});
|
||||
|
||||
// MQTT status
|
||||
router.get('/mqtt/status', requireAuth, (req, res) => {
|
||||
res.json({
|
||||
connected: mqttClient.isConnected()
|
||||
});
|
||||
});
|
||||
|
||||
// Get channel configuration
|
||||
router.get('/config/channels', requireAuth, (req, res) => {
|
||||
const config = require('../config/config');
|
||||
res.json(config.channels);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,43 +1,32 @@
|
||||
const readline = require('readline');
|
||||
const { createUser } = require('../auth/auth');
|
||||
const logger = require('../utils/logger');
|
||||
import readline from 'readline';
|
||||
import { createUser } from '../auth/auth.js';
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(query) {
|
||||
return new Promise(resolve => rl.question(query, resolve));
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const question = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Create New User ===\n');
|
||||
|
||||
try {
|
||||
const username = await question('Enter username: ');
|
||||
|
||||
if (!username || username.length < 3) {
|
||||
console.error('Username must be at least 3 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const password = await question('Enter password: ');
|
||||
|
||||
if (!password || password.length < 6) {
|
||||
console.error('Password must be at least 6 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const confirmPassword = await question('Confirm password: ');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
const confirm = await question('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error('Passwords do not match');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await createUser(username, password);
|
||||
|
||||
console.log(`\nUser '${username}' created successfully!\n`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { NodeJSSerialConnection, Constants } from '@liamcottle/meshcore.js';
|
||||
import config from '../config/config.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { contactQueries, positionQueries, messageQueries } from '../database/queries.js';
|
||||
|
||||
class MeshCoreSerialClient {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.connected = false;
|
||||
this.selfInfo = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = 5000;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const { port } = config.serial;
|
||||
logger.info(`Connecting to MeshCore device on ${port}`);
|
||||
|
||||
try {
|
||||
this.connection = new NodeJSSerialConnection(port);
|
||||
this._patchFrameHandler();
|
||||
this._bindEvents();
|
||||
await this.connection.connect();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to open serial port ${port}: ${error.message}`);
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept unknown push codes before they hit the library's console.log fallback.
|
||||
_patchFrameHandler() {
|
||||
const orig = this.connection.onFrameReceived.bind(this.connection);
|
||||
this.connection.onFrameReceived = (frame) => {
|
||||
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
||||
const code = buf[0];
|
||||
|
||||
if (code === 0x8E) {
|
||||
// "Contact heard" push (not in library's PushCodes yet):
|
||||
// [0x8E][snr_int8][rssi_int8][7 bytes route/reserved][32 bytes full pubkey]
|
||||
if (buf.length >= 42) {
|
||||
const snr = (buf.readInt8(1) / 4).toFixed(1);
|
||||
const rssi = buf.readInt8(2);
|
||||
const pubkeyHex = buf.slice(10, 42).toString('hex');
|
||||
logger.debug(`Contact heard: ${pubkeyHex.substring(0, 12)}… SNR=${snr}dB RSSI=${rssi}dBm`);
|
||||
try { contactQueries.updateLastHeard.run(pubkeyHex); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0x90) {
|
||||
// Single-byte keepalive/notification — no payload, no action needed.
|
||||
return;
|
||||
}
|
||||
|
||||
orig(frame);
|
||||
};
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// "connected" fires after deviceQuery succeeds (no data passed)
|
||||
this.connection.on('connected', async () => {
|
||||
this.connected = true;
|
||||
this.reconnectDelay = 5000;
|
||||
logger.info('Serial connected, requesting self info...');
|
||||
|
||||
try {
|
||||
this.selfInfo = await this.connection.getSelfInfo();
|
||||
logger.info(`Device: ${this.selfInfo.name || '(unnamed)'}`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to get self info:', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.syncDeviceTime();
|
||||
logger.info('Device clock synced');
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync device clock:', err.message);
|
||||
}
|
||||
|
||||
await this._syncContacts();
|
||||
await this._drainMessages();
|
||||
});
|
||||
|
||||
this.connection.on('disconnected', () => {
|
||||
this.connected = false;
|
||||
logger.warn('Disconnected from MeshCore device');
|
||||
this._scheduleReconnect();
|
||||
});
|
||||
|
||||
// New message queued on device
|
||||
this.connection.on(Constants.PushCodes.MsgWaiting, async () => {
|
||||
await this._drainMessages();
|
||||
});
|
||||
|
||||
// New contact discovered (manual-add mode)
|
||||
this.connection.on(Constants.PushCodes.NewAdvert, (contact) => {
|
||||
this._upsertContact(contact);
|
||||
});
|
||||
|
||||
// Contact re-advertised (auto-add mode)
|
||||
this.connection.on(Constants.PushCodes.Advert, (data) => {
|
||||
// Only pubkey is given; just update last_heard
|
||||
if (data.publicKey) {
|
||||
const hex = this._toHex(data.publicKey);
|
||||
if (hex) {
|
||||
try { contactQueries.updateLastHeard.run(hex); } catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delivery confirmed
|
||||
this.connection.on(Constants.PushCodes.SendConfirmed, ({ ackCode, roundTrip }) => {
|
||||
if (ackCode != null) {
|
||||
try {
|
||||
messageQueries.markDelivered.run(String(ackCode));
|
||||
logger.info(`Message delivered, ackCode=${ackCode}, rtt=${roundTrip}ms`);
|
||||
} catch (err) {
|
||||
logger.error('Error marking delivered:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _syncContacts() {
|
||||
try {
|
||||
const contacts = await this.connection.getContacts();
|
||||
logger.info(`Synced ${contacts.length} contacts from device`);
|
||||
for (const c of contacts) {
|
||||
this._upsertContact(c);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to sync contacts:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _drainMessages() {
|
||||
try {
|
||||
const messages = await this.connection.getWaitingMessages();
|
||||
if (messages.length > 0) logger.info(`Drained ${messages.length} queued messages`);
|
||||
for (const m of messages) {
|
||||
if (m.contactMessage) this._handleDirectMessage(m.contactMessage);
|
||||
if (m.channelMessage) this._handleChannelMessage(m.channelMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`Message drain ended: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_upsertContact(contact) {
|
||||
try {
|
||||
const pubkeyHex = this._toHex(contact.publicKey);
|
||||
if (!pubkeyHex) return;
|
||||
|
||||
const lat = (contact.advLat != null && contact.advLat !== 0) ? contact.advLat / 1e6 : null;
|
||||
const lon = (contact.advLon != null && contact.advLon !== 0) ? contact.advLon / 1e6 : null;
|
||||
|
||||
contactQueries.upsert.run(
|
||||
pubkeyHex,
|
||||
contact.advName || null,
|
||||
contact.type ?? 0,
|
||||
contact.flags ?? 0,
|
||||
contact.outPathLen ?? 255,
|
||||
contact.lastAdvert ?? null
|
||||
);
|
||||
|
||||
if (lat != null && lon != null) {
|
||||
positionQueries.insert.run(pubkeyHex, lat, lon, null);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error upserting contact:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDirectMessage(msg) {
|
||||
try {
|
||||
const fromHex = this._toHex(msg.pubKeyPrefix);
|
||||
const text = msg.text || '';
|
||||
|
||||
logger.info(`Direct msg from ${fromHex}: ${text.substring(0, 80)}`);
|
||||
|
||||
if (fromHex) {
|
||||
try { contactQueries.updateLastHeard.run(fromHex); } catch {}
|
||||
}
|
||||
|
||||
messageQueries.insert.run(
|
||||
fromHex || 'unknown',
|
||||
null, // to_pubkey (unknown for received messages)
|
||||
null, // channel_idx
|
||||
0, // msg_type: direct
|
||||
text,
|
||||
null, // snr (not parsed by this library version)
|
||||
msg.pathLen ?? null,
|
||||
null, // ack_hash
|
||||
msg.senderTimestamp ?? null
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Error handling direct message:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_handleChannelMessage(msg) {
|
||||
try {
|
||||
const channelIdx = msg.channelIdx ?? 0;
|
||||
const text = msg.text || '';
|
||||
|
||||
logger.info(`Channel ${channelIdx} msg: ${text.substring(0, 80)}`);
|
||||
|
||||
messageQueries.insert.run(
|
||||
`chan:${channelIdx}`,
|
||||
null,
|
||||
channelIdx,
|
||||
1, // msg_type: channel
|
||||
text,
|
||||
null, // snr
|
||||
msg.pathLen ?? null,
|
||||
null,
|
||||
msg.senderTimestamp ?? null
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Error handling channel message:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_toHex(buf) {
|
||||
if (!buf) return null;
|
||||
if (typeof buf === 'string') return buf;
|
||||
if (buf instanceof Uint8Array || Buffer.isBuffer(buf)) return Buffer.from(buf).toString('hex');
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendDirectMessage(pubkeyHex, text) {
|
||||
if (!this.connected) throw new Error('Not connected to device');
|
||||
// sendTextMessage expects full 32-byte key but only uses first 6 bytes internally
|
||||
const keyBuf = Buffer.from(pubkeyHex, 'hex');
|
||||
const result = await this.connection.sendTextMessage(keyBuf, text);
|
||||
return result; // { result, expectedAckCrc, estTimeout }
|
||||
}
|
||||
|
||||
async sendChannelMessage(channelIdx, text) {
|
||||
if (!this.connected) throw new Error('Not connected to device');
|
||||
await this.connection.sendChannelTextMessage(channelIdx, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getChannels() {
|
||||
if (!this.connected) throw new Error('Not connected to device');
|
||||
return await this.connection.getChannels();
|
||||
}
|
||||
|
||||
async listPorts() {
|
||||
try {
|
||||
const { SerialPort } = await import('serialport');
|
||||
const ports = await SerialPort.list();
|
||||
return ports.map(p => ({ path: p.path, manufacturer: p.manufacturer || null }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getSelfInfo() {
|
||||
if (!this.selfInfo) return null;
|
||||
return {
|
||||
name: this.selfInfo.name || null,
|
||||
publicKey: this.selfInfo.publicKey ? Buffer.from(this.selfInfo.publicKey).toString('hex') : null,
|
||||
type: this.selfInfo.type ?? null,
|
||||
txPower: this.selfInfo.txPower ?? null,
|
||||
maxTxPower: this.selfInfo.maxTxPower ?? null,
|
||||
advLat: this.selfInfo.advLat ?? null,
|
||||
advLon: this.selfInfo.advLon ?? null,
|
||||
radioFreq: this.selfInfo.radioFreq ?? null,
|
||||
radioBw: this.selfInfo.radioBw ?? null,
|
||||
radioSf: this.selfInfo.radioSf ?? null,
|
||||
radioCr: this.selfInfo.radioCr ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.connection) {
|
||||
try { this.connection.close(); } catch {}
|
||||
this.connection = null;
|
||||
}
|
||||
this.connected = false;
|
||||
logger.info('Serial client disconnected');
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
logger.info(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
|
||||
await this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MeshCoreSerialClient();
|
||||
+26
-55
@@ -1,20 +1,20 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const config = require('./config/config');
|
||||
const logger = require('./utils/logger');
|
||||
const apiRoutes = require('./routes/api');
|
||||
const mqttClient = require('./mqtt/client');
|
||||
const cronService = require('./services/cron');
|
||||
import config from './config/config.js';
|
||||
import logger from './utils/logger.js';
|
||||
import apiRoutes from './routes/api.js';
|
||||
import serialClient from './serial/client.js';
|
||||
import cronService from './services/cron.js';
|
||||
|
||||
// Initialize Express app
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
@@ -29,84 +29,56 @@ app.use(helmet({
|
||||
hsts: false
|
||||
}));
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: config.server.nodeEnv === 'production' ? false : true,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Body parser
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Session configuration
|
||||
app.use(session(config.session));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
app.use('/api/', rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
message: 'Too many requests from this IP, please try again later.'
|
||||
});
|
||||
message: 'Too many requests, please try again later.'
|
||||
}));
|
||||
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiRoutes);
|
||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
|
||||
|
||||
// Serve index.html for all other routes (SPA)
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Express error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
function start() {
|
||||
const PORT = config.server.port;
|
||||
|
||||
// Connect to MQTT broker
|
||||
logger.info('Starting MQTT client...');
|
||||
mqttClient.connect();
|
||||
logger.info('Starting MeshCore serial client...');
|
||||
serialClient.connect();
|
||||
|
||||
// Start cron service
|
||||
logger.info('Starting cron service...');
|
||||
cronService.start();
|
||||
|
||||
// Start Express server
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
logger.info(`Environment: ${config.server.nodeEnv}`);
|
||||
logger.info(`Serial port: ${config.serial.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
function shutdown() {
|
||||
logger.info('Shutting down gracefully...');
|
||||
|
||||
mqttClient.disconnect();
|
||||
serialClient.disconnect();
|
||||
cronService.stop();
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Shutting down gracefully...');
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
mqttClient.disconnect();
|
||||
cronService.stop();
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught exception:', error);
|
||||
process.exit(1);
|
||||
@@ -117,7 +89,6 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Start the application
|
||||
start();
|
||||
|
||||
module.exports = app;
|
||||
export default app;
|
||||
|
||||
+16
-30
@@ -1,7 +1,7 @@
|
||||
const cron = require('node-cron');
|
||||
const config = require('../config/config');
|
||||
const { messageQueries, positionQueries, telemetryQueries } = require('../database/queries');
|
||||
const logger = require('../utils/logger');
|
||||
import cron from 'node-cron';
|
||||
import config from '../config/config.js';
|
||||
import { messageQueries, positionQueries } from '../database/queries.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
class CronService {
|
||||
constructor() {
|
||||
@@ -9,45 +9,31 @@ class CronService {
|
||||
}
|
||||
|
||||
start() {
|
||||
// Schedule data purging
|
||||
const purgeTask = cron.schedule(
|
||||
const task = cron.schedule(
|
||||
config.dataRetention.purgeCronSchedule,
|
||||
() => {
|
||||
this.purgeOldData();
|
||||
},
|
||||
{
|
||||
scheduled: true,
|
||||
timezone: 'UTC'
|
||||
}
|
||||
() => this.purgeOldData(),
|
||||
{ scheduled: true, timezone: 'UTC' }
|
||||
);
|
||||
|
||||
this.tasks.push(purgeTask);
|
||||
logger.info(`Cron job scheduled: Data purging at ${config.dataRetention.purgeCronSchedule}`);
|
||||
this.tasks.push(task);
|
||||
logger.info(`Cron scheduled: data purge at ${config.dataRetention.purgeCronSchedule}`);
|
||||
}
|
||||
|
||||
purgeOldData() {
|
||||
try {
|
||||
const days = config.dataRetention.days;
|
||||
logger.info(`Starting automatic data purge (keeping last ${days} days)`);
|
||||
|
||||
const messagesDeleted = messageQueries.deleteOldMessages.run(days);
|
||||
const positionsDeleted = positionQueries.deleteOldPositions.run(days);
|
||||
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(days);
|
||||
|
||||
logger.info(
|
||||
`Data purge completed: ${messagesDeleted.changes} messages, ` +
|
||||
`${positionsDeleted.changes} positions, ` +
|
||||
`${telemetryDeleted.changes} telemetry records deleted`
|
||||
);
|
||||
const hours = config.dataRetention.days * 24;
|
||||
logger.info(`Starting automatic data purge (keeping last ${config.dataRetention.days} days)`);
|
||||
const msgs = messageQueries.deleteOld.run(hours);
|
||||
const pos = positionQueries.deleteOld.run(hours);
|
||||
logger.info(`Purge complete: ${msgs.changes} messages, ${pos.changes} positions deleted`);
|
||||
} catch (error) {
|
||||
logger.error('Error during automatic data purge:', error);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.tasks.forEach(task => task.stop());
|
||||
this.tasks.forEach(t => t.stop());
|
||||
logger.info('Cron service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CronService();
|
||||
export default new CronService();
|
||||
|
||||
+11
-26
@@ -1,58 +1,43 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Ensure logs directory exists
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const logsDir = path.join(__dirname, '..', '..', 'logs');
|
||||
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Define log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
if (stack) {
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
|
||||
}
|
||||
if (stack) return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// Write all logs to console
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
logFormat
|
||||
)
|
||||
format: winston.format.combine(winston.format.colorize(), logFormat)
|
||||
}),
|
||||
// Write all logs to combined.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxsize: 5242880,
|
||||
maxFiles: 5
|
||||
}),
|
||||
// Write error logs to error.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxsize: 5242880,
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Create a stream object for Morgan HTTP logger
|
||||
logger.stream = {
|
||||
write: (message) => {
|
||||
logger.info(message.trim());
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
export default logger;
|
||||
|
||||
Reference in New Issue
Block a user