Move meshtastic mqtt project to meshcore usb project
This commit is contained in:
+9
-21
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -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!
|
|
||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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); }
|
||||||
|
|||||||
+62
-30
@@ -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>
|
||||||
|
<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>
|
<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">×</span>
|
<span class="modal-close">×</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>
|
||||||
|
|||||||
+584
-1139
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
+12
-48
@@ -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) {
|
|
||||||
try {
|
|
||||||
const user = userQueries.getUserByUsername.get(username);
|
const user = userQueries.getUserByUsername.get(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.warn(`Failed login attempt for username: ${username}`);
|
logger.warn(`Failed login attempt for username: ${username}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
const isValid = await verifyPassword(password, user.password_hash);
|
if (!valid) {
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
logger.warn(`Invalid password for username: ${username}`);
|
logger.warn(`Invalid password for username: ${username}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
|
||||||
userQueries.updateLastLogin.run(user.id);
|
userQueries.updateLastLogin.run(user.id);
|
||||||
|
|
||||||
logger.info(`User logged in: ${username}`);
|
logger.info(`User logged in: ${username}`);
|
||||||
|
|
||||||
// Return user without password hash
|
|
||||||
const { password_hash, ...userWithoutPassword } = user;
|
const { password_hash, ...userWithoutPassword } = user;
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error authenticating user:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
@@ -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
@@ -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
@@ -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
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,405 +0,0 @@
|
|||||||
const mqtt = require('mqtt');
|
|
||||||
const config = require('../config/config');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const { nodeQueries, positionQueries, messageQueries, telemetryQueries } = require('../database/queries');
|
|
||||||
|
|
||||||
class MeshtasticMQTTClient {
|
|
||||||
constructor() {
|
|
||||||
this.client = null;
|
|
||||||
this.connected = false;
|
|
||||||
this.messageCallbacks = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
const { broker, username, password, topic, options } = config.mqtt;
|
|
||||||
|
|
||||||
logger.info(`Connecting to MQTT broker: ${broker}`);
|
|
||||||
|
|
||||||
// Create MQTT client
|
|
||||||
this.client = mqtt.connect(broker, {
|
|
||||||
...options,
|
|
||||||
username,
|
|
||||||
password
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection event handlers
|
|
||||||
this.client.on('connect', () => {
|
|
||||||
this.connected = true;
|
|
||||||
logger.info('Connected to MQTT broker');
|
|
||||||
|
|
||||||
// Subscribe to Meshtastic topics
|
|
||||||
this.client.subscribe(topic, (err) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('Failed to subscribe to topic:', err);
|
|
||||||
} else {
|
|
||||||
logger.info(`Subscribed to topic: ${topic}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('error', (error) => {
|
|
||||||
logger.error('MQTT connection error:', error);
|
|
||||||
this.connected = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('close', () => {
|
|
||||||
this.connected = false;
|
|
||||||
logger.warn('MQTT connection closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.on('reconnect', () => {
|
|
||||||
logger.info('Reconnecting to MQTT broker...');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Message handler
|
|
||||||
this.client.on('message', (topic, message) => {
|
|
||||||
this.handleMessage(topic, message);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessage(topic, message) {
|
|
||||||
try {
|
|
||||||
// Parse the topic to extract information
|
|
||||||
const topicParts = topic.split('/');
|
|
||||||
|
|
||||||
// Log raw message for debugging (changed to info for testing)
|
|
||||||
logger.info(`Received MQTT message on topic: ${topic} (${message.length} bytes)`);
|
|
||||||
|
|
||||||
// Try to parse as JSON first (some messages might be JSON)
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(message.toString());
|
|
||||||
this.processJsonMessage(topic, payload);
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, treat as protobuf or binary data
|
|
||||||
this.processRawMessage(topic, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify subscribers
|
|
||||||
this.notifyCallbacks(topic, message);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error handling MQTT message:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processJsonMessage(topic, payload) {
|
|
||||||
try {
|
|
||||||
const { from, to, channel, id, sender } = payload;
|
|
||||||
|
|
||||||
// Extract node ID (convert to hex string if it's a number, fallback to sender if none
|
|
||||||
const fromNode = from ? `!${from.toString(16).padStart(8, '0')}` : sender;
|
|
||||||
const toNode = to ? `!${to.toString(16).padStart(8, '0')}` : null;
|
|
||||||
|
|
||||||
// Debug log for Aether channel
|
|
||||||
if (topic.includes('Aether')) {
|
|
||||||
logger.info(`Aether message - Type: ${payload.type}, From: ${fromNode}, Channel: ${channel}, Payload: ${JSON.stringify(payload)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different message types based on actual JSON structure
|
|
||||||
if ((payload.type === 'sendtext' || payload.type === 'text') && payload.payload) {
|
|
||||||
this.handleTextMessage(fromNode, toNode, {
|
|
||||||
...payload,
|
|
||||||
text: typeof payload.payload === 'string' ? payload.payload : payload.payload.text,
|
|
||||||
id: payload.id,
|
|
||||||
channel: payload.channel,
|
|
||||||
rxSnr: payload.snr,
|
|
||||||
rxRssi: payload.rssi
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'position' && payload.payload) {
|
|
||||||
const pos = payload.payload;
|
|
||||||
this.handlePositionUpdate(fromNode, {
|
|
||||||
latitude: pos.latitude_i / 10000000,
|
|
||||||
longitude: pos.longitude_i / 10000000,
|
|
||||||
altitude: pos.altitude,
|
|
||||||
precisionBits: pos.precision_bits
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'nodeinfo' && payload.payload) {
|
|
||||||
this.handleNodeInfo(fromNode, {
|
|
||||||
shortName: payload.payload.shortname,
|
|
||||||
longName: payload.payload.longname,
|
|
||||||
hardwareModel: payload.payload.hardware,
|
|
||||||
role: payload.payload.role
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'telemetry' && payload.payload) {
|
|
||||||
this.handleTelemetry(fromNode, {
|
|
||||||
deviceMetrics: {
|
|
||||||
batteryLevel: payload.payload.battery_level,
|
|
||||||
voltage: payload.payload.voltage,
|
|
||||||
channelUtilization: payload.payload.channel_utilization,
|
|
||||||
airUtilTx: payload.payload.air_util_tx,
|
|
||||||
uptimeSeconds: payload.payload.uptime_seconds
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'neighborinfo' && payload.payload) {
|
|
||||||
// Just update last_seen for the node
|
|
||||||
nodeQueries.upsertNode.run(
|
|
||||||
fromNode,
|
|
||||||
null, null, null, null, null,
|
|
||||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
|
||||||
null, null, null, null, null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error processing JSON message:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processRawMessage(topic, message) {
|
|
||||||
// For protobuf messages, we'll need to decode them
|
|
||||||
// This is a simplified version - real implementation would use protobuf definitions
|
|
||||||
try {
|
|
||||||
// Extract information from topic
|
|
||||||
const topicParts = topic.split('/');
|
|
||||||
|
|
||||||
// Basic message logging for debugging
|
|
||||||
logger.debug(`Raw message length: ${message.length} bytes`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error processing raw message:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTextMessage(fromNode, toNode, data) {
|
|
||||||
try {
|
|
||||||
if (!fromNode) return;
|
|
||||||
|
|
||||||
logger.info(`Text message from ${fromNode}: ${data.text || '(empty)'}`);
|
|
||||||
|
|
||||||
// Ensure node exists first (upsert)
|
|
||||||
nodeQueries.upsertNode.run(
|
|
||||||
fromNode,
|
|
||||||
null, null, null, null, null,
|
|
||||||
null, // last_heard - will use CURRENT_TIMESTAMP via COALESCE
|
|
||||||
null, null, null, null, null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert message - ensure all values are proper types
|
|
||||||
const messageId = data.id ? String(data.id) : null;
|
|
||||||
const channel = typeof data.channel === 'number' ? data.channel : 0;
|
|
||||||
const text = data.text || '';
|
|
||||||
const rxSnr = typeof data.rxSnr === 'number' ? data.rxSnr : null;
|
|
||||||
const rxRssi = typeof data.rxRssi === 'number' ? data.rxRssi : null;
|
|
||||||
const hopLimit = typeof data.hopLimit === 'number' ? data.hopLimit : null;
|
|
||||||
const wantAck = data.wantAck === true ? 1 : 0;
|
|
||||||
|
|
||||||
messageQueries.insertMessage.run(
|
|
||||||
messageId,
|
|
||||||
fromNode,
|
|
||||||
toNode,
|
|
||||||
channel,
|
|
||||||
text,
|
|
||||||
null, // rx_time - let database use CURRENT_TIMESTAMP for created_at
|
|
||||||
rxSnr,
|
|
||||||
rxRssi,
|
|
||||||
hopLimit,
|
|
||||||
wantAck
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error handling text message:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePositionUpdate(nodeId, data) {
|
|
||||||
try {
|
|
||||||
if (!nodeId || !data.latitude || !data.longitude) return;
|
|
||||||
|
|
||||||
logger.info(`Position update from ${nodeId}: ${data.latitude}, ${data.longitude}`);
|
|
||||||
|
|
||||||
// Update node
|
|
||||||
nodeQueries.upsertNode.run(
|
|
||||||
nodeId,
|
|
||||||
null, // short_name
|
|
||||||
null, // long_name
|
|
||||||
null, // hardware_model
|
|
||||||
null, // role
|
|
||||||
null, // firmware_version
|
|
||||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
|
||||||
null, // battery_level
|
|
||||||
null, // voltage
|
|
||||||
null, // channel_utilization
|
|
||||||
null, // air_util_tx
|
|
||||||
null // uptime_seconds
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert position
|
|
||||||
positionQueries.insertPosition.run(
|
|
||||||
nodeId,
|
|
||||||
data.latitude,
|
|
||||||
data.longitude,
|
|
||||||
data.altitude || null,
|
|
||||||
data.precisionBits || null
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error handling position update:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNodeInfo(nodeId, data) {
|
|
||||||
try {
|
|
||||||
if (!nodeId) return;
|
|
||||||
|
|
||||||
logger.info(`Node info update for ${nodeId}`);
|
|
||||||
|
|
||||||
// Update node information
|
|
||||||
nodeQueries.upsertNode.run(
|
|
||||||
nodeId,
|
|
||||||
data.shortName || data.user?.shortName || null,
|
|
||||||
data.longName || data.user?.longName || null,
|
|
||||||
data.hardwareModel || data.user?.hwModel || null,
|
|
||||||
data.role || null,
|
|
||||||
data.firmwareVersion || null,
|
|
||||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
|
||||||
null, // battery_level
|
|
||||||
null, // voltage
|
|
||||||
null, // channel_utilization
|
|
||||||
null, // air_util_tx
|
|
||||||
null // uptime_seconds
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error handling node info:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTelemetry(nodeId, data) {
|
|
||||||
try {
|
|
||||||
if (!nodeId) return;
|
|
||||||
|
|
||||||
logger.info(`Telemetry update from ${nodeId}`);
|
|
||||||
|
|
||||||
// Update node with telemetry data
|
|
||||||
if (data.deviceMetrics) {
|
|
||||||
const metrics = data.deviceMetrics;
|
|
||||||
|
|
||||||
nodeQueries.upsertNode.run(
|
|
||||||
nodeId,
|
|
||||||
null, // short_name
|
|
||||||
null, // long_name
|
|
||||||
null, // hardware_model
|
|
||||||
null, // role
|
|
||||||
null, // firmware_version
|
|
||||||
null, // last_heard - will use CURRENT_TIMESTAMP
|
|
||||||
metrics.batteryLevel || null,
|
|
||||||
metrics.voltage || null,
|
|
||||||
metrics.channelUtilization || null,
|
|
||||||
metrics.airUtilTx || null,
|
|
||||||
metrics.uptimeSeconds || null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert telemetry record
|
|
||||||
telemetryQueries.insertTelemetry.run(
|
|
||||||
nodeId,
|
|
||||||
metrics.batteryLevel || null,
|
|
||||||
metrics.voltage || null,
|
|
||||||
metrics.channelUtilization || null,
|
|
||||||
metrics.airUtilTx || null,
|
|
||||||
metrics.uptimeSeconds || null,
|
|
||||||
null, // temperature
|
|
||||||
null, // relative_humidity
|
|
||||||
null // barometric_pressure
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.environmentMetrics) {
|
|
||||||
const env = data.environmentMetrics;
|
|
||||||
|
|
||||||
telemetryQueries.insertTelemetry.run(
|
|
||||||
nodeId,
|
|
||||||
null, // battery_level
|
|
||||||
null, // voltage
|
|
||||||
null, // channel_utilization
|
|
||||||
null, // air_util_tx
|
|
||||||
null, // uptime_seconds
|
|
||||||
env.temperature || null,
|
|
||||||
env.relativeHumidity || null,
|
|
||||||
env.barometricPressure || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error handling telemetry:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a message to MQTT
|
|
||||||
publish(topic, message) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!this.connected) {
|
|
||||||
reject(new Error('MQTT client not connected'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client.publish(topic, message, (error) => {
|
|
||||||
if (error) {
|
|
||||||
logger.error('Error publishing message:', error);
|
|
||||||
reject(error);
|
|
||||||
} else {
|
|
||||||
logger.info(`Published message to topic: ${topic}`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send a text message
|
|
||||||
async sendTextMessage(from, text, channel=0) {
|
|
||||||
try {
|
|
||||||
const message = JSON.stringify({
|
|
||||||
from: from,
|
|
||||||
channel: channel,
|
|
||||||
type: 'sendtext',
|
|
||||||
payload: text
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publish to the appropriate topic
|
|
||||||
await this.publish(config.mqtt.pubTopic, message);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error sending text message:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register a callback for messages
|
|
||||||
onMessage(callback) {
|
|
||||||
this.messageCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify all callbacks
|
|
||||||
notifyCallbacks(topic, message) {
|
|
||||||
this.messageCallbacks.forEach(callback => {
|
|
||||||
try {
|
|
||||||
callback(topic, message);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in message callback:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get connection status
|
|
||||||
isConnected() {
|
|
||||||
return this.connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
disconnect() {
|
|
||||||
if (this.client) {
|
|
||||||
this.client.end();
|
|
||||||
this.connected = false;
|
|
||||||
logger.info('Disconnected from MQTT broker');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create singleton instance
|
|
||||||
const mqttClient = new MeshtasticMQTTClient();
|
|
||||||
|
|
||||||
module.exports = mqttClient;
|
|
||||||
+154
-185
@@ -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.post('/purge', requireAuth, (req, res) => {
|
router.get('/serial/status', requireAuth, (req, res) => {
|
||||||
try {
|
const self = serialClient.getSelfInfo();
|
||||||
const { hours } = req.body;
|
res.json({
|
||||||
const hoursToKeep = hours || 720; // Default to 30 days (720 hours)
|
connected: serialClient.isConnected(),
|
||||||
|
port: process.env.SERIAL_PORT || 'COM3',
|
||||||
logger.info(`Starting purge of data older than ${hoursToKeep} hours`);
|
deviceName: self?.name || self?.advName || null
|
||||||
|
|
||||||
// First, update any nodes with NULL last_heard based on their most recent data
|
|
||||||
const nullLastHeardUpdated = nodeQueries.updateNullLastHeard.run();
|
|
||||||
logger.info(`Updated ${nullLastHeardUpdated.changes} nodes with NULL last_heard`);
|
|
||||||
|
|
||||||
const messagesDeleted = messageQueries.deleteOldMessages.run(hoursToKeep);
|
|
||||||
logger.info(`Deleted ${messagesDeleted.changes} old messages`);
|
|
||||||
|
|
||||||
const positionsDeleted = positionQueries.deleteOldPositions.run(hoursToKeep);
|
|
||||||
logger.info(`Deleted ${positionsDeleted.changes} old positions`);
|
|
||||||
|
|
||||||
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(hoursToKeep);
|
|
||||||
logger.info(`Deleted ${telemetryDeleted.changes} old telemetry records`);
|
|
||||||
|
|
||||||
// Check which old nodes still have data before deleting
|
|
||||||
const oldNodesWithData = nodeQueries.getOldNodesWithData.all(hoursToKeep);
|
|
||||||
oldNodesWithData.forEach(node => {
|
|
||||||
logger.info(`Old node ${node.node_id} (last_heard: ${node.last_heard}): ${node.message_count} messages, ${node.position_count} positions, ${node.telemetry_count} telemetry`);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const nodesDeleted = nodeQueries.deleteOldNodes.run(hoursToKeep, hoursToKeep, hoursToKeep, hoursToKeep);
|
router.get('/serial/ports', requireAuth, async (req, res) => {
|
||||||
logger.info(`Deleted ${nodesDeleted.changes} old nodes`);
|
try {
|
||||||
|
const ports = await serialClient.listPorts();
|
||||||
|
res.json(ports);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to list ports' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records, ${nodesDeleted.changes} nodes`);
|
// Device self info
|
||||||
|
router.get('/device', requireAuth, (req, res) => {
|
||||||
|
const self = serialClient.getSelfInfo();
|
||||||
|
if (!self) return res.json({ available: false });
|
||||||
|
|
||||||
// Log activity
|
const pubkeyHex = self.publicKey
|
||||||
const timePeriod = hoursToKeep < 24
|
? (Buffer.isBuffer(self.publicKey) ? self.publicKey.toString('hex') : self.publicKey)
|
||||||
? `${hoursToKeep} hour${hoursToKeep !== 1 ? 's' : ''}`
|
: null;
|
||||||
: `${hoursToKeep / 24} day${hoursToKeep / 24 !== 1 ? 's' : ''}`;
|
|
||||||
|
|
||||||
logActivity(
|
|
||||||
req.session.userId,
|
|
||||||
'purge_data',
|
|
||||||
`Purged data older than ${timePeriod}`,
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
available: true,
|
||||||
deleted: {
|
name: self.name || null,
|
||||||
messages: messagesDeleted.changes,
|
pubkey: pubkeyHex,
|
||||||
positions: positionsDeleted.changes,
|
pubkeyPrefix: pubkeyHex ? pubkeyHex.substring(0, 12) : null,
|
||||||
telemetry: telemetryDeleted.changes,
|
txPower: self.txPower ?? null,
|
||||||
nodes: nodesDeleted.changes
|
latitude: self.advLat != null && self.advLat !== 0 ? self.advLat / 1e6 : null,
|
||||||
}
|
longitude: self.advLon != null && self.advLon !== 0 ? self.advLon / 1e6 : null,
|
||||||
|
nodeType: self.type ?? null,
|
||||||
|
frequency: self.radioFreq ?? null,
|
||||||
|
radioBw: self.radioBw ?? null,
|
||||||
|
radioSf: self.radioSf ?? null,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Channels from device
|
||||||
|
router.get('/channels', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const channels = await serialClient.getChannels();
|
||||||
|
res.json(channels);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to get channels' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data purge
|
||||||
|
router.post('/purge', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const hoursToKeep = req.body.hours || 720;
|
||||||
|
const msgs = messageQueries.deleteOld.run(hoursToKeep);
|
||||||
|
const pos = positionQueries.deleteOld.run(hoursToKeep);
|
||||||
|
const contacts = contactQueries.deleteOld.run(hoursToKeep, hoursToKeep);
|
||||||
|
|
||||||
|
logger.info(`Purged: ${msgs.changes} messages, ${pos.changes} positions, ${contacts.changes} contacts`);
|
||||||
|
logActivity(req.session.userId, 'purge_data', `Purged data older than ${hoursToKeep}h`, req.ip);
|
||||||
|
|
||||||
|
res.json({ success: true, deleted: { messages: msgs.changes, positions: pos.changes, contacts: contacts.changes } });
|
||||||
} catch (error) {
|
} 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;
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { NodeJSSerialConnection, Constants } from '@liamcottle/meshcore.js';
|
||||||
|
import config from '../config/config.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import { contactQueries, positionQueries, messageQueries } from '../database/queries.js';
|
||||||
|
|
||||||
|
class MeshCoreSerialClient {
|
||||||
|
constructor() {
|
||||||
|
this.connection = null;
|
||||||
|
this.connected = false;
|
||||||
|
this.selfInfo = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelay = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const { port } = config.serial;
|
||||||
|
logger.info(`Connecting to MeshCore device on ${port}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.connection = new NodeJSSerialConnection(port);
|
||||||
|
this._patchFrameHandler();
|
||||||
|
this._bindEvents();
|
||||||
|
await this.connection.connect();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to open serial port ${port}: ${error.message}`);
|
||||||
|
this._scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept unknown push codes before they hit the library's console.log fallback.
|
||||||
|
_patchFrameHandler() {
|
||||||
|
const orig = this.connection.onFrameReceived.bind(this.connection);
|
||||||
|
this.connection.onFrameReceived = (frame) => {
|
||||||
|
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
||||||
|
const code = buf[0];
|
||||||
|
|
||||||
|
if (code === 0x8E) {
|
||||||
|
// "Contact heard" push (not in library's PushCodes yet):
|
||||||
|
// [0x8E][snr_int8][rssi_int8][7 bytes route/reserved][32 bytes full pubkey]
|
||||||
|
if (buf.length >= 42) {
|
||||||
|
const snr = (buf.readInt8(1) / 4).toFixed(1);
|
||||||
|
const rssi = buf.readInt8(2);
|
||||||
|
const pubkeyHex = buf.slice(10, 42).toString('hex');
|
||||||
|
logger.debug(`Contact heard: ${pubkeyHex.substring(0, 12)}… SNR=${snr}dB RSSI=${rssi}dBm`);
|
||||||
|
try { contactQueries.updateLastHeard.run(pubkeyHex); } catch {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 0x90) {
|
||||||
|
// Single-byte keepalive/notification — no payload, no action needed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
orig(frame);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
// "connected" fires after deviceQuery succeeds (no data passed)
|
||||||
|
this.connection.on('connected', async () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnectDelay = 5000;
|
||||||
|
logger.info('Serial connected, requesting self info...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.selfInfo = await this.connection.getSelfInfo();
|
||||||
|
logger.info(`Device: ${this.selfInfo.name || '(unnamed)'}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to get self info:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.syncDeviceTime();
|
||||||
|
logger.info('Device clock synced');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to sync device clock:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._syncContacts();
|
||||||
|
await this._drainMessages();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection.on('disconnected', () => {
|
||||||
|
this.connected = false;
|
||||||
|
logger.warn('Disconnected from MeshCore device');
|
||||||
|
this._scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// New message queued on device
|
||||||
|
this.connection.on(Constants.PushCodes.MsgWaiting, async () => {
|
||||||
|
await this._drainMessages();
|
||||||
|
});
|
||||||
|
|
||||||
|
// New contact discovered (manual-add mode)
|
||||||
|
this.connection.on(Constants.PushCodes.NewAdvert, (contact) => {
|
||||||
|
this._upsertContact(contact);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contact re-advertised (auto-add mode)
|
||||||
|
this.connection.on(Constants.PushCodes.Advert, (data) => {
|
||||||
|
// Only pubkey is given; just update last_heard
|
||||||
|
if (data.publicKey) {
|
||||||
|
const hex = this._toHex(data.publicKey);
|
||||||
|
if (hex) {
|
||||||
|
try { contactQueries.updateLastHeard.run(hex); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delivery confirmed
|
||||||
|
this.connection.on(Constants.PushCodes.SendConfirmed, ({ ackCode, roundTrip }) => {
|
||||||
|
if (ackCode != null) {
|
||||||
|
try {
|
||||||
|
messageQueries.markDelivered.run(String(ackCode));
|
||||||
|
logger.info(`Message delivered, ackCode=${ackCode}, rtt=${roundTrip}ms`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error marking delivered:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _syncContacts() {
|
||||||
|
try {
|
||||||
|
const contacts = await this.connection.getContacts();
|
||||||
|
logger.info(`Synced ${contacts.length} contacts from device`);
|
||||||
|
for (const c of contacts) {
|
||||||
|
this._upsertContact(c);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to sync contacts:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _drainMessages() {
|
||||||
|
try {
|
||||||
|
const messages = await this.connection.getWaitingMessages();
|
||||||
|
if (messages.length > 0) logger.info(`Drained ${messages.length} queued messages`);
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.contactMessage) this._handleDirectMessage(m.contactMessage);
|
||||||
|
if (m.channelMessage) this._handleChannelMessage(m.channelMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`Message drain ended: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_upsertContact(contact) {
|
||||||
|
try {
|
||||||
|
const pubkeyHex = this._toHex(contact.publicKey);
|
||||||
|
if (!pubkeyHex) return;
|
||||||
|
|
||||||
|
const lat = (contact.advLat != null && contact.advLat !== 0) ? contact.advLat / 1e6 : null;
|
||||||
|
const lon = (contact.advLon != null && contact.advLon !== 0) ? contact.advLon / 1e6 : null;
|
||||||
|
|
||||||
|
contactQueries.upsert.run(
|
||||||
|
pubkeyHex,
|
||||||
|
contact.advName || null,
|
||||||
|
contact.type ?? 0,
|
||||||
|
contact.flags ?? 0,
|
||||||
|
contact.outPathLen ?? 255,
|
||||||
|
contact.lastAdvert ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
positionQueries.insert.run(pubkeyHex, lat, lon, null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error upserting contact:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleDirectMessage(msg) {
|
||||||
|
try {
|
||||||
|
const fromHex = this._toHex(msg.pubKeyPrefix);
|
||||||
|
const text = msg.text || '';
|
||||||
|
|
||||||
|
logger.info(`Direct msg from ${fromHex}: ${text.substring(0, 80)}`);
|
||||||
|
|
||||||
|
if (fromHex) {
|
||||||
|
try { contactQueries.updateLastHeard.run(fromHex); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageQueries.insert.run(
|
||||||
|
fromHex || 'unknown',
|
||||||
|
null, // to_pubkey (unknown for received messages)
|
||||||
|
null, // channel_idx
|
||||||
|
0, // msg_type: direct
|
||||||
|
text,
|
||||||
|
null, // snr (not parsed by this library version)
|
||||||
|
msg.pathLen ?? null,
|
||||||
|
null, // ack_hash
|
||||||
|
msg.senderTimestamp ?? null
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error handling direct message:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChannelMessage(msg) {
|
||||||
|
try {
|
||||||
|
const channelIdx = msg.channelIdx ?? 0;
|
||||||
|
const text = msg.text || '';
|
||||||
|
|
||||||
|
logger.info(`Channel ${channelIdx} msg: ${text.substring(0, 80)}`);
|
||||||
|
|
||||||
|
messageQueries.insert.run(
|
||||||
|
`chan:${channelIdx}`,
|
||||||
|
null,
|
||||||
|
channelIdx,
|
||||||
|
1, // msg_type: channel
|
||||||
|
text,
|
||||||
|
null, // snr
|
||||||
|
msg.pathLen ?? null,
|
||||||
|
null,
|
||||||
|
msg.senderTimestamp ?? null
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error handling channel message:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_toHex(buf) {
|
||||||
|
if (!buf) return null;
|
||||||
|
if (typeof buf === 'string') return buf;
|
||||||
|
if (buf instanceof Uint8Array || Buffer.isBuffer(buf)) return Buffer.from(buf).toString('hex');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendDirectMessage(pubkeyHex, text) {
|
||||||
|
if (!this.connected) throw new Error('Not connected to device');
|
||||||
|
// sendTextMessage expects full 32-byte key but only uses first 6 bytes internally
|
||||||
|
const keyBuf = Buffer.from(pubkeyHex, 'hex');
|
||||||
|
const result = await this.connection.sendTextMessage(keyBuf, text);
|
||||||
|
return result; // { result, expectedAckCrc, estTimeout }
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendChannelMessage(channelIdx, text) {
|
||||||
|
if (!this.connected) throw new Error('Not connected to device');
|
||||||
|
await this.connection.sendChannelTextMessage(channelIdx, text);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChannels() {
|
||||||
|
if (!this.connected) throw new Error('Not connected to device');
|
||||||
|
return await this.connection.getChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPorts() {
|
||||||
|
try {
|
||||||
|
const { SerialPort } = await import('serialport');
|
||||||
|
const ports = await SerialPort.list();
|
||||||
|
return ports.map(p => ({ path: p.path, manufacturer: p.manufacturer || null }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected() {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelfInfo() {
|
||||||
|
if (!this.selfInfo) return null;
|
||||||
|
return {
|
||||||
|
name: this.selfInfo.name || null,
|
||||||
|
publicKey: this.selfInfo.publicKey ? Buffer.from(this.selfInfo.publicKey).toString('hex') : null,
|
||||||
|
type: this.selfInfo.type ?? null,
|
||||||
|
txPower: this.selfInfo.txPower ?? null,
|
||||||
|
maxTxPower: this.selfInfo.maxTxPower ?? null,
|
||||||
|
advLat: this.selfInfo.advLat ?? null,
|
||||||
|
advLon: this.selfInfo.advLon ?? null,
|
||||||
|
radioFreq: this.selfInfo.radioFreq ?? null,
|
||||||
|
radioBw: this.selfInfo.radioBw ?? null,
|
||||||
|
radioSf: this.selfInfo.radioSf ?? null,
|
||||||
|
radioCr: this.selfInfo.radioCr ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.connection) {
|
||||||
|
try { this.connection.close(); } catch {}
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
this.connected = false;
|
||||||
|
logger.info('Serial client disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
logger.info(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
||||||
|
this.reconnectTimer = setTimeout(async () => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
|
||||||
|
await this.connect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new MeshCoreSerialClient();
|
||||||
+26
-55
@@ -1,20 +1,20 @@
|
|||||||
const express = require('express');
|
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
@@ -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
@@ -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;
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
|
||||||
Reference in New Issue
Block a user