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

This commit is contained in:
2026-01-17 13:03:21 +02:00
commit 7f04566242
81 changed files with 22551 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python manage.py migrate:*)"
]
}
}
+59
View File
@@ -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
+85
View File
@@ -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
+308
View File
@@ -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
+14
View 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
+1
View File
@@ -0,0 +1 @@
# Django API app
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'
View File
@@ -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}'
)
)
+174
View File
@@ -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",
),
),
]
View File
+38
View File
@@ -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}"
+194
View File
@@ -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
+22
View File
@@ -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)),
]
+211
View File
@@ -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)
+20
View File
@@ -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()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -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",
),
),
],
),
]
+29
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
+5
View File
@@ -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)
+10
View File
@@ -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
View File
+15
View File
@@ -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()
+156
View File
@@ -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',
}
+196
View File
@@ -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)
+12
View File
@@ -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'),
]
+10
View File
@@ -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()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -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",
),
),
],
),
]
+14
View File
@@ -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)
+7
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -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'),
]
+70
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:8000
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@components/*": ["components/*"],
"@contexts/*": ["contexts/*"],
"@hooks/*": ["hooks/*"],
"@utils/*": ["utils/*"],
"@services/*": ["services/*"],
"@assets/*": ["assets/*"]
}
},
"include": ["src"]
}
+17533
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
+17
View File
@@ -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>
+68
View File
@@ -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;
}
}
+73
View File
@@ -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;
+135
View File
@@ -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;
}
}
+62
View File
@@ -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;
+112
View File
@@ -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;
}
}
+52
View File
@@ -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;
+35
View File
@@ -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>
);
}
+24
View File
@@ -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">
&times;
</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;
+141
View File
@@ -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;
}
}
+90
View File
@@ -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;
+141
View File
@@ -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>
);
};
+48
View File
@@ -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;
+18
View File
@@ -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;
}
+11
View File
@@ -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>
);
+106
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
/c/dev_projects/ScalesApp/frontend
+22
View File
@@ -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
+71
View File
@@ -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
+231
View File
@@ -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()
+117
View File
@@ -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
+31
View File
@@ -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
+9
View File
@@ -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
+57
View File
@@ -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',
)
+110
View File
@@ -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
}
+82
View File
@@ -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
+16
View File
@@ -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
+165
View File
@@ -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.
+24
View File
@@ -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'
+2
View File
@@ -0,0 +1,2 @@
pyserial==3.5
python-dotenv==1.0.0
+215
View File
@@ -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()