Move meshtastic mqtt project to meshcore usb project

This commit is contained in:
zyphlar
2026-04-15 13:45:38 -07:00
parent 150c61fe65
commit 427fbc1b4e
28 changed files with 1550 additions and 4011 deletions
+9 -21
View File
@@ -1,32 +1,20 @@
# Server Configuration
# Server
PORT=3000
NODE_ENV=production
NODE_ENV=development
SESSION_SECRET=change-this-to-a-random-secret
# Session Secret (CHANGE THIS!)
SESSION_SECRET=change-this-to-a-random-secret-string
# MeshCore USB Serial
# Windows: COM3, COM4, etc.
# Linux/Mac: /dev/ttyUSB0, /dev/cu.usbmodem14401, etc.
SERIAL_PORT=COM3
# MQTT Configuration
MQTT_BROKER=mqtt://mqtt.meshtastic.org
MQTT_PORT=1883
MQTT_USERNAME=meshdev
MQTT_PASSWORD=large4cats
MQTT_TOPIC=msh/US/#
MQTT_PUB_TOPIC="msh/US/2/json/mqtt/"
# Optional: If you have your own MQTT broker
# MQTT_BROKER=mqtt://your-broker.com
# MQTT_USERNAME=your-username
# MQTT_PASSWORD=your-password
# Data Retention (in days)
# Data retention
DATA_RETENTION_DAYS=30
# Cron schedule for automatic data purging (default: daily at 2 AM)
PURGE_CRON_SCHEDULE=0 2 * * *
# Logging
LOG_LEVEL=info
# Rate Limiting
# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
-27
View File
@@ -1,27 +0,0 @@
# Server Configuration
PORT=3000
NODE_ENV=development
# Session Secret
SESSION_SECRET=test-secret-for-development-only
# MQTT Configuration (Meshtastic Public Broker)
MQTT_BROKER=mqtt://mqtt.meshtastic.org
MQTT_PORT=1883
MQTT_USERNAME=meshdev
MQTT_PASSWORD=large4cats
MQTT_TOPIC=#
MQTT_PUB_TOPIC="msh/US/2/json/mqtt/"
# Data Retention (in days)
DATA_RETENTION_DAYS=30
# Cron schedule for automatic data purging (daily at 2 AM)
PURGE_CRON_SCHEDULE=0 2 * * *
# Logging
LOG_LEVEL=info
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=1000
-413
View File
@@ -1,413 +0,0 @@
# Deployment Guide
This guide covers deploying the Meshtastic MQTT Dashboard to production environments.
## Pre-Deployment Checklist
- [ ] Change `SESSION_SECRET` to a strong random string
- [ ] Set `NODE_ENV=production` in `.env`
- [ ] Review and adjust MQTT broker settings
- [ ] Configure data retention policies
- [ ] Set up SSL/TLS certificates
- [ ] Configure firewall rules
- [ ] Plan backup strategy
- [ ] Create initial user accounts
- [ ] Test the application locally
## Deployment Options
### Option 1: Traditional Server (Linux)
#### Requirements
- Ubuntu 20.04+ or similar Linux distribution
- Node.js 16.x or higher
- Nginx (for reverse proxy and SSL)
- 1GB+ RAM
- 10GB+ disk space
#### Steps
1. **Install Node.js**
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
2. **Clone/Upload Application**
```bash
cd /opt
sudo mkdir meshtastic-dashboard
sudo chown $USER:$USER meshtastic-dashboard
cd meshtastic-dashboard
# Upload your files here
```
3. **Install Dependencies**
```bash
npm ci --production
```
4. **Configure Environment**
```bash
cp .env.example .env
nano .env # Edit configuration
```
5. **Create User**
```bash
npm run create-user
```
6. **Set Up Systemd Service**
Create `/etc/systemd/system/meshtastic-dashboard.service`:
```ini
[Unit]
Description=Meshtastic MQTT Dashboard
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/meshtastic-dashboard
Environment=NODE_ENV=production
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable meshtastic-dashboard
sudo systemctl start meshtastic-dashboard
sudo systemctl status meshtastic-dashboard
```
7. **Configure Nginx**
Create `/etc/nginx/sites-available/meshtastic-dashboard`:
```nginx
server {
listen 80;
server_name your-domain.com;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
Enable site:
```bash
sudo ln -s /etc/nginx/sites-available/meshtastic-dashboard /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
8. **Set Up SSL with Let's Encrypt**
```bash
sudo apt-get install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
```
### Option 2: Docker Deployment
Create `Dockerfile`:
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN mkdir -p data logs
EXPOSE 3000
CMD ["node", "src/server.js"]
```
Create `docker-compose.yml`:
```yaml
version: '3.8'
services:
meshtastic-dashboard:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./.env:/app/.env:ro
restart: unless-stopped
environment:
- NODE_ENV=production
```
Deploy:
```bash
docker-compose up -d
```
### Option 3: Cloud Platforms
#### Heroku
1. Create `Procfile`:
```
web: node src/server.js
```
2. Deploy:
```bash
heroku create your-app-name
heroku config:set SESSION_SECRET=your-secret-here
git push heroku main
```
#### DigitalOcean App Platform
1. Connect your repository
2. Set environment variables in the dashboard
3. Deploy with one click
#### AWS EC2
Follow "Traditional Server" steps above on an EC2 instance.
## Security Hardening
### 1. Firewall Configuration
```bash
# UFW (Ubuntu)
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
### 2. Environment Variables
Never commit `.env` file. Use secure methods to transfer:
```bash
# On server
touch .env
chmod 600 .env
nano .env # Add your configuration
```
### 3. Database Permissions
```bash
chmod 700 data/
chmod 600 data/meshtastic.db
```
### 4. Regular Updates
```bash
# Update system packages
sudo apt update && sudo apt upgrade -y
# Update Node.js dependencies
npm audit fix
npm update
```
### 5. Monitoring
Install monitoring tools:
```bash
# PM2 for process management
npm install -g pm2
pm2 start src/server.js --name meshtastic-dashboard
pm2 startup
pm2 save
```
## Backup Strategy
### Automated Backup Script
Create `/opt/backup-meshtastic.sh`:
```bash
#!/bin/bash
BACKUP_DIR="/opt/backups/meshtastic"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# Backup database
cp /opt/meshtastic-dashboard/data/meshtastic.db $BACKUP_DIR/db_$DATE.db
# Backup configuration
cp /opt/meshtastic-dashboard/.env $BACKUP_DIR/env_$DATE.txt
# Keep only last 7 days
find $BACKUP_DIR -name "db_*.db" -mtime +7 -delete
find $BACKUP_DIR -name "env_*.txt" -mtime +7 -delete
echo "Backup completed: $DATE"
```
Add to crontab:
```bash
chmod +x /opt/backup-meshtastic.sh
crontab -e
# Add: 0 3 * * * /opt/backup-meshtastic.sh
```
## Monitoring and Logs
### View Logs
```bash
# Systemd logs
sudo journalctl -u meshtastic-dashboard -f
# Application logs
tail -f /opt/meshtastic-dashboard/logs/combined.log
tail -f /opt/meshtastic-dashboard/logs/error.log
```
### Log Rotation
Create `/etc/logrotate.d/meshtastic-dashboard`:
```
/opt/meshtastic-dashboard/logs/*.log {
daily
rotate 14
compress
delaycompress
notifempty
missingok
copytruncate
}
```
## Performance Optimization
### 1. Enable Node.js Clustering
For multi-core servers, modify `src/server.js` to use the cluster module.
### 2. Database Optimization
```bash
# Vacuum database periodically
sqlite3 data/meshtastic.db "VACUUM;"
```
### 3. Nginx Caching
Add to nginx configuration:
```nginx
location /css/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /js/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
```
## Troubleshooting
### Application Won't Start
1. Check logs: `sudo journalctl -u meshtastic-dashboard -n 50`
2. Verify Node.js version: `node --version`
3. Check port availability: `sudo netstat -tlnp | grep 3000`
4. Verify permissions on data directory
### MQTT Connection Issues
1. Check broker is accessible: `telnet mqtt.meshtastic.org 1883`
2. Verify credentials in `.env`
3. Check firewall rules
### Database Errors
1. Check disk space: `df -h`
2. Verify database permissions: `ls -la data/`
3. Try rebuilding: `rm data/meshtastic.db && npm start`
## Scaling Considerations
For high traffic deployments:
1. **Load Balancing**: Use multiple instances behind nginx
2. **Database**: Consider PostgreSQL for better concurrency
3. **Caching**: Add Redis for session storage
4. **CDN**: Use CloudFlare for static assets
5. **Monitoring**: Add Prometheus + Grafana
## Maintenance
### Weekly Tasks
- Review error logs
- Check disk space
- Verify backups
### Monthly Tasks
- Update dependencies
- Review security advisories
- Optimize database
- Rotate API keys if used
### Quarterly Tasks
- Review access logs
- Update SSL certificates (if not using auto-renewal)
- Performance audit
- Security audit
## Support
For deployment issues:
1. Check logs first
2. Review documentation
3. Check GitHub issues
4. Contact maintainers
---
**Last Updated**: 2025
**Maintainer**: Meshtastic Community
-281
View File
@@ -1,281 +0,0 @@
# Meshtastic MQTT Dashboard - Project Summary
## Overview
A complete, production-ready Node.js web application for monitoring and managing Meshtastic mesh networks via MQTT. This is a fully self-contained solution similar to the Home Assistant integration.
## What Was Built
### Backend (Node.js + Express)
#### Core Server (`src/server.js`)
- Express.js web server
- Session management with express-session
- Security middleware (Helmet, CORS, rate limiting)
- Graceful shutdown handling
- Error handling middleware
#### Authentication System (`src/auth/`)
- User registration and login with bcrypt password hashing
- Session-based authentication
- Activity logging for security auditing
- User creation script for initial setup
#### Database Layer (`src/database/`)
- SQLite database with better-sqlite3
- Comprehensive schema for:
- Users and authentication
- Meshtastic nodes
- GPS positions
- Text messages
- Device telemetry
- Activity logs
- Prepared statements for performance and security
- Automatic database initialization
#### MQTT Client (`src/mqtt/`)
- Automatic connection to Meshtastic MQTT broker
- Real-time message processing
- Support for multiple message types:
- Text messages
- Position updates
- Node information
- Telemetry data
- Message publishing for sending to mesh
- Automatic reconnection handling
#### REST API (`src/routes/api.js`)
Complete API with endpoints for:
- Authentication (login/logout)
- Node management (list all, get details)
- Position tracking (latest positions, history)
- Message history (view and send)
- Telemetry data
- Statistics dashboard
- Data purging
#### Background Services (`src/services/`)
- Cron service for scheduled tasks
- Automatic data purging based on retention policy
- Configurable schedule (default: daily at 2 AM)
#### Logging System (`src/utils/`)
- Winston-based structured logging
- Multiple log files (combined, errors)
- Log rotation
- Configurable log levels
#### Configuration (`src/config/`)
- Environment variable based configuration
- Secure defaults
- Easy customization for different deployments
### Frontend (Vanilla JavaScript)
#### Modern Web Interface (`public/`)
- **Responsive Design**: Works on desktop, tablet, and mobile
- **Clean UI**: Modern styling with CSS variables for easy theming
- **No Framework Dependencies**: Pure JavaScript for simplicity
#### Features:
1. **Login Screen**
- Secure authentication
- Form validation
- Error handling
2. **Dashboard Overview**
- Real-time statistics cards
- Recent message feed
- MQTT connection status indicator
3. **Interactive Map**
- Leaflet.js integration
- GPS position tracking
- Clickable markers with node info
- Auto-fit to show all nodes
4. **Message Management**
- Send messages to mesh network
- View message history with metadata
- Channel selection
- Signal quality indicators (SNR, RSSI)
5. **Node Monitoring**
- Grid view of all nodes
- Online/offline status
- Detailed node information modal
- Battery levels, signal strength
- Hardware information
6. **Settings Panel**
- Manual data purging
- Configurable retention periods
- Application information
### Security Features
- Password hashing with bcrypt (10 rounds)
- Session-based authentication
- HTTP security headers (Helmet)
- Rate limiting on API endpoints
- CSRF protection ready
- Activity logging for audit trails
- Secure session cookies
### Database Schema
**Tables Created:**
1. `users` - User accounts
2. `nodes` - Meshtastic node information
3. `positions` - GPS location history
4. `messages` - Text message history
5. `telemetry` - Device telemetry data
6. `activity_log` - User activity tracking
**Indexes Created:**
- Node ID lookups
- Position timestamps
- Message timestamps and senders
- Telemetry tracking
### Configuration Options
All configurable via `.env`:
- Server port
- MQTT broker settings
- Session secrets
- Data retention policies
- Cron schedules
- Log levels
- Rate limiting
## File Structure
```
meshtastic-mqtt-dashboard/
├── src/
│ ├── auth/
│ │ └── auth.js # Authentication logic
│ ├── config/
│ │ └── config.js # Configuration management
│ ├── database/
│ │ ├── db.js # Database initialization
│ │ └── queries.js # Prepared statements
│ ├── mqtt/
│ │ └── client.js # MQTT client implementation
│ ├── routes/
│ │ └── api.js # REST API endpoints
│ ├── scripts/
│ │ └── createUser.js # User creation utility
│ ├── services/
│ │ └── cron.js # Background job scheduler
│ ├── utils/
│ │ └── logger.js # Logging utility
│ └── server.js # Main application entry
├── public/
│ ├── css/
│ │ └── style.css # Modern UI styles
│ ├── js/
│ │ └── app.js # Frontend application
│ └── index.html # Single page app
├── .env.example # Environment template
├── .gitignore # Git ignore rules
├── package.json # Dependencies
├── README.md # Full documentation
├── QUICKSTART.md # Quick start guide
└── PROJECT_SUMMARY.md # This file
```
## Technology Stack
### Backend
- **Node.js**: Runtime environment
- **Express.js**: Web framework
- **better-sqlite3**: Fast SQLite database
- **mqtt**: MQTT client library
- **bcryptjs**: Password hashing
- **express-session**: Session management
- **winston**: Logging
- **node-cron**: Job scheduling
- **helmet**: Security headers
- **express-rate-limit**: Rate limiting
### Frontend
- **Vanilla JavaScript**: No frameworks, pure JS
- **Leaflet.js**: Interactive maps
- **OpenStreetMap**: Map tiles
- **CSS3**: Modern styling with variables
- **Fetch API**: HTTP requests
### Database
- **SQLite**: Embedded database
- **WAL mode**: Better concurrency
## Key Features Implemented
✅ MQTT automatic connection and message storage
✅ SQLite database with comprehensive schema
✅ User authentication with bcrypt
✅ Session management
✅ REST API for all operations
✅ Interactive map with GPS tracking
✅ Message viewing and sending
✅ Node monitoring and metadata
✅ Automatic data purging with cron
✅ Manual data purging button
✅ Activity logging
✅ Comprehensive error handling
✅ Security best practices
✅ Modern, responsive UI
✅ Real-time statistics
✅ Configurable via environment variables
✅ Complete documentation
## Getting Started
1. Install dependencies: `npm install`
2. Copy environment file: `cp .env.example .env`
3. Edit `.env` with your settings
4. Create user: `npm run create-user`
5. Start server: `npm start`
6. Open browser: `http://localhost:3000`
See `QUICKSTART.md` for detailed instructions.
## Production Ready
This application is production-ready with:
- Proper error handling
- Security best practices
- Logging and monitoring
- Graceful shutdown
- Session management
- Rate limiting
- Input validation
- SQL injection protection (prepared statements)
- XSS protection
- CSRF ready
## Future Enhancement Ideas
- WebSocket support for real-time updates
- User management interface
- Email notifications
- Export data to CSV/JSON
- Advanced filtering and search
- Grafana integration
- Multi-user permissions
- Custom alerts and triggers
- Mobile app (PWA)
## License
MIT License - Free to use, modify, and distribute
## Support
See README.md for troubleshooting and detailed documentation.
---
**Built with ❤️ for the Meshtastic community**
-107
View File
@@ -1,107 +0,0 @@
# Quick Start Guide
Get your Meshtastic MQTT Dashboard up and running in 5 minutes!
## Step 1: Install Dependencies
```bash
npm install
```
## Step 2: Configure Environment
```bash
cp .env.example .env
```
Edit the `.env` file and change the `SESSION_SECRET` to a random string:
```env
SESSION_SECRET=your-random-secret-here-change-this
```
Leave other settings as default to connect to the public Meshtastic MQTT broker.
## Step 3: Create a User Account
```bash
npm run create-user
```
Enter your desired username and password when prompted.
Example:
```
Enter username: admin
Enter password: ********
Confirm password: ********
User 'admin' created successfully!
```
## Step 4: Start the Application
```bash
npm start
```
You should see:
```
Connected to MQTT broker
Subscribed to topic: msh/US/#
Server running on http://localhost:3000
```
## Step 5: Access the Dashboard
Open your web browser and navigate to:
```
http://localhost:3000
```
Login with the username and password you created in Step 3.
## What's Next?
- **View Messages**: Navigate to the Messages tab to see incoming mesh messages
- **Check the Map**: Click on the Map tab to see node locations
- **Monitor Nodes**: View all discovered nodes in the Nodes tab
- **Send Messages**: Use the message form to send text to the mesh network
## Troubleshooting
**Can't see any data?**
- Wait a few minutes for MQTT messages to arrive
- Check that the MQTT status indicator in the header shows as connected (green dot)
- The public Meshtastic network may have variable activity depending on your region
**Can't login?**
- Make sure you created a user with `npm run create-user`
- Check that the SESSION_SECRET is set in your `.env` file
- Try clearing your browser cookies
**Port 3000 already in use?**
- Change the PORT in your `.env` file to another port (e.g., 3001)
- Restart the application
## Default Configuration
The default configuration connects to:
- **MQTT Broker**: mqtt.meshtastic.org (public)
- **Topic**: msh/US/# (all US channels)
- **Port**: 3000
- **Data Retention**: 30 days
To change these, edit your `.env` file.
## Production Deployment
For production use:
1. Change `NODE_ENV=production` in `.env`
2. Use a strong, random `SESSION_SECRET`
3. Set up HTTPS with a reverse proxy (nginx, Caddy, etc.)
4. Configure firewall rules
5. Set up automatic backups of the `data/` directory
Enjoy your Meshtastic dashboard!
+14 -233
View File
@@ -1,12 +1,13 @@
# Meshtastic MQTT Dashboard
# Meshcore Dashboard (USB-based)
A complete, self-contained Node.js web application for monitoring and managing Meshtastic networks via MQTT. Features real-time message tracking, GPS location mapping, node management, and automatic data retention with secure authentication.
A complete, self-contained Node.js web application for monitoring and managing a Meshcore client device via USB. Features real-time message tracking, GPS location mapping, and automatic data retention with secure authentication.
![Dashboard Preview](https://via.placeholder.com/800x400?text=Meshtastic+Dashboard)
![Dashboard Screenshot](screenshot-1.png)
![Messaging Screenshot](screenshot-2.png)
## Features
- **Real-time MQTT Integration** - Automatically connects to Meshtastic MQTT broker and stores all messages
- **Real-time USB Serial Integration** - Automatically connects to Meshcore USB serial device and stores all messages
- **Interactive Map View** - View GPS locations of all nodes on an interactive Leaflet map
- **Message Management** - View message history and send messages to the mesh network
- **Node Monitoring** - Track all nodes with detailed metadata including battery levels, signal strength, and telemetry
@@ -16,42 +17,18 @@ A complete, self-contained Node.js web application for monitoring and managing M
- **SQLite Database** - All data stored locally in a SQLite database
- **Comprehensive Logging** - Winston-based logging for debugging and monitoring
## Architecture
```
meshtastic-mqtt-dashboard/
├── src/
│ ├── auth/ # Authentication and authorization
│ ├── config/ # Application configuration
│ ├── database/ # SQLite database setup and queries
│ ├── mqtt/ # MQTT client and message handlers
│ ├── routes/ # Express API routes
│ ├── scripts/ # Utility scripts (user creation, etc.)
│ ├── services/ # Background services (cron jobs)
│ ├── utils/ # Utilities (logging, etc.)
│ └── server.js # Main application entry point
├── public/
│ ├── css/ # Stylesheets
│ ├── js/ # Frontend JavaScript
│ └── index.html # Main HTML file
├── data/ # SQLite database (auto-created)
├── logs/ # Application logs (auto-created)
├── .env # Environment configuration
└── package.json # Dependencies
```
## Prerequisites
- Node.js 16.x or higher
- npm or yarn
- Access to a Meshtastic MQTT broker (default: mqtt.meshtastic.org)
- Access to a Meshcore device via USB-Serial
## Installation
1. **Clone or navigate to the project directory:**
```bash
cd meshtastic-mqtt-dashboard
cd meshcore-usb-dashboard
```
2. **Install dependencies:**
@@ -68,32 +45,7 @@ Copy the example environment file and edit it:
cp .env.example .env
```
Edit `.env` with your settings:
```env
# Server Configuration
PORT=3000
NODE_ENV=production
# Session Secret (CHANGE THIS!)
SESSION_SECRET=your-random-secret-string-here
# MQTT Configuration
MQTT_BROKER=mqtt://mqtt.meshtastic.org
MQTT_PORT=1883
MQTT_USERNAME=meshdev
MQTT_PASSWORD=large4cats
MQTT_TOPIC=msh/US/#
# Data Retention (in days)
DATA_RETENTION_DAYS=30
# Cron schedule for automatic data purging (daily at 2 AM)
PURGE_CRON_SCHEDULE=0 2 * * *
# Logging
LOG_LEVEL=info
```
Edit `.env` with your settings, especially the name of your serial port (i.e. COM10)
4. **Create your first user:**
@@ -124,22 +76,6 @@ http://localhost:3000
Login with the username and password you created.
## Configuration
### MQTT Settings
The application connects to the Meshtastic MQTT broker to receive messages. Configure these settings in `.env`:
- **MQTT_BROKER**: The MQTT broker URL (default: mqtt://mqtt.meshtastic.org)
- **MQTT_USERNAME**: MQTT username (default: meshdev)
- **MQTT_PASSWORD**: MQTT password (default: large4cats)
- **MQTT_TOPIC**: MQTT topic to subscribe to (default: msh/US/# for all US channels)
To monitor a specific region, change the topic:
- `msh/US/#` - All US channels
- `msh/EU/#` - All EU channels
- `msh/US/2/json/#` - Specific channel
### Data Retention
Configure automatic data purging:
@@ -149,98 +85,9 @@ Configure automatic data purging:
You can also manually purge data from the Settings tab in the web interface.
### Security
## Database
**IMPORTANT**: Change the `SESSION_SECRET` in your `.env` file to a random string for production use.
## Usage
### Dashboard Overview
The dashboard provides several tabs:
1. **Overview** - Statistics and recent messages at a glance
2. **Map** - Interactive map showing GPS locations of all nodes
3. **Messages** - Send and view message history
4. **Nodes** - View all discovered nodes and their metadata
5. **Settings** - Data management and application settings
### Sending Messages
1. Navigate to the **Messages** tab
2. Type your message in the text field
3. Choose a device to send **From**
4. Click **Send**
Messages will be broadcast to the mesh network via MQTT.
### Viewing Node Details
1. Navigate to the **Nodes** tab
2. Click on any node card to view detailed information
3. Modal will display:
- Node ID and names
- Hardware model and firmware version
- Battery level and voltage
- Signal metrics
- Last heard timestamp
### Map View
The **Map** tab displays GPS locations of all nodes:
- Click on markers to see node details
- Map automatically fits to show all nodes
- Click **Refresh Map** to update positions
### Data Management
From the **Settings** tab:
1. Select retention period (7, 14, 30, 60, or 90 days)
2. Click **Purge Old Data**
3. Confirm the action
This will delete messages, positions, and telemetry older than the selected period.
## API Endpoints
The application provides a REST API for programmatic access:
### Authentication
- `POST /api/login` - Login with username/password
- `POST /api/logout` - Logout current session
- `GET /api/auth/status` - Check authentication status
### Data Access
- `GET /api/nodes` - Get all nodes
- `GET /api/nodes/:nodeId` - Get specific node details
- `GET /api/positions` - Get latest positions for all nodes
- `GET /api/positions/:nodeId` - Get position history for a node
- `GET /api/messages` - Get recent messages
- `GET /api/messages/node/:nodeId` - Get messages for a specific node
- `GET /api/telemetry/:nodeId` - Get telemetry history for a node
- `GET /api/stats` - Get dashboard statistics
### Actions
- `POST /api/messages/send` - Send a message to the mesh
- `POST /api/purge` - Purge old data
### Status
- `GET /api/mqtt/status` - Get MQTT connection status
All endpoints (except login) require authentication.
## Database Schema
The application uses SQLite with the following tables:
- **users** - User accounts for authentication
- **nodes** - Meshtastic node information
- **positions** - GPS position updates
- **messages** - Text messages
- **telemetry** - Device telemetry data
- **activity_log** - User activity logging
The application uses SQLite.
Database file location: `data/meshtastic.db`
@@ -253,81 +100,15 @@ Logs are stored in the `logs/` directory:
Log level can be configured with `LOG_LEVEL` in `.env` (debug, info, warn, error).
## Troubleshooting
### Cannot connect to MQTT broker
1. Check your internet connection
2. Verify MQTT broker URL in `.env`
3. Check username/password if using a private broker
4. Review logs in `logs/error.log`
### No data appearing
1. Verify MQTT connection (check status indicator in header)
2. Ensure MQTT topic is correct for your region
3. Check that there is active mesh traffic on the topic
4. Review logs for any error messages
### Messages sent over MQTT aren't sent over the radio
1. Ensure the sending radio (node) has a channel called "mqtt"
2. Ensure the node has Uplink and Downlink enabled on both the default channel (LongFast, etc) and the mqtt channel
3. Ensure the node's setting under LoRa is "Ok to MQTT"
### Database errors
1. Ensure the `data/` directory is writable
2. Check disk space
3. Try deleting `data/meshtastic.db` and restarting (this will delete all data)
### Login issues
1. Verify user was created successfully with `npm run create-user`
2. Check SESSION_SECRET is set in `.env`
3. Clear browser cookies and try again
## T-Deck
- It's touch screen, which is often easier than using the trackball.
- To pair over Bluetooth, power on the T-Deck and LONG PRESS (about 2 seconds) the Meshtastic logo.
- To set the timezone properly, it should be `PST8PDT,M3.2.0,M11.1.0` for PST
- To get map tiles: https://www.jeffgeerling.com/blog/2025/adding-gps-and-grid-maps-my-meshtastic-t-deck
## Development
### Project Structure
- **Backend**: Express.js server with REST API
- **Frontend**: Vanilla JavaScript (no frameworks)
- **Database**: SQLite with better-sqlite3
- **MQTT**: mqtt.js client
- **Map**: Leaflet.js for interactive maps
- **Logging**: Winston for structured logging
- **Security**: bcrypt for password hashing, express-session for sessions
### Adding Features
1. **New API endpoint**: Add route to `src/routes/api.js`
2. **New database table**: Modify `src/database/db.js` and `src/database/queries.js`
3. **MQTT message handler**: Update `src/mqtt/client.js`
4. **Frontend UI**: Modify `public/index.html`, `public/css/style.css`, and `public/js/app.js`
## Contributing
Contributions are welcome! Please ensure:
1. Code follows existing style conventions
2. All features are properly documented
3. Security best practices are followed
4. No sensitive data in commits
Contributions are welcome!
## Security Considerations
- Change default `SESSION_SECRET` in production
- Use HTTPS in production (configure reverse proxy)
- Regularly update dependencies
- Review and limit access to MQTT credentials
- Use strong passwords for user accounts
- Enable firewall rules to restrict access
@@ -341,12 +122,12 @@ For issues and feature requests, please open an issue on the project repository.
## Acknowledgments
- Meshtastic project for the excellent mesh networking platform
- Meshcore project
- OpenStreetMap for map tiles
- All contributors and testers
---
**Version**: 1.0.0
**Author**: Meshtastic Community
**Last Updated**: 2025
**Author**: zyphlar
**Last Updated**: 2026
-55
View File
@@ -1,55 +0,0 @@
meshtastic-mqtt-dashboard/
├── src/ # Backend source code
│ ├── auth/
│ │ └── auth.js # User authentication & authorization
│ │
│ ├── config/
│ │ └── config.js # Application configuration
│ │
│ ├── database/
│ │ ├── db.js # SQLite database setup
│ │ └── queries.js # Database query definitions
│ │
│ ├── mqtt/
│ │ └── client.js # MQTT client & message handlers
│ │
│ ├── routes/
│ │ └── api.js # REST API endpoints
│ │
│ ├── scripts/
│ │ └── createUser.js # CLI tool to create users
│ │
│ ├── services/
│ │ └── cron.js # Background job scheduler
│ │
│ ├── utils/
│ │ └── logger.js # Winston logging utility
│ │
│ └── server.js # Main application entry point
├── public/ # Frontend static files
│ ├── css/
│ │ └── style.css # Modern UI stylesheet
│ │
│ ├── js/
│ │ └── app.js # Frontend JavaScript application
│ │
│ └── index.html # Single Page Application HTML
├── data/ # Database storage (auto-created)
│ └── meshtastic.db # SQLite database file
├── logs/ # Application logs (auto-created)
│ ├── combined.log # All logs
│ └── error.log # Error logs only
├── .env # Environment configuration (create from .env.example)
├── .env.example # Environment template
├── .gitignore # Git ignore rules
├── package.json # NPM dependencies & scripts
├── README.md # Complete documentation
├── QUICKSTART.md # Quick start guide
├── PROJECT_SUMMARY.md # Project overview
└── STRUCTURE.txt # This file - directory structure
-49
View File
@@ -1,49 +0,0 @@
// Check database for received data
const db = require('./src/database/db');
const { statsQueries, messageQueries, nodeQueries, positionQueries } = require('./src/database/queries');
console.log('\n=== Database Statistics ===\n');
try {
const stats = {
messages: statsQueries.getMessageCount.get(),
nodes: statsQueries.getNodeCount.get(),
positions: statsQueries.getPositionCount.get(),
dbSize: statsQueries.getDbSize()
};
console.log(`Messages: ${stats.messages.count}`);
console.log(`Nodes: ${stats.nodes.count}`);
console.log(`Positions: ${stats.positions.count}`);
console.log(`DB Size: ${(stats.dbSize / 1024 / 1024).toFixed(2)} MB`);
if (stats.messages.count > 0) {
console.log('\n=== Recent Messages ===\n');
const messages = messageQueries.getRecentMessages.all(5);
messages.forEach(msg => {
console.log(`[${msg.created_at}] ${msg.from_node}: ${msg.text || '(no text)'}`);
});
}
if (stats.nodes.count > 0) {
console.log('\n=== Nodes ===\n');
const nodes = nodeQueries.getAllNodes.all();
nodes.forEach(node => {
console.log(`${node.node_id} - ${node.long_name || node.short_name || 'Unknown'} (${node.hardware_model || 'N/A'})`);
});
}
if (stats.positions.count > 0) {
console.log('\n=== Recent Positions ===\n');
const positions = positionQueries.getLatestPositions.all();
positions.slice(0, 5).forEach(pos => {
console.log(`${pos.node_id}: ${pos.latitude}, ${pos.longitude} @ ${pos.timestamp}`);
});
}
console.log('\n');
process.exit(0);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
+7 -7
View File
@@ -1,32 +1,32 @@
{
"name": "meshtastic-mqtt-dashboard",
"name": "meshcore-usb-dashboard",
"version": "1.0.0",
"description": "Complete Meshtastic MQTT Dashboard with web frontend",
"description": "MeshCore USB Serial Dashboard with web frontend",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"init-db": "node src/database/init.js",
"create-user": "node src/scripts/createUser.js"
},
"keywords": [
"meshtastic",
"mqtt",
"meshcore",
"serial",
"dashboard",
"iot"
],
"author": "",
"license": "MIT",
"dependencies": {
"@liamcottle/meshcore.js": "latest",
"express": "^4.18.2",
"express-session": "^1.17.3",
"bcryptjs": "^2.4.3",
"mqtt": "^5.3.4",
"better-sqlite3": "^9.2.2",
"dotenv": "^16.3.1",
"winston": "^3.11.0",
"node-cron": "^3.0.3",
"protobufjs": "^7.2.5",
"serialport": "^12.0.0",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"cors": "^2.8.5"
+183 -369
View File
@@ -28,14 +28,8 @@ body {
line-height: 1.6;
}
/* Screen Management */
.screen {
min-height: 100vh;
}
.hidden {
display: none !important;
}
.screen { min-height: 100vh; }
.hidden { display: none !important; }
/* Login Screen */
.login-container {
@@ -43,7 +37,7 @@ body {
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
}
.login-box {
@@ -55,18 +49,8 @@ body {
max-width: 400px;
}
.login-box h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
text-align: center;
color: var(--text-primary);
}
.subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.login-box h1 { font-size: 2rem; margin-bottom: 0.5rem; text-align: center; }
.subtitle { text-align: center; color: var(--text-secondary); margin-bottom: 2rem; }
/* Header */
.header {
@@ -79,9 +63,18 @@ body {
box-shadow: var(--shadow);
}
.header h1 {
font-size: 1.5rem;
color: var(--text-primary);
.header-left { display: flex; align-items: center; gap: 1rem; }
.header h1 { font-size: 1.5rem; color: var(--text-primary); }
.device-badge {
background: #eff6ff;
color: #1d4ed8;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
font-family: monospace;
border: 1px solid #bfdbfe;
}
.header-right {
@@ -90,7 +83,7 @@ body {
gap: 1.5rem;
}
.mqtt-status {
.serial-status {
display: flex;
align-items: center;
gap: 0.5rem;
@@ -105,14 +98,9 @@ body {
background-color: var(--secondary-color);
}
.status-dot.connected {
background-color: var(--success-color);
}
.status-dot.connected { background-color: var(--success-color); }
.user-info {
color: var(--text-secondary);
font-size: 0.875rem;
}
.user-info { color: var(--text-secondary); font-size: 0.875rem; }
.data-last-received {
display: flex;
@@ -122,11 +110,9 @@ body {
color: var(--text-secondary);
}
.data-label {
opacity: 0.7;
}
.data-label { opacity: 0.7; }
/* Navigation Tabs */
/* Navigation */
.nav-tabs {
background: var(--surface-color);
border-bottom: 1px solid var(--border-color);
@@ -147,29 +133,13 @@ body {
transition: all 0.2s;
}
.nav-tab:hover {
color: var(--primary-color);
}
.nav-tab:hover { color: var(--primary-color); }
.nav-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
.nav-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
/* Content Area */
.content {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Content */
.content { padding: 2rem; max-width: 1400px; margin: 0 auto; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Stats Grid */
.stats-grid {
@@ -189,20 +159,9 @@ body {
gap: 1rem;
}
.stat-icon {
font-size: 2.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat-icon { font-size: 2.5rem; }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--text-primary); }
.stat-label { font-size: 0.875rem; color: var(--text-secondary); }
/* Panel */
.panel {
@@ -213,34 +172,30 @@ body {
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
.panel h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text-primary); }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.panel-header h2 { margin: 0; }
/* Device Info */
.device-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
.device-info-item {
background: var(--bg-color);
padding: 0.75rem 1rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
}
.panel-header h2 {
margin: 0;
}
.device-info-label { font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem; }
.device-info-value { font-size: 0.95rem; font-family: monospace; color: var(--text-primary); font-weight: 600; }
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.form-group { margin-bottom: 1.5rem; }
.form-group label { display: block; margin-bottom: 0.5rem; color: var(--text-primary); font-weight: 500; }
.form-group input,
.form-group select {
@@ -258,19 +213,14 @@ body {
border-color: var(--primary-color);
}
.form-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.form-row { display: flex; gap: 0.5rem; align-items: center; }
.form-row input { flex: 1; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: 0.375rem; font-size: 1rem; }
.form-row input:focus { outline: none; border-color: var(--primary-color); }
.form-row select { min-width: 140px; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: 0.375rem; font-size: 0.9rem; }
.form-row input {
flex: 1;
}
.form-row select {
min-width: 120px;
}
.radio-group { display: flex; gap: 1.5rem; }
.radio-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 500; color: var(--text-primary); }
.radio-label input[type="radio"] { width: auto; }
/* Buttons */
.btn {
@@ -281,75 +231,30 @@ body {
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #475569;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #dc2626;
}
.btn-primary { background-color: var(--primary-color); color: white; }
.btn-primary:hover { background-color: var(--primary-hover); }
.btn-secondary { background-color: var(--secondary-color); color: white; }
.btn-secondary:hover { background-color: #475569; }
.btn-danger { background-color: var(--danger-color); color: white; }
.btn-danger:hover { background-color: #dc2626; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Messages */
.message-list {
max-height: 600px;
overflow-y: auto;
}
.message-list { max-height: 600px; overflow-y: auto; }
.message-item {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.message-item { padding: 1rem; border-bottom: 1px solid var(--border-color); }
.message-item:last-child { border-bottom: none; }
.message-item.outbound { background: #f0f9ff; }
.message-item:last-child {
border-bottom: none;
}
.message-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; align-items: center; }
.message-from { font-weight: 600; color: var(--text-primary); }
.message-time { font-size: 0.875rem; color: var(--text-secondary); }
.message-text { color: var(--text-primary); }
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.message-from {
font-weight: 600;
color: var(--text-primary);
}
.message-time {
font-size: 0.875rem;
color: var(--text-secondary);
}
.message-text {
color: var(--text-primary);
}
.message-meta {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.message-meta { margin-top: 0.5rem; font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 0.75rem; flex-wrap: wrap; }
.message-channel {
display: inline-block;
@@ -361,7 +266,19 @@ body {
text-transform: uppercase;
}
/* Nodes Grid */
.message-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 500;
}
.badge-direct { background: #eff6ff; color: #1d4ed8; }
.badge-channel { background: #f0fdf4; color: #166534; }
.badge-delivered { background: #d1fae5; color: #065f46; }
/* Contacts Grid */
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
@@ -377,28 +294,12 @@ body {
transition: all 0.2s;
}
.node-card:hover {
box-shadow: var(--shadow);
transform: translateY(-2px);
}
.node-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.node-card.favourite { border-color: #fbbf24; background: #fffbeb; }
.node-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.75rem;
}
.node-name {
font-weight: 600;
color: var(--text-primary);
}
.node-id {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
}
.node-card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem; }
.node-name { font-weight: 600; color: var(--text-primary); }
.node-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
.node-badge {
padding: 0.25rem 0.5rem;
@@ -407,212 +308,125 @@ body {
font-weight: 500;
}
.node-badge.online {
background-color: #d1fae5;
color: #065f46;
}
.node-badge.online { background-color: #d1fae5; color: #065f46; }
.node-badge.offline { background-color: #fee2e2; color: #991b1b; }
.node-badge.type-chat { background: #eff6ff; color: #1d4ed8; }
.node-badge.type-repeater { background: #f0fdf4; color: #166534; }
.node-badge.type-room { background: #fdf4ff; color: #7e22ce; }
.node-badge.type-sensor { background: #fff7ed; color: #9a3412; }
.node-badge.offline {
background-color: #fee2e2;
color: #991b1b;
}
.node-info { font-size: 0.875rem; color: var(--text-secondary); }
.node-info-row { display: flex; justify-content: space-between; margin-bottom: 0.25rem; }
.node-info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.node-info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
/* Ports List */
.ports-list { margin-top: 1rem; }
.port-item { display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--border-color); font-family: monospace; font-size: 0.9rem; }
/* Map */
.map-container {
height: 600px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
}
.map-controls {
display: flex;
gap: 0.5rem;
}
.map-container { height: 600px; border-radius: 0.5rem; overflow: hidden; margin-bottom: 1rem; }
.map-controls { display: flex; gap: 0.5rem; }
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000;
}
.modal-content {
background: var(--surface-color);
padding: 2rem;
border-radius: 0.5rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
background: var(--surface-color); padding: 2rem; border-radius: 0.5rem;
max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
}
.modal-close { position: absolute; top: 1rem; right: 1rem; font-size: 1.5rem; cursor: pointer; color: var(--text-secondary); }
.modal-close:hover { color: var(--text-primary); }
.node-detail { margin-top: 1rem; }
.modal-close:hover {
color: var(--text-primary);
}
.detail-row { display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--border-color); }
.detail-label { font-weight: 500; color: var(--text-secondary); }
.detail-value { color: var(--text-primary); font-family: monospace; word-break: break-all; max-width: 60%; text-align: right; }
.node-detail {
margin-top: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-label {
font-weight: 500;
color: var(--text-secondary);
}
.detail-value {
color: var(--text-primary);
font-family: monospace;
}
/* Error Messages */
.error-message {
color: var(--danger-color);
font-size: 0.875rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
/* Success Messages */
.success-message {
color: var(--success-color);
font-size: 0.875rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
/* Error / Success */
.error-message { color: var(--danger-color); font-size: 0.875rem; margin-top: 0.5rem; margin-bottom: 1rem; }
.success-message { color: var(--success-color); font-size: 0.875rem; margin-top: 0.5rem; }
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
.empty-state { text-align: center; padding: 3rem; color: var(--text-secondary); }
.empty-state-icon { font-size: 3rem; margin-bottom: 1rem; }
/* Notification */
.notification {
position: fixed; top: 1rem; right: 1rem; padding: 1rem 1.5rem;
border-radius: 0.5rem; box-shadow: var(--shadow-lg); z-index: 2000;
max-width: 350px; animation: slideIn 0.3s ease;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
.notification.success { background: #d1fae5; color: #065f46; border-left: 4px solid var(--success-color); }
.notification.warning { background: #fef3c7; color: #92400e; border-left: 4px solid var(--warning-color); }
.notification.error { background: #fee2e2; color: #991b1b; border-left: 4px solid var(--danger-color); }
.notification.info { background: #eff6ff; color: #1e40af; border-left: 4px solid var(--primary-color); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Responsive Design */
/* Responsive */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.header-right {
width: 100%;
justify-content: space-between;
}
.nav-tabs {
overflow-x: auto;
padding: 0 1rem;
}
.content {
padding: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.form-row {
flex-direction: column;
}
.form-row select {
width: 100%;
}
.nodes-grid {
grid-template-columns: 1fr;
}
.header { flex-direction: column; gap: 1rem; align-items: flex-start; }
.header-right { width: 100%; justify-content: space-between; }
.nav-tabs { overflow-x: auto; padding: 0 1rem; }
.content { padding: 1rem; }
.stats-grid { grid-template-columns: 1fr; }
.form-row { flex-direction: column; }
.form-row select { width: 100%; }
.nodes-grid { grid-template-columns: 1fr; }
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-color); }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--secondary-color); }
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary-color);
}
/* Loading Indicator */
/* Loading */
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block; width: 1rem; height: 1rem;
border: 2px solid var(--border-color); border-top-color: var(--primary-color);
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Map marker pulse animation for newly heard nodes */
@keyframes markerPulse {
0% {
transform: scale(1);
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255, 255, 255, 0.7);
}
50% {
transform: scale(1.6);
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 10px rgba(255, 255, 255, 0);
}
100% {
transform: scale(1);
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255, 255, 255, 0);
}
0% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255,255,255,0.7); }
50% { transform: scale(1.6); box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 10px rgba(255,255,255,0); }
100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255,255,255,0); }
}
.settings-form { max-width: 400px; }
/* Message filter tabs */
.msg-filter-tabs {
display: flex;
gap: 0.25rem;
background: var(--bg-color);
padding: 0.25rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
}
.msg-filter-btn {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.msg-filter-btn:hover { color: var(--primary-color); }
.msg-filter-btn.active { background: var(--surface-color); color: var(--primary-color); box-shadow: var(--shadow); }
+62 -30
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meshtastic MQTT Dashboard</title>
<title>MeshCore Dashboard</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="http://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
@@ -12,8 +12,8 @@
<div id="login-screen" class="screen">
<div class="login-container">
<div class="login-box">
<h1>Meshtastic Dashboard</h1>
<p class="subtitle">MQTT Monitoring & Control</p>
<h1>MeshCore Dashboard</h1>
<p class="subtitle">USB Serial Monitor</p>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
@@ -32,15 +32,15 @@
<!-- Main Dashboard -->
<div id="dashboard-screen" class="screen hidden">
<!-- Header -->
<header class="header">
<div class="header-left">
<h1>Meshtastic Dashboard</h1>
<h1>MeshCore Dashboard</h1>
<span id="device-name-badge" class="device-badge hidden"></span>
</div>
<div class="header-right">
<span class="mqtt-status" id="mqtt-status">
<span class="serial-status" id="serial-status">
<span class="status-dot"></span>
MQTT
USB
</span>
<span class="data-last-received" id="data-last-received" title="Time since last data update">
<span class="data-label">Last data:</span> <span id="data-timer">0s ago</span>
@@ -50,16 +50,14 @@
</div>
</header>
<!-- Navigation -->
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="overview">Overview</button>
<button class="nav-tab" data-tab="map">Map</button>
<button class="nav-tab" data-tab="messages">Messages</button>
<button class="nav-tab" data-tab="nodes">Nodes</button>
<button class="nav-tab" data-tab="contacts">Contacts</button>
<button class="nav-tab" data-tab="settings">Settings</button>
</nav>
<!-- Tab Contents -->
<div class="content">
<!-- Overview Tab -->
<div id="overview-tab" class="tab-content active">
@@ -67,8 +65,8 @@
<div class="stat-card">
<div class="stat-icon">📡</div>
<div class="stat-info">
<div class="stat-value" id="stat-nodes">0</div>
<div class="stat-label">Active Nodes</div>
<div class="stat-value" id="stat-contacts">0</div>
<div class="stat-label">Contacts</div>
</div>
</div>
<div class="stat-card">
@@ -94,6 +92,11 @@
</div>
</div>
<div id="device-info-panel" class="panel hidden">
<h2>Device</h2>
<div id="device-info-content" class="device-info-grid"></div>
</div>
<div class="panel">
<h2>Recent Messages</h2>
<div id="recent-messages" class="message-list"></div>
@@ -113,13 +116,32 @@
<div class="panel">
<h2>Send Message</h2>
<form id="send-message-form" class="message-form">
<div class="form-group">
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="msg-type" value="direct" id="type-direct" checked>
Direct
</label>
<label class="radio-label">
<input type="radio" name="msg-type" value="channel" id="type-channel">
Channel
</label>
</div>
</div>
<div class="form-row">
<input type="text" id="message-text" placeholder="Type your message..." required>
<select id="message-from">
<option value="474572292">!1c496604</option>
<select id="message-to-contact" title="Select contact">
<option value="">-- Select contact --</option>
</select>
<select id="message-channel">
<!-- Options populated dynamically from config -->
<select id="message-channel-idx" class="hidden" title="Select channel">
<option value="0">Public (Ch 0)</option>
<option value="1">Channel 1</option>
<option value="2">Channel 2</option>
<option value="3">Channel 3</option>
<option value="4">Channel 4</option>
<option value="5">Channel 5</option>
<option value="6">Channel 6</option>
<option value="7">Channel 7</option>
</select>
<button type="submit" class="btn btn-primary">Send</button>
</div>
@@ -128,38 +150,50 @@
<div class="panel">
<div class="panel-header">
<h2>Message History</h2>
<h2>Messages</h2>
<div style="display:flex;gap:0.5rem;align-items:center">
<div class="msg-filter-tabs" id="msg-filter-tabs">
<button class="msg-filter-btn active" data-filter="all">All</button>
<button class="msg-filter-btn" data-filter="channel" data-ch="0">Public (Ch 0)</button>
<button class="msg-filter-btn" data-filter="direct">Direct</button>
</div>
<button id="refresh-messages-btn" class="btn btn-secondary">Refresh</button>
</div>
</div>
<div id="message-history" class="message-list"></div>
</div>
</div>
<!-- Nodes Tab -->
<div id="nodes-tab" class="tab-content">
<!-- Contacts Tab -->
<div id="contacts-tab" class="tab-content">
<div class="panel">
<div class="panel-header">
<h2>Meshtastic Nodes</h2>
<button id="refresh-nodes-btn" class="btn btn-secondary">Refresh</button>
<h2>MeshCore Contacts</h2>
<button id="refresh-contacts-btn" class="btn btn-secondary">Refresh</button>
</div>
<div id="nodes-grid" class="nodes-grid"></div>
<div id="contacts-grid" class="nodes-grid"></div>
</div>
<!-- Node Detail Modal -->
<div id="node-modal" class="modal hidden">
<div id="contact-modal" class="modal hidden">
<div class="modal-content">
<span class="modal-close">&times;</span>
<h2>Node Details</h2>
<div id="node-detail" class="node-detail"></div>
<h2>Contact Details</h2>
<div id="contact-detail" class="node-detail"></div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-content">
<div class="panel">
<h2>Serial Port</h2>
<div id="port-info"></div>
<div id="available-ports" class="ports-list"></div>
</div>
<div class="panel">
<h2>Data Management</h2>
<p>Purge old data from the database to free up space.</p>
<p>Purge old data from the database.</p>
<form id="purge-form" class="settings-form">
<div class="form-group">
<label for="purge-hours">Keep data from the last:</label>
@@ -182,16 +216,14 @@
<div class="panel">
<h2>About</h2>
<p>Meshtastic MQTT Dashboard - A complete monitoring solution for Meshtastic networks.</p>
<p>MeshCore USB Dashboard — Monitor and control your MeshCore network over USB serial.</p>
<p>Version 1.0.0</p>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="http://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="http://unpkg.com/d3@7/dist/d3.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+584 -1139
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+12 -48
View File
@@ -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 {
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 isValid = await verifyPassword(password, user.password_hash);
if (!isValid) {
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
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;
}
}
// 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
View File
@@ -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
View File
@@ -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
View File
@@ -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 (?, ?, ?, ?)`),
};
-405
View File
@@ -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;
+154 -185
View File
@@ -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
router.post('/purge', requireAuth, (req, res) => {
try {
const { hours } = req.body;
const hoursToKeep = hours || 720; // Default to 30 days (720 hours)
logger.info(`Starting purge of data older than ${hoursToKeep} hours`);
// 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`);
// 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
});
});
const nodesDeleted = nodeQueries.deleteOldNodes.run(hoursToKeep, hoursToKeep, hoursToKeep, hoursToKeep);
logger.info(`Deleted ${nodesDeleted.changes} old nodes`);
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' });
}
});
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records, ${nodesDeleted.changes} nodes`);
// Device self info
router.get('/device', requireAuth, (req, res) => {
const self = serialClient.getSelfInfo();
if (!self) return res.json({ available: false });
// 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
);
const pubkeyHex = self.publicKey
? (Buffer.isBuffer(self.publicKey) ? self.publicKey.toString('hex') : self.publicKey)
: null;
res.json({
success: true,
deleted: {
messages: messagesDeleted.changes,
positions: positionsDeleted.changes,
telemetry: telemetryDeleted.changes,
nodes: nodesDeleted.changes
}
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 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(`Purged: ${msgs.changes} messages, ${pos.changes} positions, ${contacts.changes} contacts`);
logActivity(req.session.userId, 'purge_data', `Purged data older than ${hoursToKeep}h`, req.ip);
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;
+6 -17
View File
@@ -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) {
+305
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-45
View File
@@ -1,45 +0,0 @@
// Test script to capture and display JSON messages
const mqtt = require('mqtt');
const client = mqtt.connect('mqtt://mqtt.meshtastic.org', {
username: 'meshdev',
password: 'large4cats',
clientId: `test-${Math.random().toString(16).substr(2, 8)}`
});
client.on('connect', () => {
console.log('Connected! Subscribing to JSON topics...');
client.subscribe('msh/US/+/2/json/#', (err) => {
if (err) {
console.error('Subscribe error:', err);
} else {
console.log('Subscribed! Waiting for JSON messages...\n');
}
});
});
let messageCount = 0;
client.on('message', (topic, message) => {
try {
const data = JSON.parse(message.toString());
messageCount++;
console.log(`\n=== Message #${messageCount} ===`);
console.log(`Topic: ${topic}`);
console.log(`Data:`, JSON.stringify(data, null, 2));
if (messageCount >= 5) {
console.log('\n\nCaptured 5 messages, exiting...');
client.end();
process.exit(0);
}
} catch (e) {
// Not JSON, skip
}
});
setTimeout(() => {
console.log('Timeout - no messages received');
process.exit(1);
}, 30000);
-57
View File
@@ -1,57 +0,0 @@
// Test script to capture nodeinfo messages
const mqtt = require('mqtt');
const client = mqtt.connect('mqtt://mqtt.meshtastic.org', {
username: 'meshdev',
password: 'large4cats',
clientId: `test-${Math.random().toString(16).substr(2, 8)}`
});
client.on('connect', () => {
console.log('Connected! Subscribing to JSON topics...');
client.subscribe('msh/US/+/2/json/#', (err) => {
if (err) {
console.error('Subscribe error:', err);
} else {
console.log('Subscribed! Waiting for nodeinfo messages...\n');
}
});
});
let nodeinfoCount = 0;
const seenTypes = new Set();
client.on('message', (topic, message) => {
try {
const data = JSON.parse(message.toString());
if (!seenTypes.has(data.type)) {
seenTypes.add(data.type);
console.log(`\n=== New Message Type: ${data.type} ===`);
console.log(`Topic: ${topic}`);
console.log(`Data:`, JSON.stringify(data, null, 2));
console.log('\n');
}
if (data.type === 'nodeinfo' && nodeinfoCount < 3) {
nodeinfoCount++;
console.log(`\n=== NodeInfo Message #${nodeinfoCount} ===`);
console.log(`Topic: ${topic}`);
console.log(`Data:`, JSON.stringify(data, null, 2));
console.log('\n');
}
if (nodeinfoCount >= 3 && seenTypes.size >= 5) {
console.log('Captured enough samples, exiting...');
client.end();
process.exit(0);
}
} catch (e) {
// Not JSON, skip
}
});
setTimeout(() => {
console.log('Timeout - captured types:', Array.from(seenTypes));
process.exit(1);
}, 60000);
-25
View File
@@ -1,25 +0,0 @@
// Quick setup script for testing
const { createUser } = require('./src/auth/auth');
async function setup() {
try {
console.log('Creating test user...');
await createUser('admin', 'password123');
console.log('✓ User "admin" created successfully!');
console.log('✓ You can now start the server with: npm start');
console.log('✓ Login credentials: admin / password123');
process.exit(0);
} catch (error) {
if (error.message.includes('already exists')) {
console.log('✓ User "admin" already exists');
console.log('✓ You can now start the server with: npm start');
console.log('✓ Login credentials: admin / password123');
process.exit(0);
} else {
console.error('Error:', error.message);
process.exit(1);
}
}
}
setup();