barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh
commit
7f04566242
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python manage.py migrate:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/media
|
||||
/staticfiles
|
||||
|
||||
# React / Node
|
||||
node_modules/
|
||||
build/
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
@ -0,0 +1,85 @@
|
||||
# ScalesApp - Multi-Component Application
|
||||
|
||||
A distributed application consisting of three main components:
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. **Frontend (React)**
|
||||
- Location: `/frontend`
|
||||
- Description: React web application that displays real-time data from the serial port
|
||||
- Communicates with: Django backend via REST API
|
||||
- Port: 3000 (default)
|
||||
|
||||
### 2. **Backend (Django)**
|
||||
- Location: `/backend`
|
||||
- Description: Django REST API server for storing and retrieving serial data
|
||||
- Database: SQLite (configurable)
|
||||
- Port: 8000 (default)
|
||||
|
||||
### 3. **Serial Bridge (Python)**
|
||||
- Location: `/serial_bridge`
|
||||
- Description: Python application that reads data from COM ports
|
||||
- Features: System tray integration, runs as EXE (via PyInstaller)
|
||||
- Communicates with: Django backend via REST API
|
||||
- Data Flow: COM Port → Serial Bridge → Django Backend → React Frontend
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 16+ (for React)
|
||||
- Python 3.8+ (for Django and Serial Bridge)
|
||||
- pip or conda for Python packages
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Backend Setup**
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
2. **Frontend Setup**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
3. **Serial Bridge Setup**
|
||||
```bash
|
||||
cd serial_bridge
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
COM Port
|
||||
↓
|
||||
Serial Bridge (Python)
|
||||
↓
|
||||
Django Backend REST API
|
||||
↓
|
||||
React Frontend
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Each component has its own configuration file:
|
||||
- Frontend: `.env`
|
||||
- Backend: `settings.py` and `.env`
|
||||
- Serial Bridge: `config.py` and `.env`
|
||||
|
||||
## Development Tips
|
||||
|
||||
- Keep all three services running during development
|
||||
- Use `CORS` headers properly in Django for frontend requests
|
||||
- Serial Bridge should continuously read COM data and POST to backend
|
||||
- Frontend should poll/subscribe to backend for updates
|
||||
@ -0,0 +1,308 @@
|
||||
# ScalesApp - Complete Setup & Installation Guide
|
||||
|
||||
A three-tier application for reading serial COM port data, storing it in a Django backend, and displaying it in a React frontend.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ScalesApp/
|
||||
├── frontend/ # React web application
|
||||
│ ├── src/
|
||||
│ ├── public/
|
||||
│ ├── package.json
|
||||
│ └── .env.example
|
||||
├── backend/ # Django REST API
|
||||
│ ├── api/ # Main API app
|
||||
│ ├── manage.py
|
||||
│ ├── settings.py
|
||||
│ ├── requirements.txt
|
||||
│ └── .env.example
|
||||
├── serial_bridge/ # Python serial reader (system tray)
|
||||
│ ├── app.py
|
||||
│ ├── serial_reader.py
|
||||
│ ├── backend_client.py
|
||||
│ ├── tray_icon.py
|
||||
│ ├── config.py
|
||||
│ ├── requirements.txt
|
||||
│ ├── serial_bridge.spec
|
||||
│ └── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** (for Django and Serial Bridge)
|
||||
- **Node.js 16+** (for React)
|
||||
- **Windows OS** (Serial Bridge uses Windows-specific system tray)
|
||||
- A COM port device (scales or serial reader)
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
### 1. Backend (Django) Setup
|
||||
|
||||
#### a. Create virtual environment
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
#### b. Install dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### c. Setup environment variables
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Edit .env file with your settings
|
||||
```
|
||||
|
||||
#### d. Initialize database
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
#### e. Create admin user (optional)
|
||||
```bash
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
#### f. Run the backend server
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
**Available API Endpoints:**
|
||||
- `GET /api/readings/` - List all readings
|
||||
- `POST /api/readings/` - Create a new reading
|
||||
- `GET /api/readings/latest/` - Get the latest reading
|
||||
- `GET /api/readings/by_port/?port=COM1` - Get readings from specific port
|
||||
|
||||
### 2. Frontend (React) Setup
|
||||
|
||||
#### a. Install dependencies
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
#### b. Setup environment variables
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Ensure REACT_APP_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
#### c. Start development server
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The frontend will open at `http://localhost:3000`
|
||||
|
||||
**Features:**
|
||||
- Real-time data display from COM ports
|
||||
- Port selector for filtering
|
||||
- Recent readings table
|
||||
- Auto-refresh every 2 seconds
|
||||
|
||||
### 3. Serial Bridge (Python) Setup
|
||||
|
||||
#### a. Create virtual environment
|
||||
```bash
|
||||
cd serial_bridge
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
#### b. Install dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### c. Setup environment variables
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Edit .env with your COM port and backend URL
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- `COM_PORT=COM1` (change to your port)
|
||||
- `BAUD_RATE=9600` (adjust based on your device)
|
||||
- `BACKEND_URL=http://localhost:8000`
|
||||
|
||||
#### d. Run the application
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
The app will:
|
||||
- Appear in system tray
|
||||
- Start reading from COM port
|
||||
- Automatically post data to Django backend
|
||||
- Retry failed posts
|
||||
- Log to `serial_bridge.log`
|
||||
|
||||
### 4. Running All Services (Development)
|
||||
|
||||
Open 3 terminal windows and run:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
python manage.py runserver
|
||||
|
||||
# Terminal 2: Frontend
|
||||
cd frontend
|
||||
npm start
|
||||
|
||||
# Terminal 3: Serial Bridge
|
||||
cd serial_bridge
|
||||
venv\Scripts\activate
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Building Serial Bridge as EXE
|
||||
|
||||
To create a standalone Windows executable that runs in the system tray:
|
||||
|
||||
```bash
|
||||
cd serial_bridge
|
||||
|
||||
# Install PyInstaller
|
||||
pip install pyinstaller
|
||||
|
||||
# Build the executable
|
||||
pyinstaller serial_bridge.spec
|
||||
```
|
||||
|
||||
The executable will be created at `serial_bridge\dist\ScalesApp\ScalesApp.exe`
|
||||
|
||||
You can:
|
||||
- Run it directly
|
||||
- Add it to Startup folder (`C:\Users\<User>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`)
|
||||
- Create a Windows Task Scheduler entry to run at startup
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Physical Device │
|
||||
│ (Scales/COM) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ Serial data
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Serial Bridge (Python) │
|
||||
│ - Reads COM port │
|
||||
│ - System tray app │
|
||||
│ - Retry logic │
|
||||
└────────┬─────────────────────┘
|
||||
│
|
||||
│ HTTP POST /api/readings/
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Django Backend │
|
||||
│ - REST API │
|
||||
│ - Database storage │
|
||||
│ - CORS enabled │
|
||||
└────────┬─────────────────────┘
|
||||
│
|
||||
│ REST API GET /api/readings/
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ React Frontend │
|
||||
│ - Real-time display │
|
||||
│ - Port selector │
|
||||
│ - Auto-refresh │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Backend (backend/.env)
|
||||
```env
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DATABASE_URL=sqlite:///db.sqlite3
|
||||
```
|
||||
|
||||
### Frontend (frontend/.env)
|
||||
```env
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### Serial Bridge (serial_bridge/.env)
|
||||
```env
|
||||
COM_PORT=COM1
|
||||
BAUD_RATE=9600
|
||||
BACKEND_URL=http://localhost:8000
|
||||
AUTO_CONNECT=True
|
||||
DEBUG=False
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Issues
|
||||
- **Port already in use**: Change port: `python manage.py runserver 8001`
|
||||
- **CORS errors**: Check `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS` in settings.py
|
||||
- **Database errors**: Run migrations: `python manage.py migrate`
|
||||
|
||||
### Frontend Issues
|
||||
- **Cannot connect to backend**: Ensure Django is running and `REACT_APP_API_URL` is correct
|
||||
- **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000`
|
||||
|
||||
### Serial Bridge Issues
|
||||
- **COM port not found**: List available ports with: `python -m serial.tools.list_ports`
|
||||
- **Backend not reachable**: Check `BACKEND_URL` and ensure Django server is running
|
||||
- **No tray icon**: Run as administrator, check Windows system tray settings
|
||||
- **Data not posting**: Check `serial_bridge.log` for errors
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
# Use production settings
|
||||
export DJANGO_SETTINGS_MODULE=scalesapp.settings
|
||||
python manage.py collectstatic
|
||||
# Use Gunicorn
|
||||
pip install gunicorn
|
||||
gunicorn scalesapp.wsgi -b 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
npm run build
|
||||
# Serve dist folder with nginx or similar
|
||||
```
|
||||
|
||||
### Serial Bridge
|
||||
Create Windows Service or Scheduled Task to run the EXE at startup.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Test all three components locally
|
||||
2. ✅ Verify data flow from COM → Backend → Frontend
|
||||
3. ✅ Customize React components if needed
|
||||
4. ✅ Build Serial Bridge as EXE
|
||||
5. ✅ Deploy to production servers
|
||||
6. ✅ Setup monitoring and logging
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Django REST Framework Docs](https://www.django-rest-framework.org/)
|
||||
- [React Docs](https://react.dev/)
|
||||
- [PySerial Docs](https://pyserial.readthedocs.io/)
|
||||
- [Pystray Docs](https://github.com/moses-palmer/pystray)
|
||||
|
||||
## Support
|
||||
|
||||
For issues, check the logs:
|
||||
- Backend: Django console output
|
||||
- Frontend: Browser console (F12)
|
||||
- Serial Bridge: `serial_bridge.log` file
|
||||
@ -0,0 +1,14 @@
|
||||
# Backend environment variables
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database Settings
|
||||
# Use 'django.db.backends.sqlite3' for SQLite (default)
|
||||
# Use 'django.db.backends.postgresql' for PostgreSQL
|
||||
DB_ENGINE=django.db.backends.sqlite3
|
||||
DB_NAME=db.sqlite3
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
@ -0,0 +1 @@
|
||||
# Django API app
|
||||
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api'
|
||||
@ -0,0 +1,34 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from api.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates predefined admin user (username: admin, password: admin)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = 'admin'
|
||||
password = 'admin'
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'User "{username}" already exists')
|
||||
)
|
||||
return
|
||||
|
||||
user = User.objects.create(
|
||||
username=username,
|
||||
email='admin@scalesapp.com',
|
||||
role='employee',
|
||||
is_admin=True,
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Successfully created admin user: {username} / {password}'
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,174 @@
|
||||
# Generated by Django 4.2 on 2026-01-12 18:39
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[("employee", "Employee"), ("viewer", "Viewer")],
|
||||
default="viewer",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("is_admin", models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
"db_table": "api_user",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ComPortReading",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("port", models.CharField(max_length=20)),
|
||||
("data", models.TextField()),
|
||||
("timestamp", models.DateTimeField(auto_now_add=True)),
|
||||
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="comportreading",
|
||||
index=models.Index(
|
||||
fields=["-timestamp"], name="api_comport_timesta_c2b399_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="comportreading",
|
||||
index=models.Index(
|
||||
fields=["port", "-timestamp"], name="api_comport_port_123b9f_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="groups",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="user_permissions",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,38 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Custom User model with role and admin flag"""
|
||||
ROLE_CHOICES = [
|
||||
('employee', 'Employee'),
|
||||
('viewer', 'Viewer'),
|
||||
]
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||
is_admin = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_user'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.get_role_display()})"
|
||||
|
||||
|
||||
class ComPortReading(models.Model):
|
||||
"""Model to store serial port readings"""
|
||||
port = models.CharField(max_length=20)
|
||||
data = models.TextField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
source_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['-timestamp']),
|
||||
models.Index(fields=['port', '-timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.port} - {self.timestamp}"
|
||||
@ -0,0 +1,194 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from .models import ComPortReading, User
|
||||
from vehicles.models import Vehicle, VehicleExtra
|
||||
from nomenclatures.models import Nomenclature, NomenclatureField
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||
'role', 'is_admin', 'is_active', 'date_joined', 'password']
|
||||
read_only_fields = ['id', 'date_joined']
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True}
|
||||
}
|
||||
|
||||
def create(self, validated_data):
|
||||
password = validated_data.pop('password', None)
|
||||
user = User(**validated_data)
|
||||
if password:
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
password = validated_data.pop('password', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
if password:
|
||||
instance.set_password(password)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class UserDetailSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for current user details (excludes password)"""
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||
'role', 'is_admin', 'is_active', 'date_joined']
|
||||
read_only_fields = ['id', 'date_joined']
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
"""Serializer for password change endpoint"""
|
||||
old_password = serializers.CharField(required=True, write_only=True)
|
||||
new_password = serializers.CharField(required=True, write_only=True)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
user = self.context['request'].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect")
|
||||
return value
|
||||
|
||||
def validate_new_password(self, value):
|
||||
# Use Django's password validators
|
||||
validate_password(value, self.context['request'].user)
|
||||
return value
|
||||
|
||||
def save(self, **kwargs):
|
||||
user = self.context['request'].user
|
||||
user.set_password(self.validated_data['new_password'])
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class ComPortReadingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ComPortReading
|
||||
fields = ['id', 'port', 'data', 'timestamp', 'source_ip']
|
||||
read_only_fields = ['id', 'timestamp']
|
||||
|
||||
|
||||
class VehicleExtraSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = VehicleExtra
|
||||
fields = ['id', 'data']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class VehicleSerializer(serializers.ModelSerializer):
|
||||
extra = VehicleExtraSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number', 'extra']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def create(self, validated_data):
|
||||
extra_data = validated_data.pop('extra', None)
|
||||
vehicle = Vehicle.objects.create(**validated_data)
|
||||
|
||||
if extra_data:
|
||||
VehicleExtra.objects.create(vehicle=vehicle, **extra_data)
|
||||
|
||||
return vehicle
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
extra_data = validated_data.pop('extra', None)
|
||||
|
||||
# Update Vehicle fields
|
||||
instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number)
|
||||
instance.save()
|
||||
|
||||
# Handle VehicleExtra update/creation
|
||||
if extra_data is not None:
|
||||
if hasattr(instance, 'extra'):
|
||||
# Update existing VehicleExtra
|
||||
for attr, value in extra_data.items():
|
||||
setattr(instance.extra, attr, value)
|
||||
instance.extra.save()
|
||||
else:
|
||||
# Create new VehicleExtra
|
||||
VehicleExtra.objects.create(vehicle=instance, **extra_data)
|
||||
|
||||
return instance
|
||||
|
||||
def validate_vehicle_number(self, value):
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Vehicle number cannot be empty")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NomenclatureField
|
||||
fields = ['id', 'key', 'field_type']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def validate_key(self, value):
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Field key cannot be empty")
|
||||
return value.strip()
|
||||
|
||||
def validate_field_type(self, value):
|
||||
valid_types = [choice[0] for choice in NomenclatureField.FIELD_TYPES]
|
||||
if value not in valid_types:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid field type. Must be one of: {', '.join(valid_types)}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class NomenclatureSerializer(serializers.ModelSerializer):
|
||||
fields = NomenclatureFieldSerializer(many=True, required=False, source='nomenclaturefield_set')
|
||||
|
||||
class Meta:
|
||||
model = Nomenclature
|
||||
fields = ['id', 'code', 'name', 'applies_to', 'fields']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def create(self, validated_data):
|
||||
fields_data = validated_data.pop('nomenclaturefield_set', [])
|
||||
nomenclature = Nomenclature.objects.create(**validated_data)
|
||||
|
||||
for field_data in fields_data:
|
||||
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
|
||||
|
||||
return nomenclature
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
fields_data = validated_data.pop('nomenclaturefield_set', None)
|
||||
|
||||
# Update Nomenclature fields
|
||||
instance.code = validated_data.get('code', instance.code)
|
||||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.applies_to = validated_data.get('applies_to', instance.applies_to)
|
||||
instance.save()
|
||||
|
||||
# Handle fields update - replace all fields
|
||||
if fields_data is not None:
|
||||
# Delete existing fields
|
||||
instance.nomenclaturefield_set.all().delete()
|
||||
# Create new fields
|
||||
for field_data in fields_data:
|
||||
NomenclatureField.objects.create(nomenclature=instance, **field_data)
|
||||
|
||||
return instance
|
||||
|
||||
def validate_code(self, value):
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Code cannot be empty")
|
||||
return value.strip()
|
||||
|
||||
def validate_applies_to(self, value):
|
||||
valid_choices = ['vehicle', 'container']
|
||||
if value not in valid_choices:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}"
|
||||
)
|
||||
return value
|
||||
@ -0,0 +1,22 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'users', views.UserViewSet, basename='user')
|
||||
router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
|
||||
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
|
||||
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
|
||||
|
||||
urlpatterns = [
|
||||
# JWT token endpoints
|
||||
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
|
||||
# Router URLs
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
@ -0,0 +1,211 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from .models import ComPortReading, User
|
||||
from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer
|
||||
from vehicles.models import Vehicle
|
||||
from nomenclatures.models import Nomenclature
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for user management.
|
||||
|
||||
list: Get all users
|
||||
create: Create a new user
|
||||
retrieve: Get a specific user
|
||||
update: Update a user
|
||||
destroy: Delete a user
|
||||
me: Get current authenticated user
|
||||
change_password: Change password for current user
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
filterset_fields = ['role', 'is_admin', 'is_active']
|
||||
ordering = ['username']
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
def me(self, request):
|
||||
"""Get current authenticated user details"""
|
||||
serializer = UserDetailSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated],
|
||||
url_path='change-password', url_name='change_password')
|
||||
def change_password(self, request):
|
||||
"""Change password for current user"""
|
||||
serializer = ChangePasswordSerializer(
|
||||
data=request.data,
|
||||
context={'request': request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({
|
||||
'detail': 'Password updated successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for COM port readings.
|
||||
|
||||
list: Get all readings with optional filtering
|
||||
create: Create a new reading (allows unauthenticated for serial bridge)
|
||||
retrieve: Get a specific reading
|
||||
destroy: Delete a reading
|
||||
latest: Get the latest reading
|
||||
by_port: Get readings for a specific port
|
||||
"""
|
||||
queryset = ComPortReading.objects.all()
|
||||
serializer_class = ComPortReadingSerializer
|
||||
filterset_fields = ['port']
|
||||
ordering = ['-timestamp']
|
||||
|
||||
def get_permissions(self):
|
||||
"""Allow unauthenticated POST for serial bridge"""
|
||||
if self.action == 'create':
|
||||
return [AllowAny()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def latest(self, request):
|
||||
"""Get the latest reading"""
|
||||
reading = ComPortReading.objects.first()
|
||||
if reading:
|
||||
serializer = self.get_serializer(reading)
|
||||
return Response(serializer.data)
|
||||
return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def by_port(self, request):
|
||||
"""Get readings for a specific port"""
|
||||
port = request.query_params.get('port', None)
|
||||
if not port:
|
||||
return Response(
|
||||
{'detail': 'port parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
readings = ComPortReading.objects.filter(port=port)
|
||||
serializer = self.get_serializer(readings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""Get client IP address"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the reading with client IP"""
|
||||
serializer.save(source_ip=self.get_client_ip(self.request))
|
||||
|
||||
|
||||
class VehicleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for vehicle management.
|
||||
|
||||
list: Get all vehicles with pagination
|
||||
create: Create a new vehicle with optional extra data
|
||||
retrieve: Get a specific vehicle by ID
|
||||
update: Full update of vehicle and extra data
|
||||
partial_update: Partial update of vehicle
|
||||
destroy: Delete a vehicle (cascades to VehicleExtra)
|
||||
by_number: Get vehicle by vehicle_number
|
||||
"""
|
||||
queryset = Vehicle.objects.select_related('extra').all()
|
||||
serializer_class = VehicleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['vehicle_number']
|
||||
search_fields = ['vehicle_number']
|
||||
ordering = ['vehicle_number']
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-number')
|
||||
def by_number(self, request):
|
||||
"""Get vehicle by vehicle_number query parameter"""
|
||||
vehicle_number = request.query_params.get('vehicle_number', None)
|
||||
if not vehicle_number:
|
||||
return Response(
|
||||
{'detail': 'vehicle_number parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
vehicle = Vehicle.objects.get(vehicle_number=vehicle_number)
|
||||
serializer = self.get_serializer(vehicle)
|
||||
return Response(serializer.data)
|
||||
except Vehicle.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': 'Vehicle not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for nomenclature management.
|
||||
|
||||
list: Get all nomenclatures with pagination
|
||||
create: Create a new nomenclature with fields
|
||||
retrieve: Get a specific nomenclature by ID
|
||||
update: Full update of nomenclature and fields
|
||||
partial_update: Partial update of nomenclature
|
||||
destroy: Delete a nomenclature (cascades to fields)
|
||||
by_code: Get nomenclature by code
|
||||
by_applies_to: Filter nomenclatures by applies_to type
|
||||
"""
|
||||
queryset = Nomenclature.objects.prefetch_related('nomenclaturefield_set').all()
|
||||
serializer_class = NomenclatureSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['applies_to', 'code']
|
||||
search_fields = ['code', 'name']
|
||||
ordering = ['code']
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-code')
|
||||
def by_code(self, request):
|
||||
"""Get nomenclature by code query parameter"""
|
||||
code = request.query_params.get('code', None)
|
||||
if not code:
|
||||
return Response(
|
||||
{'detail': 'code parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
nomenclature = Nomenclature.objects.get(code=code)
|
||||
serializer = self.get_serializer(nomenclature)
|
||||
return Response(serializer.data)
|
||||
except Nomenclature.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': 'Nomenclature not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-applies-to')
|
||||
def by_applies_to(self, request):
|
||||
"""Get nomenclatures filtered by applies_to query parameter"""
|
||||
applies_to = request.query_params.get('applies_to', None)
|
||||
if not applies_to:
|
||||
return Response(
|
||||
{'detail': 'applies_to parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if applies_to not in ['vehicle', 'container']:
|
||||
return Response(
|
||||
{'detail': 'applies_to must be either vehicle or container'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
nomenclatures = Nomenclature.objects.filter(applies_to=applies_to)
|
||||
page = self.paginate_queryset(nomenclatures)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(nomenclatures, many=True)
|
||||
return Response(serializer.data)
|
||||
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NomenclaturesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "nomenclatures"
|
||||
@ -0,0 +1,71 @@
|
||||
# Generated by Django 4.2.8 on 2026-01-13 16:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Nomenclature",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("code", models.CharField(max_length=50, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"applies_to",
|
||||
models.CharField(
|
||||
choices=[("vehicle", "Vehicle"), ("container", "Container")],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NomenclatureField",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("key", models.CharField(max_length=50)),
|
||||
(
|
||||
"field_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("text", "Text"),
|
||||
("number", "Number"),
|
||||
("bool", "Boolean"),
|
||||
("choice", "Choice"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"nomenclature",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="nomenclatures.nomenclature",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,29 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
class Nomenclature(models.Model):
|
||||
code = models.CharField(max_length=50, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
applies_to = models.CharField(
|
||||
max_length=50,
|
||||
choices=[("vehicle", "Vehicle"), ("container", "Container")]
|
||||
)
|
||||
|
||||
class NomenclatureField(models.Model):
|
||||
TEXT = "text"
|
||||
NUMBER = "number"
|
||||
BOOL = "bool"
|
||||
CHOICE = "choice"
|
||||
|
||||
FIELD_TYPES = [
|
||||
(TEXT, "Text"),
|
||||
(NUMBER, "Number"),
|
||||
(BOOL, "Boolean"),
|
||||
(CHOICE, "Choice"),
|
||||
]
|
||||
|
||||
nomenclature = models.ForeignKey(
|
||||
Nomenclature, on_delete=models.CASCADE
|
||||
)
|
||||
key = models.CharField(max_length=50)
|
||||
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
||||
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@ -0,0 +1,5 @@
|
||||
SSE - in scalesapp
|
||||
async def authenticate_sse_request(request)
|
||||
def broadcast_update(object_name, action, data)
|
||||
async def updates_sse(request)
|
||||
def send_message(request)
|
||||
@ -0,0 +1,10 @@
|
||||
Django==6.0.1
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework-simplejwt==5.5.1
|
||||
django-cors-headers==4.6.0
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
sqlparse==0.5.3
|
||||
psycopg==3.3.2
|
||||
psycopg-binary==3.3.2
|
||||
uvicorn[standard]==0.34.0
|
||||
@ -0,0 +1,15 @@
|
||||
"""
|
||||
ASGI config for scalesapp project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@ -0,0 +1,156 @@
|
||||
"""
|
||||
Django settings for scalesapp project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-dev-key-change-in-production')
|
||||
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
|
||||
PROJECT_APPS = [
|
||||
'api',
|
||||
'vehicles',
|
||||
'nomenclatures',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
] + PROJECT_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'scalesapp.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'scalesapp.wsgi.application'
|
||||
ASGI_APPLICATION = 'scalesapp.asgi.application'
|
||||
|
||||
# Database configuration with support for SQLite and PostgreSQL
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': "django.db.backends.postgresql",
|
||||
'NAME': os.getenv('DB_NAME', 'scalesapp'),
|
||||
'USER': os.getenv('DB_USER', 'postgres'),
|
||||
'PASSWORD': os.getenv('DB_PASSWORD', ''),
|
||||
'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||
'PORT': os.getenv('DB_PORT', '5432'),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = 'api.User'
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5174',
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_HEADERS = [
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
]
|
||||
CORS_ALLOW_METHODS = [
|
||||
'DELETE',
|
||||
'GET',
|
||||
'OPTIONS',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
]
|
||||
|
||||
# DRF Configuration
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 100,
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
}
|
||||
|
||||
# JWT Configuration
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
'BLACKLIST_AFTER_ROTATION': False,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
'USER_ID_FIELD': 'id',
|
||||
'USER_ID_CLAIM': 'user_id',
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
"""
|
||||
SSE - Async Implementation for ASGI
|
||||
Server Side Events, opens connection and keeps it alive, broadcasting new/changed objects to all connected clients
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
from django.http import StreamingHttpResponse, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.shortcuts import render
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from asgiref.sync import sync_to_async
|
||||
import threading
|
||||
|
||||
# Global updates queue for all objects
|
||||
updates_queue = []
|
||||
updates_lock = threading.Lock()
|
||||
|
||||
# Global event ID counter
|
||||
event_id_counter = 0
|
||||
event_id_lock = threading.Lock()
|
||||
|
||||
# called from views when an object is changed to broadcast the change to all connected clients
|
||||
def sse_broadcast_update(object_name, action, data):
|
||||
"""
|
||||
Broadcast object update to all connected SSE clients
|
||||
object_name: 'agent', 'vessel', 'cargo', and others..
|
||||
action: 'created', 'updated', or 'deleted'
|
||||
data: serialized object data
|
||||
"""
|
||||
global event_id_counter
|
||||
|
||||
# Map action to operation
|
||||
operation_map = {
|
||||
'created': 'insert',
|
||||
'updated': 'update',
|
||||
'deleted': 'delete'
|
||||
}
|
||||
|
||||
with event_id_lock:
|
||||
event_id_counter += 1
|
||||
current_event_id = event_id_counter
|
||||
|
||||
with updates_lock:
|
||||
updates_queue.append({
|
||||
'event_id': current_event_id, # Add event ID
|
||||
'type': 'data_update',
|
||||
'object': object_name,
|
||||
'operation': operation_map.get(action, action),
|
||||
'data': data,
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
# Keep only last 100 updates
|
||||
while len(updates_queue) > 100:
|
||||
updates_queue.pop(0)
|
||||
|
||||
|
||||
async def sse_authenticate_request(request):
|
||||
"""
|
||||
Called from sse_connect, Authenticate SSE request via JWT token from Authorization header or query parameter.
|
||||
Returns (user, error_response) - if error_response is not None, return it immediately.
|
||||
"""
|
||||
# Try to get token from Authorization header
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token_key = None
|
||||
|
||||
if auth_header.startswith('Bearer '):
|
||||
token_key = auth_header[7:]
|
||||
|
||||
# Fallback: try query parameter (for browsers' EventSource that can't set headers)
|
||||
if not token_key:
|
||||
token_key = request.GET.get('token')
|
||||
|
||||
if not token_key:
|
||||
return None, JsonResponse({'error': 'Authentication required'}, status=401)
|
||||
|
||||
try:
|
||||
# Validate JWT token - wrap in sync_to_async for DB queries
|
||||
jwt_auth = JWTAuthentication()
|
||||
validated_token = jwt_auth.get_validated_token(token_key)
|
||||
user = await sync_to_async(jwt_auth.get_user)(validated_token)
|
||||
return user, None
|
||||
except (InvalidToken, TokenError) as e:
|
||||
return None, JsonResponse({'error': 'Invalid token'}, status=401)
|
||||
|
||||
|
||||
|
||||
# User calls update_sse to establish a sse connection, then he receives updates until disconnect or error
|
||||
|
||||
@csrf_exempt
|
||||
async def sse_connect(request):
|
||||
"""SSE endpoint for all object updates (requires authentication)"""
|
||||
|
||||
# Authenticate the request
|
||||
user, error_response = await sse_authenticate_request(request)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
# Check for Last-Event-ID header (browser sends this on reconnect)
|
||||
last_event_id_header = request.headers.get('Last-Event-ID', None)
|
||||
|
||||
# On fresh connection (no Last-Event-ID), use current event counter as baseline
|
||||
# On reconnection (has Last-Event-ID), use that to replay missed events
|
||||
if last_event_id_header:
|
||||
last_sent_event_id = int(last_event_id_header)
|
||||
print(f"SSE reconnection: Last-Event-ID={last_event_id_header}")
|
||||
else:
|
||||
# Fresh connection - use current event counter as baseline
|
||||
with event_id_lock:
|
||||
last_sent_event_id = event_id_counter
|
||||
print(f"SSE new connection: Starting from event #{last_sent_event_id}")
|
||||
|
||||
# Async generator for ASGI compatibility
|
||||
async def event_generator():
|
||||
nonlocal last_sent_event_id
|
||||
|
||||
# Send connection established message with event ID
|
||||
connected_msg = {
|
||||
"type": "connected",
|
||||
"message": "Connected to updates stream",
|
||||
"current_event_id": last_sent_event_id
|
||||
}
|
||||
yield f'id: {last_sent_event_id}\n'.encode('utf-8')
|
||||
yield f'data: {json.dumps(connected_msg)}\n\n'.encode('utf-8')
|
||||
|
||||
# Keep connection alive and send new updates
|
||||
while True:
|
||||
try:
|
||||
with updates_lock:
|
||||
# Get updates since last event ID
|
||||
missed_updates = [u for u in updates_queue if u['event_id'] > last_sent_event_id]
|
||||
|
||||
if missed_updates:
|
||||
print(f"Sending {len(missed_updates)} missed updates (after event #{last_sent_event_id})")
|
||||
|
||||
for update in missed_updates:
|
||||
event_id = update['event_id']
|
||||
data = json.dumps(update)
|
||||
# Send event with ID in SSE format
|
||||
yield f'id: {event_id}\n'.encode('utf-8')
|
||||
yield f'data: {data}\n\n'.encode('utf-8')
|
||||
last_sent_event_id = event_id
|
||||
|
||||
# Send keepalive comment to prevent timeout
|
||||
yield b': keepalive\n\n'
|
||||
|
||||
# Use async sleep for proper ASGI compatibility
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Updates SSE error: {e}")
|
||||
break
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
event_generator(),
|
||||
content_type='text/event-stream'
|
||||
)
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
response['X-Accel-Buffering'] = 'no'
|
||||
|
||||
return response
|
||||
|
||||
# @csrf_exempt
|
||||
# def send_message(request):
|
||||
# """Endpoint to send a message"""
|
||||
# if request.method != 'POST':
|
||||
# return JsonResponse({'error': 'POST only'}, status=405)
|
||||
|
||||
# try:
|
||||
# data = json.loads(request.body)
|
||||
# message_text = data.get('message', '').strip()
|
||||
# sender = data.get('sender', 'Anonymous')
|
||||
|
||||
# if not message_text:
|
||||
# return JsonResponse({'error': 'Empty message'}, status=400)
|
||||
|
||||
# # Add message to global queue
|
||||
# with messages_lock:
|
||||
# messages.append({
|
||||
# 'type': 'message',
|
||||
# 'sender': sender,
|
||||
# 'message': message_text,
|
||||
# 'timestamp': time.time()
|
||||
# })
|
||||
|
||||
# # Keep only last 100 messages
|
||||
# while len(messages) > 100:
|
||||
# messages.pop(0)
|
||||
|
||||
# return JsonResponse({'success': True})
|
||||
|
||||
# except Exception as e:
|
||||
# return JsonResponse({'error': str(e)}, status=500)
|
||||
@ -0,0 +1,12 @@
|
||||
"""
|
||||
Main URL configuration for scalesapp.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from .sse import sse_connect
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('api.urls')),
|
||||
path("sse-connect/", sse_connect, name='sse-connect'),
|
||||
]
|
||||
@ -0,0 +1,10 @@
|
||||
"""
|
||||
WSGI config for scalesapp project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VehiclesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "vehicles"
|
||||
@ -0,0 +1,52 @@
|
||||
# Generated by Django 4.2.8 on 2026-01-13 16:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Vehicle",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("vehicle_number", models.CharField(max_length=15, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="VehicleExtra",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(default=dict)),
|
||||
(
|
||||
"vehicle",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="extra",
|
||||
to="vehicles.vehicle",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,14 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
class Vehicle(models.Model):
|
||||
vehicle_number = models.CharField(max_length=15, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vehicle_number}"
|
||||
|
||||
class VehicleExtra(models.Model):
|
||||
vehicle = models.OneToOneField(
|
||||
Vehicle, on_delete=models.CASCADE, related_name="extra"
|
||||
)
|
||||
data = models.JSONField(default=dict)
|
||||
@ -0,0 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Vehicle
|
||||
|
||||
class VehicleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number']
|
||||
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from vehicles.views import vehiclesView, VehicleListCreateAPIView, VehicleRetrieveUpdateDestroyAPIView, get_vehicles
|
||||
|
||||
urlpatterns = [
|
||||
path("", vehiclesView.as_view(), name='list_vehicles'),
|
||||
# API endpoints
|
||||
path("api/", VehicleListCreateAPIView.as_view(), name='vehicles-list-create'),
|
||||
path("api/<int:pk>/", VehicleRetrieveUpdateDestroyAPIView.as_view(), name='vehicles-detail'),
|
||||
path("api/list/", get_vehicles, name='get-vehicles'),
|
||||
]
|
||||
@ -0,0 +1,70 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
from django.shortcuts import render
|
||||
from django.views.generic.list import ListView
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Vehicle
|
||||
from .serializers import VehicleSerializer
|
||||
from scalesapp.sse import broadcast_update
|
||||
|
||||
# Create your views here.
|
||||
class CountriesView(ListView):
|
||||
pass
|
||||
|
||||
# API Views
|
||||
class VehicleListCreateAPIView(generics.ListCreateAPIView):
|
||||
queryset =Vehicle.objects.all()
|
||||
serializer_class = VehicleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Override create to broadcast SSE message"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
# Broadcast SSE update
|
||||
broadcast_update('vehicle', 'created', serializer.data)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
class VehicleRetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = Vehicle.objects.all()
|
||||
serializer_class = VehicleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Override update to broadcast SSE message"""
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Broadcast SSE update
|
||||
broadcast_update('vehicle', 'updated', serializer.data)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to broadcast SSE message"""
|
||||
instance = self.get_object()
|
||||
country_data = CountrySerializer(instance).data
|
||||
self.perform_destroy(instance)
|
||||
|
||||
# Broadcast SSE update
|
||||
broadcast_update('vehicle', 'deleted', country_data)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_vehicles(request):
|
||||
"""API endpoint for getting countries list"""
|
||||
vehicles = Vehicle.objects.all().order_by('id')
|
||||
serializer = VehicleSerializer(vehicles, many=True)
|
||||
return Response(serializer.data)
|
||||
@ -0,0 +1 @@
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@contexts/*": ["contexts/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@services/*": ["services/*"],
|
||||
"@assets/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "scalesapp-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ScalesApp - Real-time COM port data monitoring"
|
||||
/>
|
||||
<title>ScalesApp - Data Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,68 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: white;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4CAF50;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f44336;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
// import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Login from './components/Users/Login';
|
||||
import Main from './components/Main';
|
||||
import './App.css';
|
||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||
|
||||
// function MainApp() {
|
||||
// const [selectedPort, setSelectedPort] = useState(null);
|
||||
// const { readings, isConnected, error } = useSerialData();
|
||||
|
||||
// const filteredReadings = selectedPort
|
||||
// ? readings.filter(r => r.port === selectedPort)
|
||||
// : readings;
|
||||
|
||||
// return (
|
||||
// <div className="App">
|
||||
// <Header />
|
||||
|
||||
// <div className="status-bar">
|
||||
// {isConnected ? (
|
||||
// <span className="status-connected">● Connected to Serial Bridge</span>
|
||||
// ) : (
|
||||
// <span className="status-disconnected">● Disconnected</span>
|
||||
// )}
|
||||
// {error && (
|
||||
// <span className="status-error">{error}</span>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// <div className="container">
|
||||
// <DataDisplay readings={filteredReadings} selectedPort={selectedPort} />
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
function AppContent() {
|
||||
const { user, login, logout, loading, isAuthenticated } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login onLogin={login} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NomenclatureProvider>
|
||||
<Main />
|
||||
</NomenclatureProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -0,0 +1,135 @@
|
||||
.data-display {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.data-display.empty {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.reading-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.reading-card h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.reading-card.latest {
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.reading-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.readings-history {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.readings-history h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
table tbody tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.data-cell {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reading-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
table th, table td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import './DataDisplay.css';
|
||||
|
||||
function DataDisplay({ readings, selectedPort }) {
|
||||
if (!readings || readings.length === 0) {
|
||||
return (
|
||||
<div className="data-display empty">
|
||||
<p>No data available. Waiting for COM port readings...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestReading = readings[0];
|
||||
|
||||
return (
|
||||
<div className="data-display">
|
||||
<div className="reading-card latest">
|
||||
<h2>Latest Reading</h2>
|
||||
<div className="reading-info">
|
||||
<div className="info-item">
|
||||
<label>Port:</label>
|
||||
<span>{latestReading.port}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Data:</label>
|
||||
<span className="data-value">{latestReading.data}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Time:</label>
|
||||
<span>{new Date(latestReading.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="readings-history">
|
||||
<h2>Recent Readings ({readings.length})</h2>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port</th>
|
||||
<th>Data</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{readings.map((reading, idx) => (
|
||||
<tr key={reading.id || idx}>
|
||||
<td>{reading.port}</td>
|
||||
<td className="data-cell">{reading.data}</td>
|
||||
<td>{new Date(reading.timestamp).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataDisplay;
|
||||
@ -0,0 +1,112 @@
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
align-items: flex-start;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||
import './Header.css';
|
||||
|
||||
function Header() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
setShowPasswordOverlay(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="user-info">
|
||||
<span className="user-name">{currentUser?.username}</span>
|
||||
<span className="user-role">({currentUser?.role})</span>
|
||||
</div>
|
||||
<div className="avatar-container">
|
||||
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
|
||||
{getInitials(currentUser || {})}
|
||||
</div>
|
||||
<button className="logout-button" onClick={logout} title="Logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showPasswordOverlay && (
|
||||
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@ -0,0 +1,35 @@
|
||||
import Header from './Header';
|
||||
import DataDisplay from './DataDisplay';
|
||||
import useSerialData from '../hooks/useSerialData';
|
||||
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||
|
||||
|
||||
export default function Main() {
|
||||
const { readings, isConnected, error } = useSerialData();
|
||||
const { vehicles } = useNomenclatures();
|
||||
console.log('Vehicles:', vehicles);
|
||||
return (
|
||||
<div className="main">
|
||||
<div>
|
||||
<div className="vehicles">
|
||||
{ vehicles.map(vehicle => {
|
||||
return (
|
||||
<div key={vehicle.id} className="vehicle-card">
|
||||
<h3>{vehicle.vehicle_number}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vehicles-data">
|
||||
<div className="data-header">
|
||||
<h2>Vehicle Data</h2>
|
||||
</div>
|
||||
<div className="data-list">
|
||||
<Header />
|
||||
<DataDisplay readings={readings}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
@ -0,0 +1,220 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overlay-header h2 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-button:hover:not(:disabled) {
|
||||
border-color: #999;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.cancel-button:disabled,
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fcc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
margin: 0 auto 16px;
|
||||
animation: scaleIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.overlay-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import './ChangePasswordOverlay.css';
|
||||
|
||||
function ChangePasswordOverlay({ onClose }) {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { changePassword } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldPassword === newPassword) {
|
||||
setError('New password must be different from old password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await changePassword(oldPassword, newPassword);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
} else {
|
||||
// Extract error message
|
||||
let errorMsg = 'Failed to change password';
|
||||
if (result.error.old_password) {
|
||||
errorMsg = result.error.old_password[0];
|
||||
} else if (result.error.new_password) {
|
||||
errorMsg = result.error.new_password[0];
|
||||
} else if (result.error.detail) {
|
||||
errorMsg = result.error.detail;
|
||||
}
|
||||
setError(errorMsg);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target.className === 'overlay') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={handleOverlayClick}>
|
||||
<div className="overlay-content">
|
||||
<div className="overlay-header">
|
||||
<h2>Change Password</h2>
|
||||
<button className="close-button" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="success-message">
|
||||
<div className="success-icon">✓</div>
|
||||
<p>Password changed successfully!</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="password-form">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="old-password">Current Password</label>
|
||||
<input
|
||||
id="old-password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirm-password">Confirm New Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter new password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePasswordOverlay;
|
||||
@ -0,0 +1,141 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #667eea;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 14px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fcc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import './Login.css';
|
||||
|
||||
function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>ScalesApp</h1>
|
||||
<p>Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@ -0,0 +1,141 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
try {
|
||||
// Validate token by fetching user info
|
||||
const userData = await fetchCurrentUser();
|
||||
setCurrentUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error);
|
||||
clearTokens();
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// Auto-refresh token before expiry
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
// Refresh token every 14 minutes (tokens expire at 15 min)
|
||||
const refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
logout();
|
||||
}
|
||||
}, 14 * 60 * 1000); // 14 minutes
|
||||
|
||||
return () => clearInterval(refreshInterval);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
const response = await api.get('/api/users/me/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
// Get tokens
|
||||
const response = await api.post('/api/token/', { username, password });
|
||||
const { access, refresh } = response.data;
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem('accessToken', access);
|
||||
localStorage.setItem('refreshToken', refresh);
|
||||
|
||||
// Fetch user data
|
||||
const userData = await fetchCurrentUser();
|
||||
setCurrentUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Login failed. Please check your credentials.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearTokens();
|
||||
setCurrentUser(null);
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await api.post('/api/token/refresh/', {
|
||||
refresh: refreshToken
|
||||
});
|
||||
const { access } = response.data;
|
||||
localStorage.setItem('accessToken', access);
|
||||
return access;
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
};
|
||||
|
||||
const changePassword = async (oldPassword, newPassword) => {
|
||||
try {
|
||||
await api.post('/api/users/change-password/', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Password change failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Password change failed.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
changePassword,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -0,0 +1,452 @@
|
||||
import { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const NomenclatureContext = createContext();
|
||||
|
||||
// Centralized nomenclature configuration
|
||||
const NOMENCLATURES_CONFIG = [
|
||||
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
|
||||
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
|
||||
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
|
||||
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
|
||||
// { key: 'cargos', jsonKey: 'cargos', sortBy: 'name', endpoint: '/cargos/api/list/', sseObjectType: 'cargo' },
|
||||
// { key: 'groups', jsonKey: 'groups', sortBy: 'id', endpoint: '/groups/api/list/', sseObjectType: 'group' },
|
||||
// { key: 'groupSmalls', jsonKey: 'groupsmalls', sortBy: 'id', endpoint: '/groups/api/getgroupsmalls/', sseObjectType: 'groupsmalls' },
|
||||
// { key: 'tariffs', jsonKey: 'tariffs', sortBy: 'id', endpoint: '/tariff/api/list/', sseObjectType: 'tariff' },
|
||||
// { key: 'tariffSmalls', jsonKey: 'tariffsmalls', sortBy: 'id', endpoint: '/tariff/api/tariffsmalls', sseObjectType: 'tariffsmalls' },
|
||||
// { key: 'countries', jsonKey: 'countries', sortBy: 'name', endpoint: '/countries/api/list', sseObjectType: 'country' },
|
||||
// { key: 'passThroughs', jsonKey: 'passthroughs', sortBy: 'id', endpoint: '/tariff/api/getpassthroughs/', sseObjectType: 'passthrough' },
|
||||
// { key: 'vats', jsonKey: 'vats', sortBy: 'id', endpoint: '/tariff/api/getvats/', sseObjectType: 'vat' },
|
||||
// { key: 'maneuvers', jsonKey: 'maneuvers', sortBy: 'id', endpoint: '/common/api/maneuvers/', sseObjectType: 'maneuver' },
|
||||
// { key: 'prices', jsonKey: 'prices', sortBy: 'id', endpoint: '/tariff/api/prices/', sseObjectType: 'prices' },
|
||||
// { key: 'bankAccounts', jsonKey: 'bank_accounts', sortBy: 'id', endpoint: '/common/api/bank-accounts/', sseObjectType: 'bank_account' },
|
||||
// { key: 'noticeNotes', jsonKey: 'notice_notes', sortBy: 'name', endpoint: '/invoices/api/notice-notes/list/', sseObjectType: 'notice_note' },
|
||||
// { key: 'translations', jsonKey: 'translations', sortBy: 'name', endpoint: '/common/api/translations/', sseObjectType: 'translation' }
|
||||
];
|
||||
|
||||
// Helper function to get config for a specific key
|
||||
const getConfigForKey = (key) => NOMENCLATURES_CONFIG.find(c => c.key === key);
|
||||
|
||||
// Helper function to get nomenclature key from SSE object type
|
||||
const getNomenclatureKeyFromSSE = (sseObjectType) => {
|
||||
const config = NOMENCLATURES_CONFIG.find(c => c.sseObjectType === sseObjectType);
|
||||
return config?.key;
|
||||
};
|
||||
|
||||
export const useNomenclatures = () => {
|
||||
const context = useContext(NomenclatureContext);
|
||||
if (!context) {
|
||||
throw new Error('useNomenclatures must be used within a NomenclatureProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Utility function to sort nomenclature array based on configuration
|
||||
const sortNomenclature = (array, nomenclatureKey) => {
|
||||
const config = getConfigForKey(nomenclatureKey);
|
||||
const orderBy = config?.sortBy || 'name';
|
||||
|
||||
return [...array].sort((a, b) => {
|
||||
if (orderBy === 'id') {
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
} else {
|
||||
// Order by name (case-insensitive)
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Utility function to insert item in sorted position
|
||||
const insertSorted = (array, newItem, nomenclatureKey) => {
|
||||
const config = getConfigForKey(nomenclatureKey);
|
||||
const orderBy = config?.sortBy || 'name';
|
||||
const newArray = [...array];
|
||||
|
||||
// Find the correct insertion position
|
||||
let insertIndex = newArray.length;
|
||||
|
||||
if (orderBy === 'id') {
|
||||
insertIndex = newArray.findIndex(item => {
|
||||
const comparison = item.id > newItem.id;
|
||||
return comparison;
|
||||
});
|
||||
if (insertIndex === -1) insertIndex = newArray.length;
|
||||
|
||||
} else {
|
||||
const newName = (newItem.name || '').toLowerCase();
|
||||
insertIndex = newArray.findIndex(item =>
|
||||
(item.name || '').toLowerCase() > newName
|
||||
);
|
||||
if (insertIndex === -1) insertIndex = newArray.length;
|
||||
}
|
||||
|
||||
newArray.splice(insertIndex, 0, newItem);
|
||||
return newArray;
|
||||
};
|
||||
|
||||
// Utility function to update item and maintain sorted order
|
||||
const updateSorted = (array, updatedItem, nomenclatureKey) => {
|
||||
const config = getConfigForKey(nomenclatureKey);
|
||||
const orderBy = config?.sortBy || 'name';
|
||||
|
||||
// Remove the old item
|
||||
const filteredArray = array.filter(item => item.id !== updatedItem.id);
|
||||
|
||||
// Check if the ordering field has changed
|
||||
const oldItem = array.find(item => item.id === updatedItem.id);
|
||||
if (!oldItem) {
|
||||
// Item not found, just insert it
|
||||
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
|
||||
}
|
||||
|
||||
const orderFieldChanged = orderBy === 'id'
|
||||
? oldItem.id !== updatedItem.id
|
||||
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
|
||||
|
||||
if (orderFieldChanged) {
|
||||
// Re-insert in sorted position if the ordering field changed
|
||||
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
|
||||
} else {
|
||||
// Just update in place if ordering hasn't changed
|
||||
return array.map(item =>
|
||||
item.id === updatedItem.id ? { ...item, ...updatedItem } : item
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate initial state objects from config
|
||||
const generateInitialState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||
acc[config.key] = [];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const generateInitialLoadingState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||
acc[config.key] = false;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const generateInitialErrorState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||
acc[config.key] = null;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const NomenclatureProvider = ({ children }) => {
|
||||
// Ref to track invoice update subscribers
|
||||
const subscribersRef = useRef(new Set());
|
||||
|
||||
const [nomenclatures, setNomenclatures] = useState(generateInitialState());
|
||||
const [loading, setLoading] = useState(generateInitialLoadingState());
|
||||
const [error, setError] = useState(generateInitialErrorState());
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const [hasStartedLoading, setHasStartedLoading] = useState(false);
|
||||
|
||||
// Generic fetch function
|
||||
const fetchNomenclature = async (key, url) => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${key}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
|
||||
}
|
||||
|
||||
// Normalize data format - adjust based on your API response structure
|
||||
let normalizedData;
|
||||
if (Array.isArray(data)) {
|
||||
normalizedData = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
// Handle different response formats
|
||||
const config = getConfigForKey(key);
|
||||
const jsonKey = config?.jsonKey; // Get the correct JSON key from config
|
||||
normalizedData = data.results || data.data || data[jsonKey] || data[key] || [];
|
||||
} else {
|
||||
normalizedData = [];
|
||||
}
|
||||
|
||||
// Sort the data according to configured order
|
||||
const sortedData = sortNomenclature(normalizedData, key);
|
||||
|
||||
setNomenclatures(prev => ({
|
||||
...prev,
|
||||
[key]: sortedData
|
||||
}));
|
||||
|
||||
setError(prev => ({
|
||||
...prev,
|
||||
[key]: null
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
const totalTime = performance.now() - startTime;
|
||||
console.error(`❌ [${key}] Failed after ${totalTime.toFixed(2)}ms:`, err.message);
|
||||
setError(prev => ({
|
||||
...prev,
|
||||
[key]: err.message
|
||||
}));
|
||||
} finally {
|
||||
setLoading(prev => ({
|
||||
...prev,
|
||||
[key]: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Load all nomenclatures on mount, but only if we have auth
|
||||
useEffect(() => {
|
||||
const loadAllNomenclatures = async () => {
|
||||
// Check if we have an auth token
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasStartedLoading(true);
|
||||
|
||||
// Set all loading states to true
|
||||
const allLoadingState = NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||
acc[config.key] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
setLoading(allLoadingState);
|
||||
|
||||
// Load all nomenclatures in parallel
|
||||
const promises = NOMENCLATURES_CONFIG.map(config =>
|
||||
fetchNomenclature(config.key, API_BASE_URL + config.endpoint)
|
||||
);
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
/*
|
||||
// Wait a tick for state to update, then apply translations
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Fetch translations directly from API to ensure we have the latest data
|
||||
const token = localStorage.getItem('authToken');
|
||||
const translationsConfig = getConfigForKey('translations');
|
||||
const response = await fetch(translationsConfig.endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${token}`
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const translationsData = await response.json();
|
||||
|
||||
if (translationsData && translationsData.length > 0) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const module = await import('@utils/printTranslations');
|
||||
const { translationService } = await import('@services/translationService');
|
||||
const merged = translationService.applyOverrides(
|
||||
module.printTranslations,
|
||||
translationsData
|
||||
);
|
||||
Object.assign(module.printTranslations, merged);
|
||||
console.log('✅ Translations initialized:', translationsData.length, 'translations loaded');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply translation overrides:', error);
|
||||
}
|
||||
}, 100);
|
||||
*/
|
||||
|
||||
setAllLoaded(true);
|
||||
};
|
||||
loadAllNomenclatures();
|
||||
|
||||
// Also listen for auth token changes
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'accessToken' && e.newValue && !hasStartedLoading) {
|
||||
loadAllNomenclatures();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [hasStartedLoading]);
|
||||
|
||||
// SSE connection for real-time updates with auto-reconnect
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
return; // Don't connect if not authenticated
|
||||
}
|
||||
|
||||
let eventSource;
|
||||
let reconnectTimeout;
|
||||
let isIntentionallyClosed = false;
|
||||
|
||||
const connect = () => {
|
||||
if (isIntentionallyClosed) return;
|
||||
|
||||
eventSource = new EventSource(`${API_BASE_URL}/sse-connect/?token=${token}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// console.log('SSE connection opened');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleSSEUpdate(message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
if (!isIntentionallyClosed) {
|
||||
console.log('SSE will reconnect in 5 seconds...');
|
||||
reconnectTimeout = setTimeout(connect, 5000);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Start initial connection
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isIntentionallyClosed = true;
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
if (eventSource) eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Function to subscribe to invoice updates
|
||||
const subscribeToUpdates = (callback) => {
|
||||
subscribersRef.current.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
subscribersRef.current.delete(callback);
|
||||
};
|
||||
};
|
||||
|
||||
// Handle SSE updates
|
||||
const handleSSEUpdate = (message) => {
|
||||
if (message.type !== 'data_update') {
|
||||
return; // Only handle data_update messages
|
||||
}
|
||||
|
||||
const { object, operation, data } = message;
|
||||
|
||||
// Handle invoice updates - notify all subscribers
|
||||
if (object === 'invoice' || object === 'notice') {
|
||||
subscribersRef.current.forEach(callback => {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (error) {
|
||||
console.error('Error in invoice update subscriber:', error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Map object types to nomenclature keys using config
|
||||
const nomenclatureKey = getNomenclatureKeyFromSSE(object);
|
||||
if (!nomenclatureKey) {
|
||||
console.warn('Unknown object type in SSE update:', object);
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handling for translations: update printTranslations in real-time
|
||||
// if (nomenclatureKey === 'translations' && (operation === 'insert' || operation === 'update')) {
|
||||
// import('@utils/printTranslations').then(async (module) => {
|
||||
// const key = data.key;
|
||||
// if (module.printTranslations[key]) {
|
||||
// module.printTranslations[key] = {
|
||||
// bg: data.text_bg,
|
||||
// en: data.text_en
|
||||
// };
|
||||
// }
|
||||
// }).catch(error => {
|
||||
// console.error('Failed to update printTranslations from SSE:', error);
|
||||
// });
|
||||
// }
|
||||
|
||||
setNomenclatures(prev => {
|
||||
const currentArray = prev[nomenclatureKey] || [];
|
||||
|
||||
if (operation === 'insert') {
|
||||
// Add new item if it doesn't exist
|
||||
const exists = currentArray.some(item => item.id === data.id);
|
||||
if (!exists) {
|
||||
// Insert in sorted position
|
||||
const sortedArray = insertSorted(currentArray, data, nomenclatureKey);
|
||||
return {
|
||||
...prev,
|
||||
[nomenclatureKey]: sortedArray
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
} else if (operation === 'update') {
|
||||
// Update existing item and maintain sort order
|
||||
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
|
||||
return {
|
||||
...prev,
|
||||
[nomenclatureKey]: updatedArray
|
||||
};
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const value = {
|
||||
// Spread all nomenclature data
|
||||
...nomenclatures,
|
||||
|
||||
// Loading states
|
||||
loading,
|
||||
allLoaded,
|
||||
|
||||
// Error states
|
||||
error,
|
||||
|
||||
// Utility functions
|
||||
subscribeToUpdates,
|
||||
|
||||
// State helpers
|
||||
hasStartedLoading,
|
||||
|
||||
// Helper to check if any is loading
|
||||
isAnyLoading: Object.values(loading).some(Boolean),
|
||||
|
||||
// Helper to check if any has errors
|
||||
hasErrors: Object.values(error).some(Boolean)
|
||||
};
|
||||
|
||||
return (
|
||||
<NomenclatureContext.Provider value={value}>
|
||||
{children}
|
||||
</NomenclatureContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function useSerialData() {
|
||||
const [readings, setReadings] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource('http://localhost:5000/events');
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
console.log('Connected to serial bridge SSE');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'connected') {
|
||||
console.log('SSE:', data.message);
|
||||
} else {
|
||||
// Add new reading to the beginning
|
||||
setReadings(prev => [data, ...prev].slice(0, 100)); // Keep last 100
|
||||
console.log('Received:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
setIsConnected(false);
|
||||
setError('Failed to connect to serial bridge');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { readings, isConnected, error };
|
||||
}
|
||||
|
||||
export default useSerialData;
|
||||
@ -0,0 +1,18 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@ -0,0 +1,106 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: Add JWT token to all requests
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor: Handle 401 errors with token refresh
|
||||
let isRefreshing = false;
|
||||
let failedQueue = [];
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach(prom => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If error is 401 and we haven't tried refreshing yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// Queue this request while refresh is in progress
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then(token => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
})
|
||||
.catch(err => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
// No refresh token, redirect to login
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
const response = await axios.post(`${API_BASE_URL}/api/token/refresh/`, {
|
||||
refresh: refreshToken
|
||||
});
|
||||
const { access } = response.data;
|
||||
|
||||
// Update stored token
|
||||
localStorage.setItem('accessToken', access);
|
||||
|
||||
// Update authorization header
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${access}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${access}`;
|
||||
|
||||
// Process queued requests
|
||||
processQueue(null, access);
|
||||
isRefreshing = false;
|
||||
|
||||
// Retry original request
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
processQueue(refreshError, null);
|
||||
isRefreshing = false;
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@ -0,0 +1 @@
|
||||
/c/dev_projects/ScalesApp/frontend
|
||||
@ -0,0 +1,22 @@
|
||||
# COM Port Settings
|
||||
COM_PORT=COM3
|
||||
BAUD_RATE=9600
|
||||
TIMEOUT=1
|
||||
READ_INTERVAL=0.5
|
||||
|
||||
# Backend Server Settings
|
||||
BACKEND_URL=http://localhost:8000
|
||||
API_ENDPOINT=/api/readings/
|
||||
REQUEST_TIMEOUT=5
|
||||
|
||||
# Application Settings
|
||||
DEBUG=False
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# System Tray Settings
|
||||
SHOW_WINDOW_ON_START=False
|
||||
AUTO_CONNECT=True
|
||||
|
||||
# Retry Settings
|
||||
MAX_RETRIES=3
|
||||
RETRY_DELAY=5
|
||||
@ -0,0 +1,71 @@
|
||||
# Serial Bridge - COM Port Reader
|
||||
|
||||
This is the serial port reading application that runs as a system tray service.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Reads data from COM ports
|
||||
- ✅ Runs in system tray (no console window)
|
||||
- ✅ Automatically posts data to Django backend
|
||||
- ✅ Retry logic for failed posts
|
||||
- ✅ Backend health checks
|
||||
- ✅ Configurable via environment variables
|
||||
- ✅ Can be packaged as a Windows .exe
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Python dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Configure environment:**
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Run the application:**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Building as EXE
|
||||
|
||||
To create a standalone Windows executable:
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller serial_bridge.spec
|
||||
```
|
||||
|
||||
The executable will be created in the `dist\ScalesApp\` folder.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env` to configure:
|
||||
|
||||
- `COM_PORT`: COM port to read from (default: COM1)
|
||||
- `BAUD_RATE`: Serial port baud rate (default: 9600)
|
||||
- `BACKEND_URL`: Django backend URL (default: http://localhost:8000)
|
||||
- `AUTO_CONNECT`: Automatically connect on startup (default: True)
|
||||
- `DEBUG`: Enable debug logging (default: False)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
COM Port → Serial Reader → Backend Client → Django API
|
||||
↓
|
||||
System Tray Icon
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
Logs are written to `serial_bridge.log` and console output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **COM port not found**: Check `COM_PORT` setting and device connection
|
||||
- **Cannot connect to backend**: Verify Django server is running on `BACKEND_URL`
|
||||
- **Data not posting**: Check logs in `serial_bridge.log`
|
||||
- **Tray icon not appearing**: Run as administrator, check Windows settings
|
||||
@ -0,0 +1,231 @@
|
||||
"""
|
||||
Serial Bridge - SSE Server
|
||||
Reads from COM port and streams data to React frontend via Server-Sent Events
|
||||
No backend storage - direct streaming only
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from dotenv import load_dotenv
|
||||
from threading import Thread
|
||||
from datetime import datetime
|
||||
from flask import Flask, Response
|
||||
from flask_cors import CORS, cross_origin
|
||||
from queue import Queue
|
||||
import json
|
||||
import time
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=os.getenv('LOG_LEVEL', 'INFO'),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('serial_bridge.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for all routes
|
||||
|
||||
# Global state
|
||||
data_queue = Queue()
|
||||
connected_clients = []
|
||||
is_running = False
|
||||
reader_thread = None
|
||||
|
||||
|
||||
class SerialReader:
|
||||
def __init__(self, port, baudrate, timeout):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.ser = None
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.is_connected = True
|
||||
logger.info(f"[OK] Connected to {self.port} at {self.baudrate} baud")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Failed to connect to {self.port}: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def read_data(self):
|
||||
if not self.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.ser.in_waiting:
|
||||
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||
if line:
|
||||
return {
|
||||
'port': self.port,
|
||||
'data': line,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading from {self.port}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def disconnect(self):
|
||||
if self.ser:
|
||||
self.ser.close()
|
||||
self.is_connected = False
|
||||
logger.info(f"[OK] Disconnected from {self.port}")
|
||||
|
||||
|
||||
def read_serial_data():
|
||||
"""Continuously read from serial port and queue data"""
|
||||
global is_running
|
||||
|
||||
reader = SerialReader(
|
||||
port=os.getenv('COM_PORT', 'COM3'),
|
||||
baudrate=int(os.getenv('BAUD_RATE', '9600')),
|
||||
timeout=float(os.getenv('TIMEOUT', '1'))
|
||||
)
|
||||
|
||||
if not reader.connect():
|
||||
logger.error("Failed to connect to COM port")
|
||||
return
|
||||
|
||||
read_interval = float(os.getenv('READ_INTERVAL', '0.5'))
|
||||
|
||||
while is_running:
|
||||
try:
|
||||
data = reader.read_data()
|
||||
if data:
|
||||
data_queue.put(data)
|
||||
logger.info(f"[RX] {data['port']}: {data['data']}")
|
||||
|
||||
time.sleep(read_interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Serial reader interrupted")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in serial reader: {e}")
|
||||
|
||||
reader.disconnect()
|
||||
|
||||
|
||||
@app.route('/events')
|
||||
@cross_origin()
|
||||
def events():
|
||||
"""SSE endpoint - streams serial data to connected clients"""
|
||||
|
||||
def generate():
|
||||
client_id = id(object())
|
||||
connected_clients.append(client_id)
|
||||
|
||||
logger.info(f"[CONNECT] Client connected. Total: {len(connected_clients)}")
|
||||
|
||||
try:
|
||||
# Send connection message
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Connected to serial bridge'})}\n\n"
|
||||
|
||||
# Stream data from queue
|
||||
while is_running:
|
||||
try:
|
||||
# Get data with timeout to allow graceful shutdown
|
||||
data = data_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
logger.debug(f"[TX] Sent: {data['data']}")
|
||||
|
||||
except:
|
||||
# Queue timeout - send heartbeat
|
||||
yield f": heartbeat\n\n"
|
||||
|
||||
except GeneratorExit:
|
||||
# Client disconnected
|
||||
pass
|
||||
finally:
|
||||
if client_id in connected_clients:
|
||||
connected_clients.remove(client_id)
|
||||
logger.info(f"[DISCONNECT] Client disconnected. Total: {len(connected_clients)}")
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route('/status')
|
||||
def status():
|
||||
"""Get current status"""
|
||||
return {
|
||||
'status': 'running' if is_running else 'stopped',
|
||||
'connected_clients': len(connected_clients),
|
||||
'com_port': os.getenv('COM_PORT', 'COM3'),
|
||||
'baud_rate': int(os.getenv('BAUD_RATE', '9600'))
|
||||
}
|
||||
|
||||
|
||||
@app.route('/ports')
|
||||
def list_ports():
|
||||
"""List available COM ports"""
|
||||
ports = []
|
||||
for port, desc, hwid in serial.tools.list_ports.comports():
|
||||
ports.append({
|
||||
'port': port,
|
||||
'description': desc,
|
||||
'hwid': hwid
|
||||
})
|
||||
return {'ports': ports}
|
||||
|
||||
|
||||
def start_app():
|
||||
"""Start the application"""
|
||||
global is_running, reader_thread
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("ScalesApp Serial Bridge - SSE Streaming")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"COM Port: {os.getenv('COM_PORT', 'COM3')}")
|
||||
logger.info(f"Baud Rate: {os.getenv('BAUD_RATE', '9600')}")
|
||||
logger.info(f"SSE Endpoint: http://127.0.0.1:5000/events")
|
||||
logger.info("=" * 60)
|
||||
|
||||
is_running = True
|
||||
|
||||
# Start serial reader thread
|
||||
reader_thread = Thread(target=read_serial_data, daemon=True)
|
||||
reader_thread.start()
|
||||
logger.info("[OK] Serial reader thread started\n")
|
||||
|
||||
# Start Flask app
|
||||
debug = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||
app.run(host='127.0.0.1', port=5000, debug=debug, use_reloader=False)
|
||||
|
||||
|
||||
def stop_app():
|
||||
"""Stop the application"""
|
||||
global is_running
|
||||
is_running = False
|
||||
logger.info("\n[OK] Application stopped")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
start_app()
|
||||
except KeyboardInterrupt:
|
||||
stop_app()
|
||||
@ -0,0 +1,117 @@
|
||||
"""
|
||||
Backend API Client
|
||||
Handles communication with Django backend
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BackendClient:
|
||||
def __init__(self, base_url: str, timeout: int = 5, max_retries: int = 3):
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.session = self._create_session()
|
||||
|
||||
def _create_session(self) -> requests.Session:
|
||||
"""Create a requests session with retry logic"""
|
||||
session = requests.Session()
|
||||
|
||||
retry_strategy = Retry(
|
||||
total=self.max_retries,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"]
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
return session
|
||||
|
||||
def post_reading(self, port: str, data: str) -> bool:
|
||||
"""Post a serial port reading to the backend"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/readings/"
|
||||
payload = {
|
||||
'port': port,
|
||||
'data': data
|
||||
}
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"Successfully posted reading to backend")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Backend returned status {response.status_code}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error posting reading to backend: {e}")
|
||||
return False
|
||||
|
||||
def get_latest_reading(self, port: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get the latest reading from backend"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/readings/latest/"
|
||||
params = {'port': port} if port else {}
|
||||
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.warning(f"Backend returned status {response.status_code}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error getting latest reading: {e}")
|
||||
return None
|
||||
|
||||
def get_readings(self, port: Optional[str] = None, limit: int = 10) -> Optional[list]:
|
||||
"""Get readings from backend"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/readings/"
|
||||
params = {'limit': limit}
|
||||
if port:
|
||||
params['port'] = port
|
||||
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.warning(f"Backend returned status {response.status_code}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error getting readings: {e}")
|
||||
return None
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if backend is available"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/health/"
|
||||
response = self.session.get(url, timeout=self.timeout)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Backend health check failed: {e}")
|
||||
return False
|
||||
@ -0,0 +1,31 @@
|
||||
"""
|
||||
Configuration for Serial Bridge
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# COM Port Settings
|
||||
COM_PORT = os.getenv('COM_PORT', 'COM1')
|
||||
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
|
||||
TIMEOUT = int(os.getenv('TIMEOUT', 1))
|
||||
READ_INTERVAL = float(os.getenv('READ_INTERVAL', 0.5)) # seconds
|
||||
|
||||
# Backend Server Settings
|
||||
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8000')
|
||||
API_ENDPOINT = os.getenv('API_ENDPOINT', '/api/readings/')
|
||||
REQUEST_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', 5))
|
||||
|
||||
# Application Settings
|
||||
APP_NAME = 'ScalesApp Serial Bridge'
|
||||
DEBUG = os.getenv('DEBUG', 'False') == 'True'
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
|
||||
# System Tray Settings
|
||||
SHOW_WINDOW_ON_START = os.getenv('SHOW_WINDOW_ON_START', 'False') == 'True'
|
||||
AUTO_CONNECT = os.getenv('AUTO_CONNECT', 'True') == 'True'
|
||||
|
||||
# Retry Settings
|
||||
MAX_RETRIES = int(os.getenv('MAX_RETRIES', 3))
|
||||
RETRY_DELAY = int(os.getenv('RETRY_DELAY', 5)) # seconds
|
||||
@ -0,0 +1,9 @@
|
||||
Pillow==12.1.0
|
||||
pyserial==3.5
|
||||
python-dotenv==1.0.0
|
||||
flask==3.1.0
|
||||
requests==2.32.5
|
||||
urllib3==2.6.3
|
||||
pystray==0.19.5
|
||||
psutil==7.2.1
|
||||
PyInstaller==6.17.0
|
||||
@ -0,0 +1,57 @@
|
||||
"""
|
||||
PyInstaller spec file for creating a Windows executable
|
||||
Run: pyinstaller serial_bridge.spec
|
||||
"""
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['app.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=['serial', 'requests', 'pystray', 'PIL'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludedimports=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='ScalesApp',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # No console window
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None, # You can add an icon path here
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='ScalesApp',
|
||||
)
|
||||
@ -0,0 +1,110 @@
|
||||
"""
|
||||
Serial Port Reader
|
||||
Handles reading data from COM ports
|
||||
"""
|
||||
import serial
|
||||
import threading
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SerialPortReader:
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.serial_conn: Optional[serial.Serial] = None
|
||||
self.is_connected = False
|
||||
self.reader_thread: Optional[threading.Thread] = None
|
||||
self.is_running = False
|
||||
self.data_callback: Optional[Callable] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the serial port"""
|
||||
try:
|
||||
self.serial_conn = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.is_connected = True
|
||||
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
|
||||
return True
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"Failed to connect to {self.port}: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the serial port"""
|
||||
self.is_running = False
|
||||
if self.reader_thread:
|
||||
self.reader_thread.join(timeout=2)
|
||||
|
||||
if self.serial_conn and self.serial_conn.is_open:
|
||||
self.serial_conn.close()
|
||||
self.is_connected = False
|
||||
logger.info(f"Disconnected from {self.port}")
|
||||
|
||||
def set_data_callback(self, callback: Callable):
|
||||
"""Set callback function for received data"""
|
||||
self.data_callback = callback
|
||||
|
||||
def start_reading(self):
|
||||
"""Start reading from serial port in a background thread"""
|
||||
if not self.is_connected:
|
||||
if not self.connect():
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
self.reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self.reader_thread.start()
|
||||
logger.info("Serial reader started")
|
||||
|
||||
def stop_reading(self):
|
||||
"""Stop reading from serial port"""
|
||||
self.is_running = False
|
||||
if self.reader_thread:
|
||||
self.reader_thread.join(timeout=2)
|
||||
logger.info("Serial reader stopped")
|
||||
|
||||
def _read_loop(self):
|
||||
"""Main reading loop"""
|
||||
while self.is_running and self.is_connected:
|
||||
try:
|
||||
if self.serial_conn and self.serial_conn.in_waiting > 0:
|
||||
data = self.serial_conn.readline().decode('utf-8', errors='ignore').strip()
|
||||
|
||||
if data and self.data_callback:
|
||||
self.data_callback({
|
||||
'port': self.port,
|
||||
'data': data,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading from {self.port}: {e}")
|
||||
self.is_connected = False
|
||||
break
|
||||
|
||||
def write_data(self, data: str) -> bool:
|
||||
"""Write data to the serial port"""
|
||||
try:
|
||||
if self.is_connected and self.serial_conn:
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
self.serial_conn.write(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing to {self.port}: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get current status"""
|
||||
return {
|
||||
'port': self.port,
|
||||
'connected': self.is_connected,
|
||||
'running': self.is_running,
|
||||
'baudrate': self.baudrate
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
"""
|
||||
System Tray Icon Handler
|
||||
Manages the application in the system tray
|
||||
"""
|
||||
import pystray
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SystemTrayIcon:
|
||||
def __init__(self, app_name: str):
|
||||
self.app_name = app_name
|
||||
self.icon: Optional[pystray.Icon] = None
|
||||
self.status_text = "Initializing..."
|
||||
self.on_quit: Optional[Callable] = None
|
||||
self.on_show: Optional[Callable] = None
|
||||
|
||||
def _create_image(self, status: str = "ready") -> Image.Image:
|
||||
"""Create a simple tray icon image"""
|
||||
size = 64
|
||||
color_map = {
|
||||
'ready': (0, 200, 0),
|
||||
'running': (0, 150, 200),
|
||||
'error': (200, 0, 0),
|
||||
'waiting': (200, 150, 0),
|
||||
}
|
||||
|
||||
color = color_map.get(status, (128, 128, 128))
|
||||
image = Image.new('RGB', (size, size), color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([0, 0, size-1, size-1], outline=(0, 0, 0), width=2)
|
||||
|
||||
return image
|
||||
|
||||
def set_status(self, status: str, text: str):
|
||||
"""Update the tray icon status"""
|
||||
self.status_text = text
|
||||
if self.icon:
|
||||
self.icon.update_menu()
|
||||
|
||||
def create_menu(self) -> pystray.Menu:
|
||||
"""Create the context menu for the tray icon"""
|
||||
menu_items = [
|
||||
pystray.MenuItem(f"📊 {self.app_name}", action=None),
|
||||
pystray.MenuItem("─" * 30, action=None),
|
||||
pystray.MenuItem("Status: " + self.status_text, action=None),
|
||||
pystray.MenuItem("─" * 30, action=None),
|
||||
pystray.MenuItem("Quit", self._on_quit_click),
|
||||
]
|
||||
return pystray.Menu(*menu_items)
|
||||
|
||||
def _on_quit_click(self, icon, item):
|
||||
"""Handle quit click"""
|
||||
if self.on_quit:
|
||||
self.on_quit()
|
||||
if self.icon:
|
||||
self.icon.stop()
|
||||
|
||||
def run(self):
|
||||
"""Run the tray icon"""
|
||||
try:
|
||||
self.icon = pystray.Icon(
|
||||
name=self.app_name,
|
||||
icon=self._create_image('running'),
|
||||
menu=self.create_menu(),
|
||||
title=self.app_name
|
||||
)
|
||||
self.icon.run()
|
||||
except Exception as e:
|
||||
logger.error(f"Error running tray icon: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the tray icon"""
|
||||
if self.icon:
|
||||
self.icon.stop()
|
||||
|
||||
def set_callbacks(self, on_quit: Optional[Callable] = None, on_show: Optional[Callable] = None):
|
||||
"""Set callback functions"""
|
||||
self.on_quit = on_quit
|
||||
self.on_show = on_show
|
||||
@ -0,0 +1,16 @@
|
||||
# COM Port Test Writer configuration
|
||||
COM_PORT=COM1
|
||||
BAUD_RATE=9600
|
||||
TIMEOUT=1
|
||||
|
||||
# Test Data Settings
|
||||
WRITE_INTERVAL=1.0
|
||||
TEST_DATA_TYPE=scales
|
||||
|
||||
# Scales data settings
|
||||
SCALES_MIN=0
|
||||
SCALES_MAX=100
|
||||
SCALES_INCREMENT=0.5
|
||||
|
||||
# Debug
|
||||
DEBUG=True
|
||||
@ -0,0 +1,165 @@
|
||||
# COM Port Test Writer - Simulates device sending data
|
||||
|
||||
This utility simulates a physical device (like scales) by continuously writing test data to a COM port.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Writes test data continuously to COM port
|
||||
- ✅ Multiple data types: scales, counter, random, mixed
|
||||
- ✅ Configurable write interval
|
||||
- ✅ Easy start/stop with Ctrl+C
|
||||
- ✅ List available COM ports
|
||||
- ✅ Command-line arguments
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Configure environment (optional):**
|
||||
```bash
|
||||
copy .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### List Available COM Ports
|
||||
```bash
|
||||
python test_writer.py --list
|
||||
```
|
||||
|
||||
### Basic Usage (using configured port)
|
||||
```bash
|
||||
python test_writer.py
|
||||
```
|
||||
|
||||
### Specify COM Port
|
||||
```bash
|
||||
python test_writer.py --port COM3
|
||||
```
|
||||
|
||||
### Different Data Types
|
||||
|
||||
**Scales Data (default)** - Simulates scale readings
|
||||
```bash
|
||||
python test_writer.py --type scales
|
||||
```
|
||||
|
||||
**Counter Data** - Incrementing numbers
|
||||
```bash
|
||||
python test_writer.py --type counter
|
||||
```
|
||||
|
||||
**Random Data** - Random numeric values
|
||||
```bash
|
||||
python test_writer.py --type random
|
||||
```
|
||||
|
||||
**Mixed Sensor Data** - Temperature, humidity, pressure
|
||||
```bash
|
||||
python test_writer.py --type mixed
|
||||
```
|
||||
|
||||
### Custom Write Interval
|
||||
```bash
|
||||
python test_writer.py --interval 2.0 # Send every 2 seconds
|
||||
python test_writer.py --interval 0.5 # Send every 0.5 seconds
|
||||
```
|
||||
|
||||
### Custom Baud Rate
|
||||
```bash
|
||||
python test_writer.py --baud 115200
|
||||
```
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
1. **Start Serial Bridge** (reads COM port)
|
||||
```bash
|
||||
cd serial_bridge
|
||||
python app.py
|
||||
```
|
||||
|
||||
2. **Start Test Writer** (writes test data)
|
||||
```bash
|
||||
cd test_comport_writer
|
||||
python test_writer.py --type scales --interval 1.0
|
||||
```
|
||||
|
||||
3. **View in React Frontend**
|
||||
- Open http://localhost:3000
|
||||
- Data should appear in real-time
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env` for default settings:
|
||||
|
||||
```env
|
||||
COM_PORT=COM1
|
||||
BAUD_RATE=9600
|
||||
WRITE_INTERVAL=1.0
|
||||
TEST_DATA_TYPE=scales
|
||||
SCALES_MIN=0
|
||||
SCALES_MAX=100
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
==================================================
|
||||
COM Port Test Writer
|
||||
==================================================
|
||||
Port: COM1
|
||||
Baud Rate: 9600
|
||||
Data Type: scales
|
||||
Write Interval: 1.0s
|
||||
==================================================
|
||||
|
||||
✓ Connected to COM1 at 9600 baud
|
||||
Press Ctrl+C to stop writing data
|
||||
|
||||
→ Sent: 45.23 kg
|
||||
→ Sent: 45.67 kg
|
||||
→ Sent: 46.12 kg
|
||||
→ Sent: 46.45 kg
|
||||
...
|
||||
(Press Ctrl+C to stop)
|
||||
|
||||
✓ Disconnected from COM1
|
||||
✓ Test writer stopped
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"COM port not found"**: Check `COM_PORT` setting and ensure port exists
|
||||
- **"Port already in use"**: Another application is using the port (close Serial Bridge temporarily)
|
||||
- **No data appearing**: Ensure Serial Bridge is running and pointing to same COM port
|
||||
- **Baud rate mismatch**: Ensure test writer and serial bridge use same baud rate
|
||||
|
||||
## Complete System Test
|
||||
|
||||
To test the complete flow:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Django Backend
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
python manage.py runserver
|
||||
|
||||
# Terminal 2: React Frontend
|
||||
cd frontend
|
||||
npm start
|
||||
|
||||
# Terminal 3: Serial Bridge
|
||||
cd serial_bridge
|
||||
venv\Scripts\activate
|
||||
python app.py
|
||||
|
||||
# Terminal 4: Test Writer
|
||||
cd test_comport_writer
|
||||
python test_writer.py --type scales --interval 1.0
|
||||
```
|
||||
|
||||
Then visit http://localhost:3000 to see real-time data flow.
|
||||
@ -0,0 +1,24 @@
|
||||
"""
|
||||
Configuration for COM Port Test Writer
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# COM Port Settings
|
||||
COM_PORT = os.getenv('COM_PORT', 'COM4')
|
||||
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
|
||||
TIMEOUT = int(os.getenv('TIMEOUT', 1))
|
||||
|
||||
# Test Data Settings
|
||||
WRITE_INTERVAL = float(os.getenv('WRITE_INTERVAL', 1.0)) # seconds between writes
|
||||
TEST_DATA_TYPE = os.getenv('TEST_DATA_TYPE', 'scales') # 'scales', 'counter', 'random'
|
||||
|
||||
# Scales data
|
||||
SCALES_MIN = int(os.getenv('SCALES_MIN', 5000))
|
||||
SCALES_MAX = int(os.getenv('SCALES_MAX', 20000))
|
||||
SCALES_INCREMENT = float(os.getenv('SCALES_INCREMENT', 5.0))
|
||||
|
||||
# Application Settings
|
||||
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||
@ -0,0 +1,2 @@
|
||||
pyserial==3.5
|
||||
python-dotenv==1.0.0
|
||||
@ -0,0 +1,215 @@
|
||||
"""
|
||||
COM Port Test Writer - Simulates data being sent to a COM port
|
||||
"""
|
||||
import serial
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
from config import (
|
||||
COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL,
|
||||
TEST_DATA_TYPE, SCALES_MIN, SCALES_MAX, SCALES_INCREMENT, DEBUG
|
||||
)
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if DEBUG else logging.INFO,
|
||||
# format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
format='%(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ComPortTestWriter:
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.serial_conn: Optional[serial.Serial] = None
|
||||
self.is_connected = False
|
||||
self.is_running = False
|
||||
self.data_counter = 0
|
||||
self.current_scales_value = SCALES_MIN
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the serial port"""
|
||||
try:
|
||||
self.serial_conn = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.is_connected = True
|
||||
logger.info(f"✓ Connected to {self.port} at {self.baudrate} baud")
|
||||
return True
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"✗ Failed to connect to {self.port}: {e}")
|
||||
logger.info("\nTip: Check that the COM port exists and is not in use.")
|
||||
logger.info("You can list available ports with: python -m serial.tools.list_ports")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the serial port"""
|
||||
if self.serial_conn and self.serial_conn.is_open:
|
||||
self.serial_conn.close()
|
||||
self.is_connected = False
|
||||
logger.info(f"✓ Disconnected from {self.port}")
|
||||
|
||||
def write_data(self, data: str) -> bool:
|
||||
"""Write data to the serial port"""
|
||||
try:
|
||||
if self.is_connected and self.serial_conn:
|
||||
# Add newline if not present
|
||||
if not data.endswith('\n'):
|
||||
data += '\n'
|
||||
|
||||
self.serial_conn.write(data.encode('utf-8'))
|
||||
logger.info(f"→ Sent: {data.strip()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing to {self.port}: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def generate_scales_data(self) -> str:
|
||||
"""Generate simulated scales data"""
|
||||
# Simulate scales value fluctuating slightly
|
||||
variation = random.uniform(-0.1, 0.1)
|
||||
self.current_scales_value += variation
|
||||
|
||||
# Keep within bounds
|
||||
self.current_scales_value = max(SCALES_MIN, min(SCALES_MAX, self.current_scales_value))
|
||||
|
||||
return f"{self.current_scales_value:.2f} kg"
|
||||
|
||||
def generate_counter_data(self) -> str:
|
||||
"""Generate incrementing counter data"""
|
||||
self.data_counter += 1
|
||||
return f"Count: {self.data_counter}"
|
||||
|
||||
def generate_random_data(self) -> str:
|
||||
"""Generate random numeric data"""
|
||||
value = random.uniform(10000, 50000)
|
||||
return f"Value: {value:.2f}"
|
||||
|
||||
def generate_mixed_data(self) -> str:
|
||||
"""Generate mixed sensor data"""
|
||||
temperature = random.uniform(20, 30)
|
||||
humidity = random.uniform(30, 70)
|
||||
pressure = random.uniform(1010, 1020)
|
||||
return f"T:{temperature:.1f}C H:{humidity:.1f}% P:{pressure:.1f}hPa"
|
||||
|
||||
def get_next_data(self) -> str:
|
||||
"""Get the next data to send based on test type"""
|
||||
if TEST_DATA_TYPE == 'scales':
|
||||
return self.generate_scales_data()
|
||||
elif TEST_DATA_TYPE == 'counter':
|
||||
return self.generate_counter_data()
|
||||
elif TEST_DATA_TYPE == 'random':
|
||||
return self.generate_random_data()
|
||||
elif TEST_DATA_TYPE == 'mixed':
|
||||
return self.generate_mixed_data()
|
||||
else:
|
||||
return self.generate_scales_data()
|
||||
|
||||
def run(self):
|
||||
"""Run the test writer"""
|
||||
logger.info(f"\n{'='*50}")
|
||||
logger.info(f"COM Port Test Writer")
|
||||
logger.info(f"{'='*50}")
|
||||
logger.info(f"Port: {self.port}")
|
||||
logger.info(f"Baud Rate: {self.baudrate}")
|
||||
logger.info(f"Data Type: {TEST_DATA_TYPE}")
|
||||
logger.info(f"Write Interval: {WRITE_INTERVAL}s")
|
||||
logger.info(f"{'='*50}\n")
|
||||
|
||||
if not self.connect():
|
||||
logger.error("Failed to connect to COM port. Exiting.")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
|
||||
try:
|
||||
logger.info("Press Ctrl+C to stop writing data\n")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
data = self.get_next_data()
|
||||
self.write_data(data)
|
||||
time.sleep(WRITE_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in write loop: {e}")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.is_running = False
|
||||
self.disconnect()
|
||||
logger.info("\n✓ Test writer stopped")
|
||||
|
||||
def list_available_ports():
|
||||
"""List available COM ports"""
|
||||
try:
|
||||
from serial.tools import list_ports
|
||||
ports = list_ports.comports()
|
||||
if ports:
|
||||
logger.info("\nAvailable COM ports:")
|
||||
for port in ports:
|
||||
logger.info(f" {port.device} - {port.description}")
|
||||
else:
|
||||
logger.info("No COM ports found")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing ports: {e}")
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Test COM port writer - continuously writes data to a COM port'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
default=COM_PORT,
|
||||
help=f'COM port to write to (default: {COM_PORT})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--baud',
|
||||
type=int,
|
||||
default=BAUD_RATE,
|
||||
help=f'Baud rate (default: {BAUD_RATE})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--type',
|
||||
choices=['scales', 'counter', 'random', 'mixed'],
|
||||
default=TEST_DATA_TYPE,
|
||||
help=f'Type of test data (default: {TEST_DATA_TYPE})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--interval',
|
||||
type=float,
|
||||
default=WRITE_INTERVAL,
|
||||
help=f'Write interval in seconds (default: {WRITE_INTERVAL})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='List available COM ports and exit'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
list_available_ports()
|
||||
return
|
||||
|
||||
writer = ComPortTestWriter(args.port, args.baud)
|
||||
writer.run()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue