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 PORT=3000
NODE_ENV=production NODE_ENV=development
SESSION_SECRET=change-this-to-a-random-secret
# Session Secret (CHANGE THIS!) # MeshCore USB Serial
SESSION_SECRET=change-this-to-a-random-secret-string # Windows: COM3, COM4, etc.
# Linux/Mac: /dev/ttyUSB0, /dev/cu.usbmodem14401, etc.
SERIAL_PORT=COM3
# MQTT Configuration # Data retention
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_DAYS=30 DATA_RETENTION_DAYS=30
# Cron schedule for automatic data purging (default: daily at 2 AM)
PURGE_CRON_SCHEDULE=0 2 * * * PURGE_CRON_SCHEDULE=0 2 * * *
# Logging # Logging
LOG_LEVEL=info LOG_LEVEL=info
# Rate Limiting # Rate limiting
RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100 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 ## 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 - **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 - **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 - **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 - **SQLite Database** - All data stored locally in a SQLite database
- **Comprehensive Logging** - Winston-based logging for debugging and monitoring - **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 ## Prerequisites
- Node.js 16.x or higher - Node.js 16.x or higher
- npm or yarn - npm or yarn
- Access to a Meshtastic MQTT broker (default: mqtt.meshtastic.org) - Access to a Meshcore device via USB-Serial
## Installation ## Installation
1. **Clone or navigate to the project directory:** 1. **Clone or navigate to the project directory:**
```bash ```bash
cd meshtastic-mqtt-dashboard cd meshcore-usb-dashboard
``` ```
2. **Install dependencies:** 2. **Install dependencies:**
@@ -68,32 +45,7 @@ Copy the example environment file and edit it:
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` with your settings: Edit `.env` with your settings, especially the name of your serial port (i.e. COM10)
```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
```
4. **Create your first user:** 4. **Create your first user:**
@@ -124,22 +76,6 @@ http://localhost:3000
Login with the username and password you created. 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 ### Data Retention
Configure automatic data purging: 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. 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. The application uses SQLite.
## 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
Database file location: `data/meshtastic.db` 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). 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 ## Contributing
Contributions are welcome! Please ensure: Contributions are welcome!
1. Code follows existing style conventions
2. All features are properly documented
3. Security best practices are followed
4. No sensitive data in commits
## Security Considerations ## Security Considerations
- Change default `SESSION_SECRET` in production - Change default `SESSION_SECRET` in production
- Use HTTPS in production (configure reverse proxy) - Use HTTPS in production (configure reverse proxy)
- Regularly update dependencies - Regularly update dependencies
- Review and limit access to MQTT credentials
- Use strong passwords for user accounts - Use strong passwords for user accounts
- Enable firewall rules to restrict access - Enable firewall rules to restrict access
@@ -341,12 +122,12 @@ For issues and feature requests, please open an issue on the project repository.
## Acknowledgments ## Acknowledgments
- Meshtastic project for the excellent mesh networking platform - Meshcore project
- OpenStreetMap for map tiles - OpenStreetMap for map tiles
- All contributors and testers - All contributors and testers
--- ---
**Version**: 1.0.0 **Version**: 1.0.0
**Author**: Meshtastic Community **Author**: zyphlar
**Last Updated**: 2025 **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", "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", "main": "src/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "nodemon src/server.js", "dev": "nodemon src/server.js",
"init-db": "node src/database/init.js",
"create-user": "node src/scripts/createUser.js" "create-user": "node src/scripts/createUser.js"
}, },
"keywords": [ "keywords": [
"meshtastic", "meshcore",
"mqtt", "serial",
"dashboard", "dashboard",
"iot" "iot"
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@liamcottle/meshcore.js": "latest",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"mqtt": "^5.3.4",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"winston": "^3.11.0", "winston": "^3.11.0",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"protobufjs": "^7.2.5", "serialport": "^12.0.0",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"cors": "^2.8.5" "cors": "^2.8.5"
+183 -369
View File
@@ -28,14 +28,8 @@ body {
line-height: 1.6; line-height: 1.6;
} }
/* Screen Management */ .screen { min-height: 100vh; }
.screen { .hidden { display: none !important; }
min-height: 100vh;
}
.hidden {
display: none !important;
}
/* Login Screen */ /* Login Screen */
.login-container { .login-container {
@@ -43,7 +37,7 @@ body {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
} }
.login-box { .login-box {
@@ -55,18 +49,8 @@ body {
max-width: 400px; max-width: 400px;
} }
.login-box h1 { .login-box h1 { font-size: 2rem; margin-bottom: 0.5rem; text-align: center; }
font-size: 2rem; .subtitle { text-align: center; color: var(--text-secondary); margin-bottom: 2rem; }
margin-bottom: 0.5rem;
text-align: center;
color: var(--text-primary);
}
.subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
}
/* Header */ /* Header */
.header { .header {
@@ -79,9 +63,18 @@ body {
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.header h1 { .header-left { display: flex; align-items: center; gap: 1rem; }
font-size: 1.5rem; .header h1 { font-size: 1.5rem; color: var(--text-primary); }
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 { .header-right {
@@ -90,7 +83,7 @@ body {
gap: 1.5rem; gap: 1.5rem;
} }
.mqtt-status { .serial-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -105,14 +98,9 @@ body {
background-color: var(--secondary-color); background-color: var(--secondary-color);
} }
.status-dot.connected { .status-dot.connected { background-color: var(--success-color); }
background-color: var(--success-color);
}
.user-info { .user-info { color: var(--text-secondary); font-size: 0.875rem; }
color: var(--text-secondary);
font-size: 0.875rem;
}
.data-last-received { .data-last-received {
display: flex; display: flex;
@@ -122,11 +110,9 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.data-label { .data-label { opacity: 0.7; }
opacity: 0.7;
}
/* Navigation Tabs */ /* Navigation */
.nav-tabs { .nav-tabs {
background: var(--surface-color); background: var(--surface-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -147,29 +133,13 @@ body {
transition: all 0.2s; transition: all 0.2s;
} }
.nav-tab:hover { .nav-tab:hover { color: var(--primary-color); }
color: var(--primary-color); .nav-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
}
.nav-tab.active { /* Content */
color: var(--primary-color); .content { padding: 2rem; max-width: 1400px; margin: 0 auto; }
border-bottom-color: var(--primary-color); .tab-content { display: none; }
} .tab-content.active { display: block; }
/* Content Area */
.content {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Stats Grid */ /* Stats Grid */
.stats-grid { .stats-grid {
@@ -189,20 +159,9 @@ body {
gap: 1rem; gap: 1rem;
} }
.stat-icon { .stat-icon { font-size: 2.5rem; }
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-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Panel */ /* Panel */
.panel { .panel {
@@ -213,34 +172,30 @@ body {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.panel h2 { .panel h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text-primary); }
font-size: 1.25rem; .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
margin-bottom: 1rem; .panel-header h2 { margin: 0; }
color: var(--text-primary);
/* Device Info */
.device-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
} }
.panel-header { .device-info-item {
display: flex; background: var(--bg-color);
justify-content: space-between; padding: 0.75rem 1rem;
align-items: center; border-radius: 0.375rem;
margin-bottom: 1rem; border: 1px solid var(--border-color);
} }
.panel-header h2 { .device-info-label { font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem; }
margin: 0; .device-info-value { font-size: 0.95rem; font-family: monospace; color: var(--text-primary); font-weight: 600; }
}
/* Forms */ /* Forms */
.form-group { .form-group { margin-bottom: 1.5rem; }
margin-bottom: 1.5rem; .form-group label { display: block; margin-bottom: 0.5rem; color: var(--text-primary); font-weight: 500; }
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.form-group input, .form-group input,
.form-group select { .form-group select {
@@ -258,19 +213,14 @@ body {
border-color: var(--primary-color); border-color: var(--primary-color);
} }
.form-row { .form-row { display: flex; gap: 0.5rem; align-items: center; }
display: flex; .form-row input { flex: 1; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: 0.375rem; font-size: 1rem; }
gap: 0.5rem; .form-row input:focus { outline: none; border-color: var(--primary-color); }
align-items: center; .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 { .radio-group { display: flex; gap: 1.5rem; }
flex: 1; .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; }
.form-row select {
min-width: 120px;
}
/* Buttons */ /* Buttons */
.btn { .btn {
@@ -281,75 +231,30 @@ body {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.btn-primary { .btn-primary { background-color: var(--primary-color); color: white; }
background-color: var(--primary-color); .btn-primary:hover { background-color: var(--primary-hover); }
color: white; .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-primary:hover { .btn-danger:hover { background-color: #dc2626; }
background-color: var(--primary-hover); .btn:disabled { opacity: 0.5; cursor: not-allowed; }
}
.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;
}
/* Messages */ /* Messages */
.message-list { .message-list { max-height: 600px; overflow-y: auto; }
max-height: 600px;
overflow-y: auto;
}
.message-item { .message-item { padding: 1rem; border-bottom: 1px solid var(--border-color); }
padding: 1rem; .message-item:last-child { border-bottom: none; }
border-bottom: 1px solid var(--border-color); .message-item.outbound { background: #f0f9ff; }
}
.message-item:last-child { .message-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; align-items: center; }
border-bottom: none; .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 { .message-meta { margin-top: 0.5rem; font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 0.75rem; flex-wrap: wrap; }
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-channel { .message-channel {
display: inline-block; display: inline-block;
@@ -361,7 +266,19 @@ body {
text-transform: uppercase; 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 { .nodes-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
@@ -377,28 +294,12 @@ body {
transition: all 0.2s; transition: all 0.2s;
} }
.node-card:hover { .node-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
box-shadow: var(--shadow); .node-card.favourite { border-color: #fbbf24; background: #fffbeb; }
transform: translateY(-2px);
}
.node-card-header { .node-card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem; }
display: flex; .node-name { font-weight: 600; color: var(--text-primary); }
justify-content: space-between; .node-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
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 { .node-badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@@ -407,212 +308,125 @@ body {
font-weight: 500; font-weight: 500;
} }
.node-badge.online { .node-badge.online { background-color: #d1fae5; color: #065f46; }
background-color: #d1fae5; .node-badge.offline { background-color: #fee2e2; color: #991b1b; }
color: #065f46; .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 { .node-info { font-size: 0.875rem; color: var(--text-secondary); }
background-color: #fee2e2; .node-info-row { display: flex; justify-content: space-between; margin-bottom: 0.25rem; }
color: #991b1b;
}
.node-info { /* Ports List */
font-size: 0.875rem; .ports-list { margin-top: 1rem; }
color: var(--text-secondary); .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; }
}
.node-info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
/* Map */ /* Map */
.map-container { .map-container { height: 600px; border-radius: 0.5rem; overflow: hidden; margin-bottom: 1rem; }
height: 600px; .map-controls { display: flex; gap: 0.5rem; }
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
}
.map-controls {
display: flex;
gap: 0.5rem;
}
/* Modal */ /* Modal */
.modal { .modal {
position: fixed; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
top: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000;
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 { .modal-content {
background: var(--surface-color); background: var(--surface-color); padding: 2rem; border-radius: 0.5rem;
padding: 2rem; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative;
border-radius: 0.5rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
} }
.modal-close { .modal-close { position: absolute; top: 1rem; right: 1rem; font-size: 1.5rem; cursor: pointer; color: var(--text-secondary); }
position: absolute; .modal-close:hover { color: var(--text-primary); }
top: 1rem; .node-detail { margin-top: 1rem; }
right: 1rem;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
}
.modal-close:hover { .detail-row { display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--border-color); }
color: var(--text-primary); .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 { /* Error / Success */
margin-top: 1rem; .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; }
.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;
}
/* Empty State */ /* Empty State */
.empty-state { .empty-state { text-align: center; padding: 3rem; color: var(--text-secondary); }
text-align: center; .empty-state-icon { font-size: 3rem; margin-bottom: 1rem; }
padding: 3rem;
color: var(--text-secondary); /* 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 { .notification.success { background: #d1fae5; color: #065f46; border-left: 4px solid var(--success-color); }
font-size: 3rem; .notification.warning { background: #fef3c7; color: #92400e; border-left: 4px solid var(--warning-color); }
margin-bottom: 1rem; .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) { @media (max-width: 768px) {
.header { .header { flex-direction: column; gap: 1rem; align-items: flex-start; }
flex-direction: column; .header-right { width: 100%; justify-content: space-between; }
gap: 1rem; .nav-tabs { overflow-x: auto; padding: 0 1rem; }
align-items: flex-start; .content { padding: 1rem; }
} .stats-grid { grid-template-columns: 1fr; }
.form-row { flex-direction: column; }
.header-right { .form-row select { width: 100%; }
width: 100%; .nodes-grid { grid-template-columns: 1fr; }
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 */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar { width: 8px; height: 8px; }
width: 8px; ::-webkit-scrollbar-track { background: var(--bg-color); }
height: 8px; ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
} ::-webkit-scrollbar-thumb:hover { background: var(--secondary-color); }
::-webkit-scrollbar-track { /* Loading */
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; display: inline-block; width: 1rem; height: 1rem;
width: 1rem; border: 2px solid var(--border-color); border-top-color: var(--primary-color);
height: 1rem; border-radius: 50%; animation: spin 0.8s linear infinite;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
@keyframes spin { @keyframes spin { to { transform: rotate(360deg); } }
to { transform: rotate(360deg); }
}
/* Map marker pulse animation for newly heard nodes */
@keyframes markerPulse { @keyframes markerPulse {
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); }
transform: scale(1); 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); }
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255, 255, 255, 0.7); 100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255,255,255,0); }
}
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); }
+63 -31
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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="css/style.css">
<link rel="stylesheet" href="http://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="http://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head> </head>
@@ -12,8 +12,8 @@
<div id="login-screen" class="screen"> <div id="login-screen" class="screen">
<div class="login-container"> <div class="login-container">
<div class="login-box"> <div class="login-box">
<h1>Meshtastic Dashboard</h1> <h1>MeshCore Dashboard</h1>
<p class="subtitle">MQTT Monitoring & Control</p> <p class="subtitle">USB Serial Monitor</p>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
@@ -32,15 +32,15 @@
<!-- Main Dashboard --> <!-- Main Dashboard -->
<div id="dashboard-screen" class="screen hidden"> <div id="dashboard-screen" class="screen hidden">
<!-- Header -->
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<h1>Meshtastic Dashboard</h1> <h1>MeshCore Dashboard</h1>
<span id="device-name-badge" class="device-badge hidden"></span>
</div> </div>
<div class="header-right"> <div class="header-right">
<span class="mqtt-status" id="mqtt-status"> <span class="serial-status" id="serial-status">
<span class="status-dot"></span> <span class="status-dot"></span>
MQTT USB
</span> </span>
<span class="data-last-received" id="data-last-received" title="Time since last data update"> <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> <span class="data-label">Last data:</span> <span id="data-timer">0s ago</span>
@@ -50,16 +50,14 @@
</div> </div>
</header> </header>
<!-- Navigation -->
<nav class="nav-tabs"> <nav class="nav-tabs">
<button class="nav-tab active" data-tab="overview">Overview</button> <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="map">Map</button>
<button class="nav-tab" data-tab="messages">Messages</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> <button class="nav-tab" data-tab="settings">Settings</button>
</nav> </nav>
<!-- Tab Contents -->
<div class="content"> <div class="content">
<!-- Overview Tab --> <!-- Overview Tab -->
<div id="overview-tab" class="tab-content active"> <div id="overview-tab" class="tab-content active">
@@ -67,8 +65,8 @@
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">📡</div> <div class="stat-icon">📡</div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value" id="stat-nodes">0</div> <div class="stat-value" id="stat-contacts">0</div>
<div class="stat-label">Active Nodes</div> <div class="stat-label">Contacts</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
@@ -94,6 +92,11 @@
</div> </div>
</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"> <div class="panel">
<h2>Recent Messages</h2> <h2>Recent Messages</h2>
<div id="recent-messages" class="message-list"></div> <div id="recent-messages" class="message-list"></div>
@@ -113,13 +116,32 @@
<div class="panel"> <div class="panel">
<h2>Send Message</h2> <h2>Send Message</h2>
<form id="send-message-form" class="message-form"> <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"> <div class="form-row">
<input type="text" id="message-text" placeholder="Type your message..." required> <input type="text" id="message-text" placeholder="Type your message..." required>
<select id="message-from"> <select id="message-to-contact" title="Select contact">
<option value="474572292">!1c496604</option> <option value="">-- Select contact --</option>
</select> </select>
<select id="message-channel"> <select id="message-channel-idx" class="hidden" title="Select channel">
<!-- Options populated dynamically from config --> <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> </select>
<button type="submit" class="btn btn-primary">Send</button> <button type="submit" class="btn btn-primary">Send</button>
</div> </div>
@@ -128,38 +150,50 @@
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Message History</h2> <h2>Messages</h2>
<button id="refresh-messages-btn" class="btn btn-secondary">Refresh</button> <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>
<div id="message-history" class="message-list"></div> <div id="message-history" class="message-list"></div>
</div> </div>
</div> </div>
<!-- Nodes Tab --> <!-- Contacts Tab -->
<div id="nodes-tab" class="tab-content"> <div id="contacts-tab" class="tab-content">
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
<h2>Meshtastic Nodes</h2> <h2>MeshCore Contacts</h2>
<button id="refresh-nodes-btn" class="btn btn-secondary">Refresh</button> <button id="refresh-contacts-btn" class="btn btn-secondary">Refresh</button>
</div> </div>
<div id="nodes-grid" class="nodes-grid"></div> <div id="contacts-grid" class="nodes-grid"></div>
</div> </div>
<!-- Node Detail Modal --> <div id="contact-modal" class="modal hidden">
<div id="node-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<span class="modal-close">&times;</span> <span class="modal-close">&times;</span>
<h2>Node Details</h2> <h2>Contact Details</h2>
<div id="node-detail" class="node-detail"></div> <div id="contact-detail" class="node-detail"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Settings Tab --> <!-- Settings Tab -->
<div id="settings-tab" class="tab-content"> <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"> <div class="panel">
<h2>Data Management</h2> <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"> <form id="purge-form" class="settings-form">
<div class="form-group"> <div class="form-group">
<label for="purge-hours">Keep data from the last:</label> <label for="purge-hours">Keep data from the last:</label>
@@ -182,16 +216,14 @@
<div class="panel"> <div class="panel">
<h2>About</h2> <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> <p>Version 1.0.0</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Scripts -->
<script src="http://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <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> <script src="js/app.js"></script>
</body> </body>
</html> </html>
+589 -1144
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

+23 -59
View File
@@ -1,23 +1,12 @@
const bcrypt = require('bcryptjs'); import bcrypt from 'bcryptjs';
const { userQueries, activityLogQueries } = require('../database/queries'); import { userQueries, activityLogQueries } from '../database/queries.js';
const logger = require('../utils/logger'); import logger from '../utils/logger.js';
// Hash password export async function createUser(username, password) {
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10); const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt); const hash = await bcrypt.hash(password, salt);
}
// Verify password
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
// Create user
async function createUser(username, password) {
try { try {
const hashedPassword = await hashPassword(password); const result = userQueries.createUser.run(username, hash);
const result = userQueries.createUser.run(username, hashedPassword);
logger.info(`User created: ${username}`); logger.info(`User created: ${username}`);
return { id: result.lastInsertRowid, username }; return { id: result.lastInsertRowid, username };
} catch (error) { } catch (error) {
@@ -28,39 +17,24 @@ async function createUser(username, password) {
} }
} }
// Authenticate user export async function authenticateUser(username, password) {
async function authenticateUser(username, password) { const user = userQueries.getUserByUsername.get(username);
try { if (!user) {
const user = userQueries.getUserByUsername.get(username); logger.warn(`Failed login attempt for username: ${username}`);
return null;
if (!user) {
logger.warn(`Failed login attempt for username: ${username}`);
return null;
}
const isValid = await verifyPassword(password, user.password_hash);
if (!isValid) {
logger.warn(`Invalid password for username: ${username}`);
return null;
}
// Update last login
userQueries.updateLastLogin.run(user.id);
logger.info(`User logged in: ${username}`);
// Return user without password hash
const { password_hash, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
logger.error('Error authenticating user:', error);
throw error;
} }
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
logger.warn(`Invalid password for username: ${username}`);
return null;
}
userQueries.updateLastLogin.run(user.id);
logger.info(`User logged in: ${username}`);
const { password_hash, ...userWithoutPassword } = user;
return userWithoutPassword;
} }
// Middleware to check if user is authenticated export function requireAuth(req, res, next) {
function requireAuth(req, res, next) {
if (req.session && req.session.userId) { if (req.session && req.session.userId) {
next(); next();
} else { } else {
@@ -68,20 +42,10 @@ function requireAuth(req, res, next) {
} }
} }
// Log activity export function logActivity(userId, action, details = null, ipAddress = null) {
function logActivity(userId, action, details = null, ipAddress = null) {
try { try {
activityLogQueries.logActivity.run(userId, action, details, ipAddress); activityLogQueries.log.run(userId, action, details, ipAddress);
} catch (error) { } catch (error) {
logger.error('Error logging activity:', 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 = { export default {
// Server configuration
server: { server: {
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development' nodeEnv: process.env.NODE_ENV || 'development'
}, },
// Session configuration
session: { session: {
secret: process.env.SESSION_SECRET || 'change-this-secret', secret: process.env.SESSION_SECRET || 'change-this-secret',
resave: false, resave: false,
@@ -15,52 +13,25 @@ module.exports = {
cookie: { cookie: {
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000
} }
}, },
// MQTT configuration serial: {
mqtt: { port: process.env.SERIAL_PORT || 'COM3'
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
}
}, },
// Data retention configuration
dataRetention: { dataRetention: {
days: parseInt(process.env.DATA_RETENTION_DAYS) || 30, days: parseInt(process.env.DATA_RETENTION_DAYS) || 30,
purgeCronSchedule: process.env.PURGE_CRON_SCHEDULE || '0 2 * * *' purgeCronSchedule: process.env.PURGE_CRON_SCHEDULE || '0 2 * * *'
}, },
// Rate limiting configuration
rateLimit: { 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 maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
}, },
// Logging configuration
logging: { logging: {
level: process.env.LOG_LEVEL || 'info' 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'); import Database from 'better-sqlite3';
const path = require('path'); import path from 'path';
const fs = require('fs'); 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'); const dataDir = path.join(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true });
} }
const dbPath = path.join(dataDir, 'meshtastic.db'); const db = new Database(path.join(dataDir, 'meshcore.db'));
const db = new Database(dbPath);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
// Initialize database schema
function initializeDatabase() { function initializeDatabase() {
// Users table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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(` db.exec(`
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT UNIQUE NOT NULL, pubkey TEXT UNIQUE NOT NULL,
short_name TEXT, name TEXT,
long_name TEXT, contact_type INTEGER DEFAULT 0,
hardware_model TEXT, flags INTEGER DEFAULT 0,
role TEXT, path_length INTEGER DEFAULT 255,
firmware_version TEXT, last_advert INTEGER,
last_heard DATETIME, battery_mv INTEGER,
battery_level INTEGER,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER, uptime_seconds INTEGER,
last_heard DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`); `);
// Create index on node_id db.exec(`CREATE INDEX IF NOT EXISTS idx_contacts_pubkey ON contacts(pubkey)`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_nodes_node_id ON nodes(node_id)
`);
// Positions table - stores GPS position data // Positions — GPS from contact advertisements
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS positions ( CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL, pubkey TEXT NOT NULL,
latitude REAL NOT NULL, latitude REAL NOT NULL,
longitude REAL NOT NULL, longitude REAL NOT NULL,
altitude INTEGER, altitude INTEGER,
precision_bits INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 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_pubkey ON positions(pubkey)`);
db.exec(` db.exec(`CREATE INDEX IF NOT EXISTS idx_positions_timestamp ON positions(timestamp)`);
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)
`);
// Messages table - stores text messages // Messages — direct (msg_type=0) and channel (msg_type=1)
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT, from_pubkey TEXT NOT NULL,
from_node TEXT NOT NULL, to_pubkey TEXT,
to_node TEXT, channel_idx INTEGER,
channel INTEGER, msg_type INTEGER NOT NULL DEFAULT 0,
text TEXT, text TEXT,
rx_time DATETIME, snr REAL,
rx_snr REAL, path_length INTEGER,
rx_rssi INTEGER, ack_hash TEXT,
hop_limit INTEGER, delivered INTEGER DEFAULT 0,
want_ack BOOLEAN, sender_ts INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (from_node) REFERENCES nodes(node_id)
) )
`); `);
// Create indexes for messages db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_pubkey)`);
db.exec(` db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at)`);
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)
`);
// Telemetry table - stores device telemetry data // Activity log
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
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS activity_log ( CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -144,7 +97,6 @@ function initializeDatabase() {
console.log('Database initialized successfully'); console.log('Database initialized successfully');
} }
// Initialize the database
initializeDatabase(); 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 export const userQueries = {
const userQueries = { createUser: db.prepare(`INSERT INTO users (username, password_hash) VALUES (?, ?)`),
createUser: db.prepare(` getUserByUsername: db.prepare(`SELECT * FROM users WHERE username = ?`),
INSERT INTO users (username, password_hash) updateLastLogin: db.prepare(`UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?`),
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
`)
}; };
// Node queries export const contactQueries = {
const nodeQueries = { upsert: db.prepare(`
upsertNode: db.prepare(` INSERT INTO contacts (pubkey, name, contact_type, flags, path_length, last_advert, last_heard, updated_at)
INSERT INTO nodes ( VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
node_id, short_name, long_name, hardware_model, role, ON CONFLICT(pubkey) DO UPDATE SET
firmware_version, last_heard, battery_level, voltage, name = COALESCE(excluded.name, name),
channel_utilization, air_util_tx, uptime_seconds, updated_at contact_type = COALESCE(excluded.contact_type, contact_type),
) VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) flags = COALESCE(excluded.flags, flags),
ON CONFLICT(node_id) DO UPDATE SET path_length = COALESCE(excluded.path_length, path_length),
short_name = COALESCE(excluded.short_name, short_name), last_advert = COALESCE(excluded.last_advert, last_advert),
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),
last_heard = CURRENT_TIMESTAMP, 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 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 SELECT
n.*, c.*,
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude, (SELECT latitude FROM positions WHERE pubkey = c.pubkey 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 longitude FROM positions WHERE pubkey = c.pubkey 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 pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as position_timestamp
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp FROM contacts c
FROM nodes n ORDER BY COALESCE(datetime(c.last_heard), datetime('1970-01-01')) DESC
WHERE n.node_id = ?
`), `),
getAllNodes: db.prepare(` getByPubkey: db.prepare(`
SELECT SELECT
n.*, c.*,
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude, (SELECT latitude FROM positions WHERE pubkey = c.pubkey 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 longitude FROM positions WHERE pubkey = c.pubkey 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 pubkey = c.pubkey ORDER BY timestamp DESC LIMIT 1) as position_timestamp
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp FROM contacts c WHERE c.pubkey = ?
FROM nodes n
ORDER BY
COALESCE(datetime(n.last_heard), datetime('1970-01-01')) DESC
`), `),
updateNodeLastHeard: db.prepare(` deleteOld: db.prepare(`
UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ? 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 export const positionQueries = {
const positionQueries = { insert: db.prepare(`
insertPosition: db.prepare(` INSERT INTO positions (pubkey, latitude, longitude, altitude) VALUES (?, ?, ?, ?)
INSERT INTO positions (node_id, latitude, longitude, altitude, precision_bits)
VALUES (?, ?, ?, ?, ?)
`), `),
getLatestPositions: db.prepare(` getLatest: db.prepare(`
SELECT p.*, n.short_name, n.long_name, n.last_heard SELECT p.*, c.name, c.contact_type, c.last_heard
FROM positions p FROM positions p
LEFT JOIN nodes n ON p.node_id = n.node_id LEFT JOIN contacts c ON p.pubkey = c.pubkey
WHERE p.id IN ( WHERE p.id IN (SELECT MAX(id) FROM positions GROUP BY pubkey)
SELECT MAX(id) FROM positions GROUP BY node_id
)
ORDER BY p.timestamp DESC ORDER BY p.timestamp DESC
`), `),
getPositionsByNode: db.prepare(` getByPubkey: db.prepare(`
SELECT * FROM positions SELECT * FROM positions WHERE pubkey = ? ORDER BY timestamp DESC LIMIT ?
WHERE node_id = ?
ORDER BY timestamp DESC
LIMIT ?
`), `),
deleteOldPositions: db.prepare(` getTrails: db.prepare(`
DELETE FROM positions WHERE timestamp < datetime('now', '-' || ? || ' hours') SELECT pubkey, latitude, longitude, altitude, timestamp, id
`),
getPositionTrails: db.prepare(`
SELECT
node_id,
latitude,
longitude,
altitude,
timestamp,
id
FROM ( FROM (
SELECT SELECT pubkey, latitude, longitude, altitude, timestamp, id,
node_id, ROW_NUMBER() OVER (PARTITION BY pubkey ORDER BY timestamp DESC) as rn
latitude,
longitude,
altitude,
timestamp,
id,
ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY timestamp DESC) as rn
FROM positions FROM positions
) AS ranked ) AS ranked
WHERE rn <= ? 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 export const messageQueries = {
const messageQueries = { insert: db.prepare(`
insertMessage: db.prepare(` INSERT INTO messages (from_pubkey, to_pubkey, channel_idx, msg_type, text, snr, path_length, ack_hash, sender_ts)
INSERT INTO messages ( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
message_id, from_node, to_node, channel, text,
rx_time, rx_snr, rx_rssi, hop_limit, want_ack
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), `),
getRecentMessages: db.prepare(` markDelivered: db.prepare(`UPDATE messages SET delivered = 1 WHERE ack_hash = ? AND delivered = 0`),
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 ?
`),
getRecentMessagesWithTimeLimit: db.prepare(` getRecent: db.prepare(`
SELECT m.*, SELECT m.*, c.name as from_name
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 FROM messages m
LEFT JOIN nodes n1 ON m.from_node = n1.node_id LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
WHERE m.created_at >= datetime('now', '-24 hours') WHERE m.created_at >= datetime('now', '-24 hours')
ORDER BY m.created_at DESC ORDER BY m.created_at DESC
LIMIT ? LIMIT ?
`), `),
getMessagesByNode: db.prepare(` getRecentDirect: db.prepare(`
SELECT m.*, SELECT m.*, c.name as from_name
n1.short_name as from_short_name,
n1.long_name as from_long_name
FROM messages m FROM messages m
LEFT JOIN nodes n1 ON m.from_node = n1.node_id LEFT JOIN contacts c ON m.from_pubkey = c.pubkey
WHERE m.from_node = ? OR m.to_node = ? WHERE m.created_at >= datetime('now', '-24 hours')
AND m.msg_type = 0
ORDER BY m.created_at DESC ORDER BY m.created_at DESC
LIMIT ? LIMIT ?
`), `),
deleteOldMessages: db.prepare(` getRecentChannel: db.prepare(`
DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' hours') 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')
// Telemetry queries AND m.msg_type = 1
const telemetryQueries = { AND m.channel_idx = ?
insertTelemetry: db.prepare(` ORDER BY m.created_at DESC
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
LIMIT ? LIMIT ?
`), `),
deleteOldTelemetry: db.prepare(` getByContact: db.prepare(`
DELETE FROM telemetry WHERE timestamp < datetime('now', '-' || ? || ' hours') 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 = ?
// Activity log queries ORDER BY m.created_at DESC
const activityLogQueries = { LIMIT ?
logActivity: db.prepare(`
INSERT INTO activity_log (user_id, action, details, ip_address)
VALUES (?, ?, ?, ?)
`), `),
getRecentActivity: db.prepare(` deleteOld: db.prepare(`DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' hours')`),
SELECT a.*, u.username
FROM activity_log a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.timestamp DESC
LIMIT ?
`)
}; };
// Statistics queries export const statsQueries = {
const statsQueries = {
getMessageCount: db.prepare(`SELECT COUNT(*) as count FROM messages`), 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`), getPositionCount: db.prepare(`SELECT COUNT(*) as count FROM positions`),
getDbSize: () => { getDbSize: () => {
const result = db.prepare(` const result = db.prepare(
SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size() `SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()`
`).get(); ).get();
return result.size; return result.size;
} }
}; };
module.exports = { export const activityLogQueries = {
userQueries, log: db.prepare(`INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`),
nodeQueries,
positionQueries,
messageQueries,
telemetryQueries,
activityLogQueries,
statsQueries
}; };
-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;
+157 -188
View File
@@ -1,219 +1,189 @@
const express = require('express'); import express from 'express';
const router = express.Router(); import { authenticateUser, requireAuth, logActivity } from '../auth/auth.js';
const { authenticateUser, requireAuth, logActivity } = require('../auth/auth'); import { contactQueries, positionQueries, messageQueries, statsQueries } from '../database/queries.js';
const { import serialClient from '../serial/client.js';
nodeQueries, import logger from '../utils/logger.js';
positionQueries,
messageQueries,
telemetryQueries,
statsQueries
} = require('../database/queries');
const mqttClient = require('../mqtt/client');
const logger = require('../utils/logger');
// Login endpoint const router = express.Router();
// Auth
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const { username, password } = req.body; 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); 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.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
// Log activity
logActivity(user.id, 'login', null, req.ip); logActivity(user.id, 'login', null, req.ip);
res.json({ res.json({ success: true, user: { id: user.id, username: user.username } });
success: true,
user: {
id: user.id,
username: user.username
}
});
} catch (error) { } catch (error) {
logger.error('Login error:', error); logger.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
// Logout endpoint
router.post('/logout', requireAuth, (req, res) => { router.post('/logout', requireAuth, (req, res) => {
const userId = req.session.userId; const userId = req.session.userId;
req.session.destroy((err) => { req.session.destroy((err) => {
if (err) { if (err) return res.status(500).json({ error: 'Failed to logout' });
logger.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
logActivity(userId, 'logout', null, req.ip); logActivity(userId, 'logout', null, req.ip);
res.json({ success: true }); res.json({ success: true });
}); });
}); });
// Check authentication status
router.get('/auth/status', (req, res) => { router.get('/auth/status', (req, res) => {
if (req.session && req.session.userId) { if (req.session?.userId) {
res.json({ res.json({ authenticated: true, user: { id: req.session.userId, username: req.session.username } });
authenticated: true,
user: {
id: req.session.userId,
username: req.session.username
}
});
} else { } else {
res.json({ authenticated: false }); res.json({ authenticated: false });
} }
}); });
// Get all nodes // Contacts
router.get('/nodes', requireAuth, (req, res) => { router.get('/contacts', requireAuth, (req, res) => {
try { try {
const nodes = nodeQueries.getAllNodes.all(); res.json(contactQueries.getAll.all());
res.json(nodes);
} catch (error) { } catch (error) {
logger.error('Error fetching nodes:', error); logger.error('Error fetching contacts:', error);
res.status(500).json({ error: 'Failed to fetch nodes' }); res.status(500).json({ error: 'Failed to fetch contacts' });
} }
}); });
// Get specific node router.get('/contacts/:pubkey', requireAuth, (req, res) => {
router.get('/nodes/:nodeId', requireAuth, (req, res) => {
try { try {
const { nodeId } = req.params; const contact = contactQueries.getByPubkey.get(req.params.pubkey);
const node = nodeQueries.getNodeById.get(nodeId); if (!contact) return res.status(404).json({ error: 'Contact not found' });
res.json(contact);
if (!node) {
return res.status(404).json({ error: 'Node not found' });
}
res.json(node);
} catch (error) { } catch (error) {
logger.error('Error fetching node:', error); logger.error('Error fetching contact:', error);
res.status(500).json({ error: 'Failed to fetch node' }); res.status(500).json({ error: 'Failed to fetch contact' });
} }
}); });
// Get latest positions for all nodes // Positions
router.get('/positions', requireAuth, (req, res) => { router.get('/positions', requireAuth, (req, res) => {
try { try {
const positions = positionQueries.getLatestPositions.all(); res.json(positionQueries.getLatest.all());
res.json(positions);
} catch (error) { } catch (error) {
logger.error('Error fetching positions:', error); logger.error('Error fetching positions:', error);
res.status(500).json({ error: 'Failed to fetch positions' }); 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) => { router.get('/positions/trails/all', requireAuth, (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const trails = positionQueries.getPositionTrails.all(limit); res.json(positionQueries.getTrails.all(limit));
res.json(trails);
} catch (error) { } catch (error) {
logger.error('Error fetching position trails:', error); logger.error('Error fetching trails:', error);
res.status(500).json({ error: 'Failed to fetch position trails' }); res.status(500).json({ error: 'Failed to fetch trails' });
} }
}); });
// Get position history for a specific node router.get('/positions/:pubkey', requireAuth, (req, res) => {
router.get('/positions/:nodeId', requireAuth, (req, res) => {
try { try {
const { nodeId } = req.params;
const limit = parseInt(req.query.limit) || 100; const limit = parseInt(req.query.limit) || 100;
const positions = positionQueries.getPositionsByNode.all(nodeId, limit); res.json(positionQueries.getByPubkey.all(req.params.pubkey, limit));
res.json(positions);
} catch (error) { } catch (error) {
logger.error('Error fetching position history:', error); logger.error('Error fetching position history:', error);
res.status(500).json({ error: 'Failed to fetch position history' }); 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) => { router.get('/messages', requireAuth, (req, res) => {
try { try {
const limit = Math.min(parseInt(req.query.limit) || 1000, 1000); const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
const messages = messageQueries.getRecentMessagesWithTimeLimit.all(limit); const { type, channel_idx } = req.query;
res.json(messages);
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) { } catch (error) {
logger.error('Error fetching messages:', error); logger.error('Error fetching messages:', error);
res.status(500).json({ error: 'Failed to fetch messages' }); res.status(500).json({ error: 'Failed to fetch messages' });
} }
}); });
// Get messages for a specific node router.get('/messages/contact/:pubkey', requireAuth, (req, res) => {
router.get('/messages/node/:nodeId', requireAuth, (req, res) => {
try { try {
const { nodeId } = req.params;
const limit = parseInt(req.query.limit) || 100; const limit = parseInt(req.query.limit) || 100;
const messages = messageQueries.getMessagesByNode.all(nodeId, nodeId, limit); res.json(messageQueries.getByContact.all(req.params.pubkey, req.params.pubkey, limit));
res.json(messages);
} catch (error) { } catch (error) {
logger.error('Error fetching node messages:', error); logger.error('Error fetching contact messages:', error);
res.status(500).json({ error: 'Failed to fetch node messages' }); res.status(500).json({ error: 'Failed to fetch contact messages' });
} }
}); });
// Send a message
router.post('/messages/send', requireAuth, async (req, res) => { router.post('/messages/send', requireAuth, async (req, res) => {
try { try {
const { from, text, channel } = req.body; const { type, to_pubkey, channel_idx, text } = req.body;
if (!text) { if (!text?.trim()) return res.status(400).json({ error: 'Message text required' });
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); logActivity(req.session.userId, 'send_message', text.substring(0, 100), req.ip);
res.json({ success: true, ackHash });
// Log activity
logActivity(req.session.userId, 'send_message', text, req.ip);
res.json({ success: true });
} catch (error) { } catch (error) {
logger.error('Error sending message:', 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 // Stats
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
router.get('/stats', requireAuth, (req, res) => { router.get('/stats', requireAuth, (req, res) => {
try { try {
const messageCount = statsQueries.getMessageCount.get();
const nodeCount = statsQueries.getNodeCount.get();
const positionCount = statsQueries.getPositionCount.get();
const dbSize = statsQueries.getDbSize();
res.json({ res.json({
messages: messageCount.count, contacts: statsQueries.getContactCount.get().count,
nodes: nodeCount.count, messages: statsQueries.getMessageCount.get().count,
positions: positionCount.count, positions: statsQueries.getPositionCount.get().count,
databaseSize: dbSize, databaseSize: statsQueries.getDbSize(),
mqttConnected: mqttClient.isConnected() serialConnected: serialClient.isConnected()
}); });
} catch (error) { } catch (error) {
logger.error('Error fetching stats:', error); logger.error('Error fetching stats:', error);
@@ -221,76 +191,75 @@ router.get('/stats', requireAuth, (req, res) => {
} }
}); });
// Purge old data // Serial status & ports
router.get('/serial/status', requireAuth, (req, res) => {
const self = serialClient.getSelfInfo();
res.json({
connected: serialClient.isConnected(),
port: process.env.SERIAL_PORT || 'COM3',
deviceName: self?.name || self?.advName || null
});
});
router.get('/serial/ports', requireAuth, async (req, res) => {
try {
const ports = await serialClient.listPorts();
res.json(ports);
} catch (error) {
res.status(500).json({ error: 'Failed to list ports' });
}
});
// Device self info
router.get('/device', requireAuth, (req, res) => {
const self = serialClient.getSelfInfo();
if (!self) return res.json({ available: false });
const pubkeyHex = self.publicKey
? (Buffer.isBuffer(self.publicKey) ? self.publicKey.toString('hex') : self.publicKey)
: null;
res.json({
available: true,
name: self.name || null,
pubkey: pubkeyHex,
pubkeyPrefix: pubkeyHex ? pubkeyHex.substring(0, 12) : null,
txPower: self.txPower ?? null,
latitude: self.advLat != null && self.advLat !== 0 ? self.advLat / 1e6 : null,
longitude: self.advLon != null && self.advLon !== 0 ? self.advLon / 1e6 : null,
nodeType: self.type ?? null,
frequency: self.radioFreq ?? null,
radioBw: self.radioBw ?? null,
radioSf: self.radioSf ?? null,
});
});
// Channels from device
router.get('/channels', requireAuth, async (req, res) => {
try {
const channels = await serialClient.getChannels();
res.json(channels);
} catch (error) {
res.status(500).json({ error: error.message || 'Failed to get channels' });
}
});
// Data purge
router.post('/purge', requireAuth, (req, res) => { router.post('/purge', requireAuth, (req, res) => {
try { try {
const { hours } = req.body; const hoursToKeep = req.body.hours || 720;
const hoursToKeep = hours || 720; // Default to 30 days (720 hours) const msgs = messageQueries.deleteOld.run(hoursToKeep);
const pos = positionQueries.deleteOld.run(hoursToKeep);
const contacts = contactQueries.deleteOld.run(hoursToKeep, hoursToKeep);
logger.info(`Starting purge of data older than ${hoursToKeep} hours`); logger.info(`Purged: ${msgs.changes} messages, ${pos.changes} positions, ${contacts.changes} contacts`);
logActivity(req.session.userId, 'purge_data', `Purged data older than ${hoursToKeep}h`, req.ip);
// First, update any nodes with NULL last_heard based on their most recent data res.json({ success: true, deleted: { messages: msgs.changes, positions: pos.changes, contacts: contacts.changes } });
const nullLastHeardUpdated = nodeQueries.updateNullLastHeard.run();
logger.info(`Updated ${nullLastHeardUpdated.changes} nodes with NULL last_heard`);
const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep);
logger.info(`Deleted ${messagesDeleted.changes} old messages`);
const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep);
logger.info(`Deleted ${positionsDeleted.changes} old positions`);
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep);
logger.info(`Deleted ${telemetryDeleted.changes} old telemetry records`);
// Check which old nodes still have data before deleting
const oldNodesWithData = nodeQueries.getOldNodesWithData.all(hoursToKeep);
oldNodesWithData.forEach(node => {
logger.info(`Old node ${node.node_id} (last_heard: ${node.last_heard}): ${node.message_count} messages, ${node.position_count} positions, ${node.telemetry_count} telemetry`);
});
const nodesDeleted = nodeQueries.deleteOldNodes.run(hoursToKeep, hoursToKeep, hoursToKeep, hoursToKeep);
logger.info(`Deleted ${nodesDeleted.changes} old nodes`);
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records, ${nodesDeleted.changes} nodes`);
// Log activity
const timePeriod = hoursToKeep < 24
? `${hoursToKeep} hour${hoursToKeep !== 1 ? 's' : ''}`
: `${hoursToKeep / 24} day${hoursToKeep / 24 !== 1 ? 's' : ''}`;
logActivity(
req.session.userId,
'purge_data',
`Purged data older than ${timePeriod}`,
req.ip
);
res.json({
success: true,
deleted: {
messages: messagesDeleted.changes,
positions: positionsDeleted.changes,
telemetry: telemetryDeleted.changes,
nodes: nodesDeleted.changes
}
});
} catch (error) { } catch (error) {
logger.error('Error purging data:', error); logger.error('Error purging data:', error);
res.status(500).json({ error: 'Failed to purge data' }); res.status(500).json({ error: 'Failed to purge data' });
} }
}); });
// MQTT status export default router;
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;
+6 -17
View File
@@ -1,43 +1,32 @@
const readline = require('readline'); import readline from 'readline';
const { createUser } = require('../auth/auth'); import { createUser } from '../auth/auth.js';
const logger = require('../utils/logger');
const rl = readline.createInterface({ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
input: process.stdin, const question = (q) => new Promise(resolve => rl.question(q, resolve));
output: process.stdout
});
function question(query) {
return new Promise(resolve => rl.question(query, resolve));
}
async function main() { async function main() {
console.log('\n=== Create New User ===\n'); console.log('\n=== Create New User ===\n');
try { try {
const username = await question('Enter username: '); const username = await question('Enter username: ');
if (!username || username.length < 3) { if (!username || username.length < 3) {
console.error('Username must be at least 3 characters long'); console.error('Username must be at least 3 characters long');
process.exit(1); process.exit(1);
} }
const password = await question('Enter password: '); const password = await question('Enter password: ');
if (!password || password.length < 6) { if (!password || password.length < 6) {
console.error('Password must be at least 6 characters long'); console.error('Password must be at least 6 characters long');
process.exit(1); process.exit(1);
} }
const confirmPassword = await question('Confirm password: '); const confirm = await question('Confirm password: ');
if (password !== confirm) {
if (password !== confirmPassword) {
console.error('Passwords do not match'); console.error('Passwords do not match');
process.exit(1); process.exit(1);
} }
await createUser(username, password); await createUser(username, password);
console.log(`\nUser '${username}' created successfully!\n`); console.log(`\nUser '${username}' created successfully!\n`);
process.exit(0); process.exit(0);
} catch (error) { } 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'); import express from 'express';
const session = require('express-session'); import session from 'express-session';
const helmet = require('helmet'); import helmet from 'helmet';
const cors = require('cors'); import cors from 'cors';
const path = require('path'); import path from 'path';
const rateLimit = require('express-rate-limit'); import rateLimit from 'express-rate-limit';
import { fileURLToPath } from 'url';
const config = require('./config/config'); import config from './config/config.js';
const logger = require('./utils/logger'); import logger from './utils/logger.js';
const apiRoutes = require('./routes/api'); import apiRoutes from './routes/api.js';
const mqttClient = require('./mqtt/client'); import serialClient from './serial/client.js';
const cronService = require('./services/cron'); import cronService from './services/cron.js';
// Initialize Express app const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
// Security middleware
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@@ -29,84 +29,56 @@ app.use(helmet({
hsts: false hsts: false
})); }));
// CORS configuration
app.use(cors({ app.use(cors({
origin: config.server.nodeEnv === 'production' ? false : true, origin: config.server.nodeEnv === 'production' ? false : true,
credentials: true credentials: true
})); }));
// Body parser
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(session(config.session)); app.use(session(config.session));
// Rate limiting app.use('/api/', rateLimit({
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs, windowMs: config.rateLimit.windowMs,
max: config.rateLimit.maxRequests, 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'))); app.use(express.static(path.join(__dirname, '..', 'public')));
// API routes
app.use('/api', apiRoutes); 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) => { app.use((err, req, res, next) => {
logger.error('Express error:', err); logger.error('Express error:', err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
}); });
// Start server
function start() { function start() {
const PORT = config.server.port; const PORT = config.server.port;
// Connect to MQTT broker logger.info('Starting MeshCore serial client...');
logger.info('Starting MQTT client...'); serialClient.connect();
mqttClient.connect();
// Start cron service
logger.info('Starting cron service...'); logger.info('Starting cron service...');
cronService.start(); cronService.start();
// Start Express server
app.listen(PORT, () => { app.listen(PORT, () => {
logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Server running on http://localhost:${PORT}`);
logger.info(`Environment: ${config.server.nodeEnv}`); logger.info(`Environment: ${config.server.nodeEnv}`);
logger.info(`Serial port: ${config.serial.port}`);
}); });
} }
// Graceful shutdown function shutdown() {
process.on('SIGINT', () => {
logger.info('Shutting down gracefully...'); logger.info('Shutting down gracefully...');
serialClient.disconnect();
mqttClient.disconnect();
cronService.stop(); cronService.stop();
process.exit(0); process.exit(0);
}); }
process.on('SIGTERM', () => { process.on('SIGINT', shutdown);
logger.info('Shutting down gracefully...'); process.on('SIGTERM', shutdown);
mqttClient.disconnect();
cronService.stop();
process.exit(0);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
logger.error('Uncaught exception:', error); logger.error('Uncaught exception:', error);
process.exit(1); process.exit(1);
@@ -117,7 +89,6 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1); process.exit(1);
}); });
// Start the application
start(); start();
module.exports = app; export default app;
+16 -30
View File
@@ -1,7 +1,7 @@
const cron = require('node-cron'); import cron from 'node-cron';
const config = require('../config/config'); import config from '../config/config.js';
const { messageQueries, positionQueries, telemetryQueries } = require('../database/queries'); import { messageQueries, positionQueries } from '../database/queries.js';
const logger = require('../utils/logger'); import logger from '../utils/logger.js';
class CronService { class CronService {
constructor() { constructor() {
@@ -9,45 +9,31 @@ class CronService {
} }
start() { start() {
// Schedule data purging const task = cron.schedule(
const purgeTask = cron.schedule(
config.dataRetention.purgeCronSchedule, config.dataRetention.purgeCronSchedule,
() => { () => this.purgeOldData(),
this.purgeOldData(); { scheduled: true, timezone: 'UTC' }
},
{
scheduled: true,
timezone: 'UTC'
}
); );
this.tasks.push(task);
this.tasks.push(purgeTask); logger.info(`Cron scheduled: data purge at ${config.dataRetention.purgeCronSchedule}`);
logger.info(`Cron job scheduled: Data purging at ${config.dataRetention.purgeCronSchedule}`);
} }
purgeOldData() { purgeOldData() {
try { try {
const days = config.dataRetention.days; const hours = config.dataRetention.days * 24;
logger.info(`Starting automatic data purge (keeping last ${days} days)`); logger.info(`Starting automatic data purge (keeping last ${config.dataRetention.days} days)`);
const msgs = messageQueries.deleteOld.run(hours);
const messagesDeleted = messageQueries.deleteOldMessages.run(days); const pos = positionQueries.deleteOld.run(hours);
const positionsDeleted = positionQueries.deleteOldPositions.run(days); logger.info(`Purge complete: ${msgs.changes} messages, ${pos.changes} positions deleted`);
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(days);
logger.info(
`Data purge completed: ${messagesDeleted.changes} messages, ` +
`${positionsDeleted.changes} positions, ` +
`${telemetryDeleted.changes} telemetry records deleted`
);
} catch (error) { } catch (error) {
logger.error('Error during automatic data purge:', error); logger.error('Error during automatic data purge:', error);
} }
} }
stop() { stop() {
this.tasks.forEach(task => task.stop()); this.tasks.forEach(t => t.stop());
logger.info('Cron service stopped'); 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'); import winston from 'winston';
const path = require('path'); import path from 'path';
const fs = require('fs'); 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'); const logsDir = path.join(__dirname, '..', '..', 'logs');
if (!fs.existsSync(logsDir)) { if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
} }
// Define log format
const logFormat = winston.format.combine( const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, stack }) => { winston.format.printf(({ timestamp, level, message, stack }) => {
if (stack) { if (stack) return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
}
return `${timestamp} [${level.toUpperCase()}]: ${message}`; return `${timestamp} [${level.toUpperCase()}]: ${message}`;
}) })
); );
// Create logger instance
const logger = winston.createLogger({ const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
format: logFormat, format: logFormat,
transports: [ transports: [
// Write all logs to console
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(winston.format.colorize(), logFormat)
winston.format.colorize(),
logFormat
)
}), }),
// Write all logs to combined.log
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'combined.log'), filename: path.join(logsDir, 'combined.log'),
maxsize: 5242880, // 5MB maxsize: 5242880,
maxFiles: 5 maxFiles: 5
}), }),
// Write error logs to error.log
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'error.log'), filename: path.join(logsDir, 'error.log'),
level: 'error', level: 'error',
maxsize: 5242880, // 5MB maxsize: 5242880,
maxFiles: 5 maxFiles: 5
}) })
] ]
}); });
// Create a stream object for Morgan HTTP logger export default logger;
logger.stream = {
write: (message) => {
logger.info(message.trim());
}
};
module.exports = 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();