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

master
kikimor 2 months ago
commit 7f04566242

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python manage.py migrate:*)"
]
}
}

59
.gitignore vendored

@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
venv/
ENV/
env/
.venv
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/media
/staticfiles
# React / Node
node_modules/
build/
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment
.env
.env.local

@ -0,0 +1,85 @@
# ScalesApp - Multi-Component Application
A distributed application consisting of three main components:
## Architecture
### 1. **Frontend (React)**
- Location: `/frontend`
- Description: React web application that displays real-time data from the serial port
- Communicates with: Django backend via REST API
- Port: 3000 (default)
### 2. **Backend (Django)**
- Location: `/backend`
- Description: Django REST API server for storing and retrieving serial data
- Database: SQLite (configurable)
- Port: 8000 (default)
### 3. **Serial Bridge (Python)**
- Location: `/serial_bridge`
- Description: Python application that reads data from COM ports
- Features: System tray integration, runs as EXE (via PyInstaller)
- Communicates with: Django backend via REST API
- Data Flow: COM Port → Serial Bridge → Django Backend → React Frontend
## Installation & Setup
### Prerequisites
- Node.js 16+ (for React)
- Python 3.8+ (for Django and Serial Bridge)
- pip or conda for Python packages
### Quick Start
1. **Backend Setup**
```bash
cd backend
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
2. **Frontend Setup**
```bash
cd frontend
npm install
npm start
```
3. **Serial Bridge Setup**
```bash
cd serial_bridge
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
python app.py
```
## Data Flow
```
COM Port
Serial Bridge (Python)
Django Backend REST API
React Frontend
```
## Configuration
Each component has its own configuration file:
- Frontend: `.env`
- Backend: `settings.py` and `.env`
- Serial Bridge: `config.py` and `.env`
## Development Tips
- Keep all three services running during development
- Use `CORS` headers properly in Django for frontend requests
- Serial Bridge should continuously read COM data and POST to backend
- Frontend should poll/subscribe to backend for updates

@ -0,0 +1,308 @@
# ScalesApp - Complete Setup & Installation Guide
A three-tier application for reading serial COM port data, storing it in a Django backend, and displaying it in a React frontend.
## Project Structure
```
ScalesApp/
├── frontend/ # React web application
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── .env.example
├── backend/ # Django REST API
│ ├── api/ # Main API app
│ ├── manage.py
│ ├── settings.py
│ ├── requirements.txt
│ └── .env.example
├── serial_bridge/ # Python serial reader (system tray)
│ ├── app.py
│ ├── serial_reader.py
│ ├── backend_client.py
│ ├── tray_icon.py
│ ├── config.py
│ ├── requirements.txt
│ ├── serial_bridge.spec
│ └── .env.example
└── README.md
```
## Prerequisites
- **Python 3.8+** (for Django and Serial Bridge)
- **Node.js 16+** (for React)
- **Windows OS** (Serial Bridge uses Windows-specific system tray)
- A COM port device (scales or serial reader)
## Step-by-Step Installation
### 1. Backend (Django) Setup
#### a. Create virtual environment
```bash
cd backend
python -m venv venv
venv\Scripts\activate
```
#### b. Install dependencies
```bash
pip install -r requirements.txt
```
#### c. Setup environment variables
```bash
copy .env.example .env
# Edit .env file with your settings
```
#### d. Initialize database
```bash
python manage.py migrate
```
#### e. Create admin user (optional)
```bash
python manage.py createsuperuser
```
#### f. Run the backend server
```bash
python manage.py runserver
```
The API will be available at `http://localhost:8000`
**Available API Endpoints:**
- `GET /api/readings/` - List all readings
- `POST /api/readings/` - Create a new reading
- `GET /api/readings/latest/` - Get the latest reading
- `GET /api/readings/by_port/?port=COM1` - Get readings from specific port
### 2. Frontend (React) Setup
#### a. Install dependencies
```bash
cd frontend
npm install
```
#### b. Setup environment variables
```bash
copy .env.example .env
# Ensure REACT_APP_API_URL=http://localhost:8000
```
#### c. Start development server
```bash
npm start
```
The frontend will open at `http://localhost:3000`
**Features:**
- Real-time data display from COM ports
- Port selector for filtering
- Recent readings table
- Auto-refresh every 2 seconds
### 3. Serial Bridge (Python) Setup
#### a. Create virtual environment
```bash
cd serial_bridge
python -m venv venv
venv\Scripts\activate
```
#### b. Install dependencies
```bash
pip install -r requirements.txt
```
#### c. Setup environment variables
```bash
copy .env.example .env
# Edit .env with your COM port and backend URL
```
Key settings:
- `COM_PORT=COM1` (change to your port)
- `BAUD_RATE=9600` (adjust based on your device)
- `BACKEND_URL=http://localhost:8000`
#### d. Run the application
```bash
python app.py
```
The app will:
- Appear in system tray
- Start reading from COM port
- Automatically post data to Django backend
- Retry failed posts
- Log to `serial_bridge.log`
### 4. Running All Services (Development)
Open 3 terminal windows and run:
```bash
# Terminal 1: Backend
cd backend
venv\Scripts\activate
python manage.py runserver
# Terminal 2: Frontend
cd frontend
npm start
# Terminal 3: Serial Bridge
cd serial_bridge
venv\Scripts\activate
python app.py
```
## Building Serial Bridge as EXE
To create a standalone Windows executable that runs in the system tray:
```bash
cd serial_bridge
# Install PyInstaller
pip install pyinstaller
# Build the executable
pyinstaller serial_bridge.spec
```
The executable will be created at `serial_bridge\dist\ScalesApp\ScalesApp.exe`
You can:
- Run it directly
- Add it to Startup folder (`C:\Users\<User>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`)
- Create a Windows Task Scheduler entry to run at startup
## Data Flow
```
┌──────────────────┐
│ Physical Device │
│ (Scales/COM) │
└────────┬─────────┘
│ Serial data
┌──────────────────────────────┐
│ Serial Bridge (Python) │
│ - Reads COM port │
│ - System tray app │
│ - Retry logic │
└────────┬─────────────────────┘
│ HTTP POST /api/readings/
┌──────────────────────────────┐
│ Django Backend │
│ - REST API │
│ - Database storage │
│ - CORS enabled │
└────────┬─────────────────────┘
│ REST API GET /api/readings/
┌──────────────────────────────┐
│ React Frontend │
│ - Real-time display │
│ - Port selector │
│ - Auto-refresh │
└──────────────────────────────┘
```
## Configuration Files
### Backend (backend/.env)
```env
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=sqlite:///db.sqlite3
```
### Frontend (frontend/.env)
```env
REACT_APP_API_URL=http://localhost:8000
```
### Serial Bridge (serial_bridge/.env)
```env
COM_PORT=COM1
BAUD_RATE=9600
BACKEND_URL=http://localhost:8000
AUTO_CONNECT=True
DEBUG=False
```
## Troubleshooting
### Backend Issues
- **Port already in use**: Change port: `python manage.py runserver 8001`
- **CORS errors**: Check `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS` in settings.py
- **Database errors**: Run migrations: `python manage.py migrate`
### Frontend Issues
- **Cannot connect to backend**: Ensure Django is running and `REACT_APP_API_URL` is correct
- **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000`
### Serial Bridge Issues
- **COM port not found**: List available ports with: `python -m serial.tools.list_ports`
- **Backend not reachable**: Check `BACKEND_URL` and ensure Django server is running
- **No tray icon**: Run as administrator, check Windows system tray settings
- **Data not posting**: Check `serial_bridge.log` for errors
## Production Deployment
### Backend
```bash
# Use production settings
export DJANGO_SETTINGS_MODULE=scalesapp.settings
python manage.py collectstatic
# Use Gunicorn
pip install gunicorn
gunicorn scalesapp.wsgi -b 0.0.0.0:8000
```
### Frontend
```bash
npm run build
# Serve dist folder with nginx or similar
```
### Serial Bridge
Create Windows Service or Scheduled Task to run the EXE at startup.
## Next Steps
1. ✅ Test all three components locally
2. ✅ Verify data flow from COM → Backend → Frontend
3. ✅ Customize React components if needed
4. ✅ Build Serial Bridge as EXE
5. ✅ Deploy to production servers
6. ✅ Setup monitoring and logging
## Additional Resources
- [Django REST Framework Docs](https://www.django-rest-framework.org/)
- [React Docs](https://react.dev/)
- [PySerial Docs](https://pyserial.readthedocs.io/)
- [Pystray Docs](https://github.com/moses-palmer/pystray)
## Support
For issues, check the logs:
- Backend: Django console output
- Frontend: Browser console (F12)
- Serial Bridge: `serial_bridge.log` file

@ -0,0 +1,14 @@
# Backend environment variables
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database Settings
# Use 'django.db.backends.sqlite3' for SQLite (default)
# Use 'django.db.backends.postgresql' for PostgreSQL
DB_ENGINE=django.db.backends.sqlite3
DB_NAME=db.sqlite3
DB_USER=postgres
DB_PASSWORD=
DB_HOST=localhost
DB_PORT=5432

@ -0,0 +1 @@
# Django API app

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from api.models import User
class Command(BaseCommand):
help = 'Creates predefined admin user (username: admin, password: admin)'
def handle(self, *args, **options):
username = 'admin'
password = 'admin'
if User.objects.filter(username=username).exists():
self.stdout.write(
self.style.WARNING(f'User "{username}" already exists')
)
return
user = User.objects.create(
username=username,
email='admin@scalesapp.com',
role='employee',
is_admin=True,
is_staff=True,
is_superuser=True,
is_active=True
)
user.set_password(password)
user.save()
self.stdout.write(
self.style.SUCCESS(
f'Successfully created admin user: {username} / {password}'
)
)

@ -0,0 +1,174 @@
# Generated by Django 4.2 on 2026-01-12 18:39
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"role",
models.CharField(
choices=[("employee", "Employee"), ("viewer", "Viewer")],
default="viewer",
max_length=20,
),
),
("is_admin", models.BooleanField(default=False)),
],
options={
"db_table": "api_user",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="ComPortReading",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("port", models.CharField(max_length=20)),
("data", models.TextField()),
("timestamp", models.DateTimeField(auto_now_add=True)),
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.AddIndex(
model_name="comportreading",
index=models.Index(
fields=["-timestamp"], name="api_comport_timesta_c2b399_idx"
),
),
migrations.AddIndex(
model_name="comportreading",
index=models.Index(
fields=["port", "-timestamp"], name="api_comport_port_123b9f_idx"
),
),
migrations.AddField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
migrations.AddField(
model_name="user",
name="user_permissions",
field=models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
]

@ -0,0 +1,38 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
class User(AbstractUser):
"""Custom User model with role and admin flag"""
ROLE_CHOICES = [
('employee', 'Employee'),
('viewer', 'Viewer'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
is_admin = models.BooleanField(default=False)
class Meta:
db_table = 'api_user'
def __str__(self):
return f"{self.username} ({self.get_role_display()})"
class ComPortReading(models.Model):
"""Model to store serial port readings"""
port = models.CharField(max_length=20)
data = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
source_ip = models.GenericIPAddressField(null=True, blank=True)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['-timestamp']),
models.Index(fields=['port', '-timestamp']),
]
def __str__(self):
return f"{self.port} - {self.timestamp}"

@ -0,0 +1,194 @@
from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password
from .models import ComPortReading, User
from vehicles.models import Vehicle, VehicleExtra
from nomenclatures.models import Nomenclature, NomenclatureField
class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False)
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name',
'role', 'is_admin', 'is_active', 'date_joined', 'password']
read_only_fields = ['id', 'date_joined']
extra_kwargs = {
'password': {'write_only': True}
}
def create(self, validated_data):
password = validated_data.pop('password', None)
user = User(**validated_data)
if password:
user.set_password(password)
user.save()
return user
def update(self, instance, validated_data):
password = validated_data.pop('password', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
return instance
class UserDetailSerializer(serializers.ModelSerializer):
"""Serializer for current user details (excludes password)"""
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name',
'role', 'is_admin', 'is_active', 'date_joined']
read_only_fields = ['id', 'date_joined']
class ChangePasswordSerializer(serializers.Serializer):
"""Serializer for password change endpoint"""
old_password = serializers.CharField(required=True, write_only=True)
new_password = serializers.CharField(required=True, write_only=True)
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError("Old password is incorrect")
return value
def validate_new_password(self, value):
# Use Django's password validators
validate_password(value, self.context['request'].user)
return value
def save(self, **kwargs):
user = self.context['request'].user
user.set_password(self.validated_data['new_password'])
user.save()
return user
class ComPortReadingSerializer(serializers.ModelSerializer):
class Meta:
model = ComPortReading
fields = ['id', 'port', 'data', 'timestamp', 'source_ip']
read_only_fields = ['id', 'timestamp']
class VehicleExtraSerializer(serializers.ModelSerializer):
class Meta:
model = VehicleExtra
fields = ['id', 'data']
read_only_fields = ['id']
class VehicleSerializer(serializers.ModelSerializer):
extra = VehicleExtraSerializer(required=False, allow_null=True)
class Meta:
model = Vehicle
fields = ['id', 'vehicle_number', 'extra']
read_only_fields = ['id']
def create(self, validated_data):
extra_data = validated_data.pop('extra', None)
vehicle = Vehicle.objects.create(**validated_data)
if extra_data:
VehicleExtra.objects.create(vehicle=vehicle, **extra_data)
return vehicle
def update(self, instance, validated_data):
extra_data = validated_data.pop('extra', None)
# Update Vehicle fields
instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number)
instance.save()
# Handle VehicleExtra update/creation
if extra_data is not None:
if hasattr(instance, 'extra'):
# Update existing VehicleExtra
for attr, value in extra_data.items():
setattr(instance.extra, attr, value)
instance.extra.save()
else:
# Create new VehicleExtra
VehicleExtra.objects.create(vehicle=instance, **extra_data)
return instance
def validate_vehicle_number(self, value):
if not value or not value.strip():
raise serializers.ValidationError("Vehicle number cannot be empty")
return value.strip()
class NomenclatureFieldSerializer(serializers.ModelSerializer):
class Meta:
model = NomenclatureField
fields = ['id', 'key', 'field_type']
read_only_fields = ['id']
def validate_key(self, value):
if not value or not value.strip():
raise serializers.ValidationError("Field key cannot be empty")
return value.strip()
def validate_field_type(self, value):
valid_types = [choice[0] for choice in NomenclatureField.FIELD_TYPES]
if value not in valid_types:
raise serializers.ValidationError(
f"Invalid field type. Must be one of: {', '.join(valid_types)}"
)
return value
class NomenclatureSerializer(serializers.ModelSerializer):
fields = NomenclatureFieldSerializer(many=True, required=False, source='nomenclaturefield_set')
class Meta:
model = Nomenclature
fields = ['id', 'code', 'name', 'applies_to', 'fields']
read_only_fields = ['id']
def create(self, validated_data):
fields_data = validated_data.pop('nomenclaturefield_set', [])
nomenclature = Nomenclature.objects.create(**validated_data)
for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
return nomenclature
def update(self, instance, validated_data):
fields_data = validated_data.pop('nomenclaturefield_set', None)
# Update Nomenclature fields
instance.code = validated_data.get('code', instance.code)
instance.name = validated_data.get('name', instance.name)
instance.applies_to = validated_data.get('applies_to', instance.applies_to)
instance.save()
# Handle fields update - replace all fields
if fields_data is not None:
# Delete existing fields
instance.nomenclaturefield_set.all().delete()
# Create new fields
for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=instance, **field_data)
return instance
def validate_code(self, value):
if not value or not value.strip():
raise serializers.ValidationError("Code cannot be empty")
return value.strip()
def validate_applies_to(self, value):
valid_choices = ['vehicle', 'container']
if value not in valid_choices:
raise serializers.ValidationError(
f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}"
)
return value

@ -0,0 +1,22 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from . import views
router = DefaultRouter()
router.register(r'users', views.UserViewSet, basename='user')
router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
urlpatterns = [
# JWT token endpoints
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# Router URLs
path('', include(router.urls)),
]

@ -0,0 +1,211 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import ComPortReading, User
from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer
from vehicles.models import Vehicle
from nomenclatures.models import Nomenclature
class UserViewSet(viewsets.ModelViewSet):
"""
API endpoint for user management.
list: Get all users
create: Create a new user
retrieve: Get a specific user
update: Update a user
destroy: Delete a user
me: Get current authenticated user
change_password: Change password for current user
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filterset_fields = ['role', 'is_admin', 'is_active']
ordering = ['username']
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def me(self, request):
"""Get current authenticated user details"""
serializer = UserDetailSerializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated],
url_path='change-password', url_name='change_password')
def change_password(self, request):
"""Change password for current user"""
serializer = ChangePasswordSerializer(
data=request.data,
context={'request': request}
)
if serializer.is_valid():
serializer.save()
return Response({
'detail': 'Password updated successfully'
}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ComPortReadingViewSet(viewsets.ModelViewSet):
"""
API endpoint for COM port readings.
list: Get all readings with optional filtering
create: Create a new reading (allows unauthenticated for serial bridge)
retrieve: Get a specific reading
destroy: Delete a reading
latest: Get the latest reading
by_port: Get readings for a specific port
"""
queryset = ComPortReading.objects.all()
serializer_class = ComPortReadingSerializer
filterset_fields = ['port']
ordering = ['-timestamp']
def get_permissions(self):
"""Allow unauthenticated POST for serial bridge"""
if self.action == 'create':
return [AllowAny()]
return [IsAuthenticated()]
@action(detail=False, methods=['get'])
def latest(self, request):
"""Get the latest reading"""
reading = ComPortReading.objects.first()
if reading:
serializer = self.get_serializer(reading)
return Response(serializer.data)
return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['get'])
def by_port(self, request):
"""Get readings for a specific port"""
port = request.query_params.get('port', None)
if not port:
return Response(
{'detail': 'port parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
readings = ComPortReading.objects.filter(port=port)
serializer = self.get_serializer(readings, many=True)
return Response(serializer.data)
def get_client_ip(self, request):
"""Get client IP address"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def perform_create(self, serializer):
"""Save the reading with client IP"""
serializer.save(source_ip=self.get_client_ip(self.request))
class VehicleViewSet(viewsets.ModelViewSet):
"""
API endpoint for vehicle management.
list: Get all vehicles with pagination
create: Create a new vehicle with optional extra data
retrieve: Get a specific vehicle by ID
update: Full update of vehicle and extra data
partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra)
by_number: Get vehicle by vehicle_number
"""
queryset = Vehicle.objects.select_related('extra').all()
serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['vehicle_number']
search_fields = ['vehicle_number']
ordering = ['vehicle_number']
@action(detail=False, methods=['get'], url_path='by-number')
def by_number(self, request):
"""Get vehicle by vehicle_number query parameter"""
vehicle_number = request.query_params.get('vehicle_number', None)
if not vehicle_number:
return Response(
{'detail': 'vehicle_number parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
vehicle = Vehicle.objects.get(vehicle_number=vehicle_number)
serializer = self.get_serializer(vehicle)
return Response(serializer.data)
except Vehicle.DoesNotExist:
return Response(
{'detail': 'Vehicle not found'},
status=status.HTTP_404_NOT_FOUND
)
class NomenclatureViewSet(viewsets.ModelViewSet):
"""
API endpoint for nomenclature management.
list: Get all nomenclatures with pagination
create: Create a new nomenclature with fields
retrieve: Get a specific nomenclature by ID
update: Full update of nomenclature and fields
partial_update: Partial update of nomenclature
destroy: Delete a nomenclature (cascades to fields)
by_code: Get nomenclature by code
by_applies_to: Filter nomenclatures by applies_to type
"""
queryset = Nomenclature.objects.prefetch_related('nomenclaturefield_set').all()
serializer_class = NomenclatureSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['applies_to', 'code']
search_fields = ['code', 'name']
ordering = ['code']
@action(detail=False, methods=['get'], url_path='by-code')
def by_code(self, request):
"""Get nomenclature by code query parameter"""
code = request.query_params.get('code', None)
if not code:
return Response(
{'detail': 'code parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
nomenclature = Nomenclature.objects.get(code=code)
serializer = self.get_serializer(nomenclature)
return Response(serializer.data)
except Nomenclature.DoesNotExist:
return Response(
{'detail': 'Nomenclature not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['get'], url_path='by-applies-to')
def by_applies_to(self, request):
"""Get nomenclatures filtered by applies_to query parameter"""
applies_to = request.query_params.get('applies_to', None)
if not applies_to:
return Response(
{'detail': 'applies_to parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
if applies_to not in ['vehicle', 'container']:
return Response(
{'detail': 'applies_to must be either vehicle or container'},
status=status.HTTP_400_BAD_REQUEST
)
nomenclatures = Nomenclature.objects.filter(applies_to=applies_to)
page = self.paginate_queryset(nomenclatures)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(nomenclatures, many=True)
return Response(serializer.data)

@ -0,0 +1,20 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NomenclaturesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "nomenclatures"

@ -0,0 +1,71 @@
# Generated by Django 4.2.8 on 2026-01-13 16:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Nomenclature",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=50, unique=True)),
("name", models.CharField(max_length=255)),
(
"applies_to",
models.CharField(
choices=[("vehicle", "Vehicle"), ("container", "Container")],
max_length=50,
),
),
],
),
migrations.CreateModel(
name="NomenclatureField",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=50)),
(
"field_type",
models.CharField(
choices=[
("text", "Text"),
("number", "Number"),
("bool", "Boolean"),
("choice", "Choice"),
],
max_length=20,
),
),
(
"nomenclature",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="nomenclatures.nomenclature",
),
),
],
),
]

@ -0,0 +1,29 @@
from django.db import models
# Create your models here.
class Nomenclature(models.Model):
code = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=255)
applies_to = models.CharField(
max_length=50,
choices=[("vehicle", "Vehicle"), ("container", "Container")]
)
class NomenclatureField(models.Model):
TEXT = "text"
NUMBER = "number"
BOOL = "bool"
CHOICE = "choice"
FIELD_TYPES = [
(TEXT, "Text"),
(NUMBER, "Number"),
(BOOL, "Boolean"),
(CHOICE, "Choice"),
]
nomenclature = models.ForeignKey(
Nomenclature, on_delete=models.CASCADE
)
key = models.CharField(max_length=50)
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

@ -0,0 +1,5 @@
SSE - in scalesapp
async def authenticate_sse_request(request)
def broadcast_update(object_name, action, data)
async def updates_sse(request)
def send_message(request)

@ -0,0 +1,10 @@
Django==6.0.1
djangorestframework==3.15.2
djangorestframework-simplejwt==5.5.1
django-cors-headers==4.6.0
python-dotenv==1.0.1
requests==2.32.3
sqlparse==0.5.3
psycopg==3.3.2
psycopg-binary==3.3.2
uvicorn[standard]==0.34.0

@ -0,0 +1,15 @@
"""
ASGI config for scalesapp project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
application = get_asgi_application()

@ -0,0 +1,156 @@
"""
Django settings for scalesapp project.
"""
import os
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-dev-key-change-in-production')
DEBUG = os.getenv('DEBUG', 'True') == 'True'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
PROJECT_APPS = [
'api',
'vehicles',
'nomenclatures',
]
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework.authtoken',
'rest_framework_simplejwt',
'corsheaders',
] + PROJECT_APPS
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'scalesapp.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'scalesapp.wsgi.application'
ASGI_APPLICATION = 'scalesapp.asgi.application'
# Database configuration with support for SQLite and PostgreSQL
DATABASES = {
'default': {
'ENGINE': "django.db.backends.postgresql",
'NAME': os.getenv('DB_NAME', 'scalesapp'),
'USER': os.getenv('DB_USER', 'postgres'),
'PASSWORD': os.getenv('DB_PASSWORD', ''),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Custom User Model
AUTH_USER_MODEL = 'api.User'
# CORS Configuration
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:5174',
'http://127.0.0.1:5174',
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
CORS_ALLOW_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
]
# DRF Configuration
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_FILTER_BACKENDS': [
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
}
# JWT Configuration
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'AUTH_HEADER_TYPES': ('Bearer',),
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
}

@ -0,0 +1,196 @@
"""
SSE - Async Implementation for ASGI
Server Side Events, opens connection and keeps it alive, broadcasting new/changed objects to all connected clients
"""
import json
import time
import asyncio
from django.http import StreamingHttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from asgiref.sync import sync_to_async
import threading
# Global updates queue for all objects
updates_queue = []
updates_lock = threading.Lock()
# Global event ID counter
event_id_counter = 0
event_id_lock = threading.Lock()
# called from views when an object is changed to broadcast the change to all connected clients
def sse_broadcast_update(object_name, action, data):
"""
Broadcast object update to all connected SSE clients
object_name: 'agent', 'vessel', 'cargo', and others..
action: 'created', 'updated', or 'deleted'
data: serialized object data
"""
global event_id_counter
# Map action to operation
operation_map = {
'created': 'insert',
'updated': 'update',
'deleted': 'delete'
}
with event_id_lock:
event_id_counter += 1
current_event_id = event_id_counter
with updates_lock:
updates_queue.append({
'event_id': current_event_id, # Add event ID
'type': 'data_update',
'object': object_name,
'operation': operation_map.get(action, action),
'data': data,
'timestamp': time.time()
})
# Keep only last 100 updates
while len(updates_queue) > 100:
updates_queue.pop(0)
async def sse_authenticate_request(request):
"""
Called from sse_connect, Authenticate SSE request via JWT token from Authorization header or query parameter.
Returns (user, error_response) - if error_response is not None, return it immediately.
"""
# Try to get token from Authorization header
auth_header = request.headers.get('Authorization', '')
token_key = None
if auth_header.startswith('Bearer '):
token_key = auth_header[7:]
# Fallback: try query parameter (for browsers' EventSource that can't set headers)
if not token_key:
token_key = request.GET.get('token')
if not token_key:
return None, JsonResponse({'error': 'Authentication required'}, status=401)
try:
# Validate JWT token - wrap in sync_to_async for DB queries
jwt_auth = JWTAuthentication()
validated_token = jwt_auth.get_validated_token(token_key)
user = await sync_to_async(jwt_auth.get_user)(validated_token)
return user, None
except (InvalidToken, TokenError) as e:
return None, JsonResponse({'error': 'Invalid token'}, status=401)
# User calls update_sse to establish a sse connection, then he receives updates until disconnect or error
@csrf_exempt
async def sse_connect(request):
"""SSE endpoint for all object updates (requires authentication)"""
# Authenticate the request
user, error_response = await sse_authenticate_request(request)
if error_response:
return error_response
# Check for Last-Event-ID header (browser sends this on reconnect)
last_event_id_header = request.headers.get('Last-Event-ID', None)
# On fresh connection (no Last-Event-ID), use current event counter as baseline
# On reconnection (has Last-Event-ID), use that to replay missed events
if last_event_id_header:
last_sent_event_id = int(last_event_id_header)
print(f"SSE reconnection: Last-Event-ID={last_event_id_header}")
else:
# Fresh connection - use current event counter as baseline
with event_id_lock:
last_sent_event_id = event_id_counter
print(f"SSE new connection: Starting from event #{last_sent_event_id}")
# Async generator for ASGI compatibility
async def event_generator():
nonlocal last_sent_event_id
# Send connection established message with event ID
connected_msg = {
"type": "connected",
"message": "Connected to updates stream",
"current_event_id": last_sent_event_id
}
yield f'id: {last_sent_event_id}\n'.encode('utf-8')
yield f'data: {json.dumps(connected_msg)}\n\n'.encode('utf-8')
# Keep connection alive and send new updates
while True:
try:
with updates_lock:
# Get updates since last event ID
missed_updates = [u for u in updates_queue if u['event_id'] > last_sent_event_id]
if missed_updates:
print(f"Sending {len(missed_updates)} missed updates (after event #{last_sent_event_id})")
for update in missed_updates:
event_id = update['event_id']
data = json.dumps(update)
# Send event with ID in SSE format
yield f'id: {event_id}\n'.encode('utf-8')
yield f'data: {data}\n\n'.encode('utf-8')
last_sent_event_id = event_id
# Send keepalive comment to prevent timeout
yield b': keepalive\n\n'
# Use async sleep for proper ASGI compatibility
await asyncio.sleep(1)
except Exception as e:
print(f"Updates SSE error: {e}")
break
response = StreamingHttpResponse(
event_generator(),
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response
# @csrf_exempt
# def send_message(request):
# """Endpoint to send a message"""
# if request.method != 'POST':
# return JsonResponse({'error': 'POST only'}, status=405)
# try:
# data = json.loads(request.body)
# message_text = data.get('message', '').strip()
# sender = data.get('sender', 'Anonymous')
# if not message_text:
# return JsonResponse({'error': 'Empty message'}, status=400)
# # Add message to global queue
# with messages_lock:
# messages.append({
# 'type': 'message',
# 'sender': sender,
# 'message': message_text,
# 'timestamp': time.time()
# })
# # Keep only last 100 messages
# while len(messages) > 100:
# messages.pop(0)
# return JsonResponse({'success': True})
# except Exception as e:
# return JsonResponse({'error': str(e)}, status=500)

@ -0,0 +1,12 @@
"""
Main URL configuration for scalesapp.
"""
from django.contrib import admin
from django.urls import path, include
from .sse import sse_connect
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('api.urls')),
path("sse-connect/", sse_connect, name='sse-connect'),
]

@ -0,0 +1,10 @@
"""
WSGI config for scalesapp project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
application = get_wsgi_application()

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

@ -0,0 +1,6 @@
from django.apps import AppConfig
class VehiclesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vehicles"

@ -0,0 +1,52 @@
# Generated by Django 4.2.8 on 2026-01-13 16:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Vehicle",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("vehicle_number", models.CharField(max_length=15, unique=True)),
],
),
migrations.CreateModel(
name="VehicleExtra",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("data", models.JSONField(default=dict)),
(
"vehicle",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="extra",
to="vehicles.vehicle",
),
),
],
),
]

@ -0,0 +1,14 @@
from django.db import models
# Create your models here.
class Vehicle(models.Model):
vehicle_number = models.CharField(max_length=15, unique=True)
def __str__(self):
return f"{self.vehicle_number}"
class VehicleExtra(models.Model):
vehicle = models.OneToOneField(
Vehicle, on_delete=models.CASCADE, related_name="extra"
)
data = models.JSONField(default=dict)

@ -0,0 +1,7 @@
from rest_framework import serializers
from .models import Vehicle
class VehicleSerializer(serializers.ModelSerializer):
class Meta:
model = Vehicle
fields = ['id', 'vehicle_number']

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,10 @@
from django.urls import path
from vehicles.views import vehiclesView, VehicleListCreateAPIView, VehicleRetrieveUpdateDestroyAPIView, get_vehicles
urlpatterns = [
path("", vehiclesView.as_view(), name='list_vehicles'),
# API endpoints
path("api/", VehicleListCreateAPIView.as_view(), name='vehicles-list-create'),
path("api/<int:pk>/", VehicleRetrieveUpdateDestroyAPIView.as_view(), name='vehicles-detail'),
path("api/list/", get_vehicles, name='get-vehicles'),
]

@ -0,0 +1,70 @@
from django.shortcuts import render
from django.shortcuts import render
from django.views.generic.list import ListView
from rest_framework import generics, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Vehicle
from .serializers import VehicleSerializer
from scalesapp.sse import broadcast_update
# Create your views here.
class CountriesView(ListView):
pass
# API Views
class VehicleListCreateAPIView(generics.ListCreateAPIView):
queryset =Vehicle.objects.all()
serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
"""Override create to broadcast SSE message"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# Broadcast SSE update
broadcast_update('vehicle', 'created', serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class VehicleRetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView):
queryset = Vehicle.objects.all()
serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated]
def update(self, request, *args, **kwargs):
"""Override update to broadcast SSE message"""
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# Broadcast SSE update
broadcast_update('vehicle', 'updated', serializer.data)
return Response(serializer.data)
def destroy(self, request, *args, **kwargs):
"""Override destroy to broadcast SSE message"""
instance = self.get_object()
country_data = CountrySerializer(instance).data
self.perform_destroy(instance)
# Broadcast SSE update
broadcast_update('vehicle', 'deleted', country_data)
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_vehicles(request):
"""API endpoint for getting countries list"""
vehicles = Vehicle.objects.all().order_by('id')
serializer = VehicleSerializer(vehicles, many=True)
return Response(serializer.data)

@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:8000

@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@components/*": ["components/*"],
"@contexts/*": ["contexts/*"],
"@hooks/*": ["hooks/*"],
"@utils/*": ["utils/*"],
"@services/*": ["services/*"],
"@assets/*": ["assets/*"]
}
},
"include": ["src"]
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,37 @@
{
"name": "scalesapp-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"axios": "^1.6.2",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:8000"
}

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta
name="description"
content="ScalesApp - Real-time COM port data monitoring"
/>
<title>ScalesApp - Data Monitor</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

@ -0,0 +1,68 @@
.App {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.status-bar {
background: white;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.status-connected {
color: #4CAF50;
font-size: 14px;
font-weight: 500;
}
.status-disconnected {
color: #f44336;
font-size: 14px;
font-weight: 500;
}
.status-error {
color: #f44336;
font-size: 12px;
}
.container {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
flex: 1;
width: 100%;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 12px 20px;
border-radius: 4px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
}
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.container {
padding: 0 10px;
}
}

@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
// import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Users/Login';
import Main from './components/Main';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
// function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null);
// const { readings, isConnected, error } = useSerialData();
// const filteredReadings = selectedPort
// ? readings.filter(r => r.port === selectedPort)
// : readings;
// return (
// <div className="App">
// <Header />
// <div className="status-bar">
// {isConnected ? (
// <span className="status-connected">● Connected to Serial Bridge</span>
// ) : (
// <span className="status-disconnected">● Disconnected</span>
// )}
// {error && (
// <span className="status-error">{error}</span>
// )}
// </div>
// <div className="container">
// <DataDisplay readings={filteredReadings} selectedPort={selectedPort} />
// </div>
// </div>
// );
// }
function AppContent() {
const { user, login, logout, loading, isAuthenticated } = useAuth();
if (loading) {
return (
<div className="loading">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Login onLogin={login} />;
}
return (
<NomenclatureProvider>
<Main />
</NomenclatureProvider>
);
}
function App() {
return (
<Router>
<AuthProvider>
<AppContent />
</AuthProvider>
</Router>
);
}
export default App;

@ -0,0 +1,135 @@
.data-display {
margin-top: 20px;
}
.data-display.empty {
background: white;
padding: 40px;
border-radius: 8px;
text-align: center;
color: #999;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.reading-card.latest {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-left: 4px solid #667eea;
}
.reading-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 15px;
}
.info-item {
padding: 10px;
background: white;
border-radius: 4px;
}
.info-item label {
display: block;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
font-size: 14px;
}
.info-item span {
display: block;
font-size: 16px;
color: #333;
word-break: break-all;
}
.data-value {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
font-weight: bold;
}
.readings-history {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.readings-history h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.table-container {
overflow-x: auto;
margin-top: 15px;
}
table {
width: 100%;
border-collapse: collapse;
}
table thead {
background-color: #f5f5f5;
}
table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #667eea;
border-bottom: 2px solid #ddd;
}
table td {
padding: 12px;
border-bottom: 1px solid #eee;
}
table tbody tr:hover {
background-color: #f9f9f9;
}
.data-cell {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
word-break: break-all;
}
@media (max-width: 768px) {
.reading-info {
grid-template-columns: 1fr;
}
table {
font-size: 14px;
}
table th, table td {
padding: 8px;
}
}

@ -0,0 +1,62 @@
import React from 'react';
import './DataDisplay.css';
function DataDisplay({ readings, selectedPort }) {
if (!readings || readings.length === 0) {
return (
<div className="data-display empty">
<p>No data available. Waiting for COM port readings...</p>
</div>
);
}
const latestReading = readings[0];
return (
<div className="data-display">
<div className="reading-card latest">
<h2>Latest Reading</h2>
<div className="reading-info">
<div className="info-item">
<label>Port:</label>
<span>{latestReading.port}</span>
</div>
<div className="info-item">
<label>Data:</label>
<span className="data-value">{latestReading.data}</span>
</div>
<div className="info-item">
<label>Time:</label>
<span>{new Date(latestReading.timestamp).toLocaleString()}</span>
</div>
</div>
</div>
{/* <div className="readings-history">
<h2>Recent Readings ({readings.length})</h2>
<div className="table-container">
<table>
<thead>
<tr>
<th>Port</th>
<th>Data</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{readings.map((reading, idx) => (
<tr key={reading.id || idx}>
<td>{reading.port}</td>
<td className="data-cell">{reading.data}</td>
<td>{new Date(reading.timestamp).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> */}
</div>
);
}
export default DataDisplay;

@ -0,0 +1,112 @@
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 10px;
}
.user-name {
font-weight: 600;
font-size: 14px;
}
.user-role {
font-size: 12px;
opacity: 0.9;
}
.avatar-container {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: white;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.logout-button {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.header-left h1 {
font-size: 18px;
text-align: center;
}
.header-right {
justify-content: space-between;
}
.user-info {
align-items: flex-start;
margin-right: 0;
}
}

@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
const handleAvatarClick = () => {
setShowPasswordOverlay(true);
};
return (
<>
<header className="app-header">
<div className="header-content">
<div className="header-left">
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
</>
);
}
export default Header;

@ -0,0 +1,35 @@
import Header from './Header';
import DataDisplay from './DataDisplay';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
export default function Main() {
const { readings, isConnected, error } = useSerialData();
const { vehicles } = useNomenclatures();
console.log('Vehicles:', vehicles);
return (
<div className="main">
<div>
<div className="vehicles">
{ vehicles.map(vehicle => {
return (
<div key={vehicle.id} className="vehicle-card">
<h3>{vehicle.vehicle_number}</h3>
</div>
);
})}
</div>
</div>
<div className="vehicles-data">
<div className="data-header">
<h2>Vehicle Data</h2>
</div>
<div className="data-list">
<Header />
<DataDisplay readings={readings}/>
</div>
</div>
</div>
);
}

@ -0,0 +1,24 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;

@ -0,0 +1,220 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.overlay-header {
padding: 24px 24px 16px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.overlay-header h2 {
margin: 0;
color: #667eea;
font-size: 24px;
}
.close-button {
background: none;
border: none;
font-size: 32px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.close-button:hover {
background: #f5f5f5;
color: #333;
}
.password-form {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.cancel-button,
.submit-button {
flex: 1;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.cancel-button {
background: white;
border: 2px solid #ddd;
color: #666;
}
.cancel-button:hover:not(:disabled) {
border-color: #999;
color: #333;
}
.submit-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.success-message {
padding: 40px 24px;
text-align: center;
}
.success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 16px;
animation: scaleIn 0.4s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.success-message p {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
}
@media (max-width: 540px) {
.overlay-content {
max-width: 100%;
}
.password-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
}

@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import './ChangePasswordOverlay.css';
function ChangePasswordOverlay({ onClose }) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { changePassword } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validation
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (oldPassword === newPassword) {
setError('New password must be different from old password');
return;
}
setIsLoading(true);
const result = await changePassword(oldPassword, newPassword);
if (result.success) {
setSuccess(true);
setTimeout(() => {
onClose();
}, 2000);
} else {
// Extract error message
let errorMsg = 'Failed to change password';
if (result.error.old_password) {
errorMsg = result.error.old_password[0];
} else if (result.error.new_password) {
errorMsg = result.error.new_password[0];
} else if (result.error.detail) {
errorMsg = result.error.detail;
}
setError(errorMsg);
setIsLoading(false);
}
};
const handleOverlayClick = (e) => {
if (e.target.className === 'overlay') {
onClose();
}
};
return (
<div className="overlay" onClick={handleOverlayClick}>
<div className="overlay-content">
<div className="overlay-header">
<h2>Change Password</h2>
<button className="close-button" onClick={onClose} aria-label="Close">
&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;

@ -0,0 +1,141 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
margin: 0 0 10px 0;
color: #667eea;
font-size: 32px;
}
.login-header p {
margin: 0;
color: #666;
font-size: 16px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 10px;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.login-header h1 {
font-size: 28px;
}
}

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import './Login.css';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/');
} else {
setError(result.error);
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>ScalesApp</h1>
<p>Sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoFocus
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
export default Login;

@ -0,0 +1,141 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Initialize auth state from localStorage
useEffect(() => {
const initAuth = async () => {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (accessToken && refreshToken) {
try {
// Validate token by fetching user info
const userData = await fetchCurrentUser();
setCurrentUser(userData);
setIsAuthenticated(true);
} catch (error) {
console.error('Token validation failed:', error);
clearTokens();
}
}
setIsLoading(false);
};
initAuth();
}, []);
// Auto-refresh token before expiry
useEffect(() => {
if (!isAuthenticated) return;
// Refresh token every 14 minutes (tokens expire at 15 min)
const refreshInterval = setInterval(async () => {
try {
await refreshAccessToken();
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
}, 14 * 60 * 1000); // 14 minutes
return () => clearInterval(refreshInterval);
}, [isAuthenticated]);
const fetchCurrentUser = async () => {
const response = await api.get('/api/users/me/');
return response.data;
};
const login = async (username, password) => {
try {
// Get tokens
const response = await api.post('/api/token/', { username, password });
const { access, refresh } = response.data;
// Store tokens
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
// Fetch user data
const userData = await fetchCurrentUser();
setCurrentUser(userData);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
console.error('Login failed:', error);
return {
success: false,
error: error.response?.data?.detail || 'Login failed. Please check your credentials.'
};
}
};
const logout = () => {
clearTokens();
setCurrentUser(null);
setIsAuthenticated(false);
};
const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post('/api/token/refresh/', {
refresh: refreshToken
});
const { access } = response.data;
localStorage.setItem('accessToken', access);
return access;
};
const clearTokens = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
};
const changePassword = async (oldPassword, newPassword) => {
try {
await api.post('/api/users/change-password/', {
old_password: oldPassword,
new_password: newPassword
});
return { success: true };
} catch (error) {
console.error('Password change failed:', error);
return {
success: false,
error: error.response?.data || 'Password change failed.'
};
}
};
const value = {
currentUser,
isAuthenticated,
isLoading,
login,
logout,
refreshAccessToken,
changePassword,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

@ -0,0 +1,452 @@
import { createContext, useContext, useState, useEffect, useRef } from 'react';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const NomenclatureContext = createContext();
// Centralized nomenclature configuration
const NOMENCLATURES_CONFIG = [
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
// { key: 'cargos', jsonKey: 'cargos', sortBy: 'name', endpoint: '/cargos/api/list/', sseObjectType: 'cargo' },
// { key: 'groups', jsonKey: 'groups', sortBy: 'id', endpoint: '/groups/api/list/', sseObjectType: 'group' },
// { key: 'groupSmalls', jsonKey: 'groupsmalls', sortBy: 'id', endpoint: '/groups/api/getgroupsmalls/', sseObjectType: 'groupsmalls' },
// { key: 'tariffs', jsonKey: 'tariffs', sortBy: 'id', endpoint: '/tariff/api/list/', sseObjectType: 'tariff' },
// { key: 'tariffSmalls', jsonKey: 'tariffsmalls', sortBy: 'id', endpoint: '/tariff/api/tariffsmalls', sseObjectType: 'tariffsmalls' },
// { key: 'countries', jsonKey: 'countries', sortBy: 'name', endpoint: '/countries/api/list', sseObjectType: 'country' },
// { key: 'passThroughs', jsonKey: 'passthroughs', sortBy: 'id', endpoint: '/tariff/api/getpassthroughs/', sseObjectType: 'passthrough' },
// { key: 'vats', jsonKey: 'vats', sortBy: 'id', endpoint: '/tariff/api/getvats/', sseObjectType: 'vat' },
// { key: 'maneuvers', jsonKey: 'maneuvers', sortBy: 'id', endpoint: '/common/api/maneuvers/', sseObjectType: 'maneuver' },
// { key: 'prices', jsonKey: 'prices', sortBy: 'id', endpoint: '/tariff/api/prices/', sseObjectType: 'prices' },
// { key: 'bankAccounts', jsonKey: 'bank_accounts', sortBy: 'id', endpoint: '/common/api/bank-accounts/', sseObjectType: 'bank_account' },
// { key: 'noticeNotes', jsonKey: 'notice_notes', sortBy: 'name', endpoint: '/invoices/api/notice-notes/list/', sseObjectType: 'notice_note' },
// { key: 'translations', jsonKey: 'translations', sortBy: 'name', endpoint: '/common/api/translations/', sseObjectType: 'translation' }
];
// Helper function to get config for a specific key
const getConfigForKey = (key) => NOMENCLATURES_CONFIG.find(c => c.key === key);
// Helper function to get nomenclature key from SSE object type
const getNomenclatureKeyFromSSE = (sseObjectType) => {
const config = NOMENCLATURES_CONFIG.find(c => c.sseObjectType === sseObjectType);
return config?.key;
};
export const useNomenclatures = () => {
const context = useContext(NomenclatureContext);
if (!context) {
throw new Error('useNomenclatures must be used within a NomenclatureProvider');
}
return context;
};
// Utility function to sort nomenclature array based on configuration
const sortNomenclature = (array, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
return [...array].sort((a, b) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
} else {
// Order by name (case-insensitive)
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
}
});
};
// Utility function to insert item in sorted position
const insertSorted = (array, newItem, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
const newArray = [...array];
// Find the correct insertion position
let insertIndex = newArray.length;
if (orderBy === 'id') {
insertIndex = newArray.findIndex(item => {
const comparison = item.id > newItem.id;
return comparison;
});
if (insertIndex === -1) insertIndex = newArray.length;
} else {
const newName = (newItem.name || '').toLowerCase();
insertIndex = newArray.findIndex(item =>
(item.name || '').toLowerCase() > newName
);
if (insertIndex === -1) insertIndex = newArray.length;
}
newArray.splice(insertIndex, 0, newItem);
return newArray;
};
// Utility function to update item and maintain sorted order
const updateSorted = (array, updatedItem, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
// Remove the old item
const filteredArray = array.filter(item => item.id !== updatedItem.id);
// Check if the ordering field has changed
const oldItem = array.find(item => item.id === updatedItem.id);
if (!oldItem) {
// Item not found, just insert it
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
}
const orderFieldChanged = orderBy === 'id'
? oldItem.id !== updatedItem.id
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
if (orderFieldChanged) {
// Re-insert in sorted position if the ordering field changed
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
} else {
// Just update in place if ordering hasn't changed
return array.map(item =>
item.id === updatedItem.id ? { ...item, ...updatedItem } : item
);
}
};
// Generate initial state objects from config
const generateInitialState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = [];
return acc;
}, {});
const generateInitialLoadingState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = false;
return acc;
}, {});
const generateInitialErrorState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = null;
return acc;
}, {});
export const NomenclatureProvider = ({ children }) => {
// Ref to track invoice update subscribers
const subscribersRef = useRef(new Set());
const [nomenclatures, setNomenclatures] = useState(generateInitialState());
const [loading, setLoading] = useState(generateInitialLoadingState());
const [error, setError] = useState(generateInitialErrorState());
const [allLoaded, setAllLoaded] = useState(false);
const [hasStartedLoading, setHasStartedLoading] = useState(false);
// Generic fetch function
const fetchNomenclature = async (key, url) => {
const startTime = performance.now();
try {
const token = localStorage.getItem('accessToken');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
headers,
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Failed to fetch ${key}: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && typeof data === 'object') {
}
// Normalize data format - adjust based on your API response structure
let normalizedData;
if (Array.isArray(data)) {
normalizedData = data;
} else if (data && typeof data === 'object') {
// Handle different response formats
const config = getConfigForKey(key);
const jsonKey = config?.jsonKey; // Get the correct JSON key from config
normalizedData = data.results || data.data || data[jsonKey] || data[key] || [];
} else {
normalizedData = [];
}
// Sort the data according to configured order
const sortedData = sortNomenclature(normalizedData, key);
setNomenclatures(prev => ({
...prev,
[key]: sortedData
}));
setError(prev => ({
...prev,
[key]: null
}));
} catch (err) {
const totalTime = performance.now() - startTime;
console.error(`❌ [${key}] Failed after ${totalTime.toFixed(2)}ms:`, err.message);
setError(prev => ({
...prev,
[key]: err.message
}));
} finally {
setLoading(prev => ({
...prev,
[key]: false
}));
}
};
// Load all nomenclatures on mount, but only if we have auth
useEffect(() => {
const loadAllNomenclatures = async () => {
// Check if we have an auth token
const token = localStorage.getItem('accessToken');
if (!token) {
return;
}
setHasStartedLoading(true);
// Set all loading states to true
const allLoadingState = NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = true;
return acc;
}, {});
setLoading(allLoadingState);
// Load all nomenclatures in parallel
const promises = NOMENCLATURES_CONFIG.map(config =>
fetchNomenclature(config.key, API_BASE_URL + config.endpoint)
);
await Promise.allSettled(promises);
/*
// Wait a tick for state to update, then apply translations
setTimeout(async () => {
try {
// Fetch translations directly from API to ensure we have the latest data
const token = localStorage.getItem('authToken');
const translationsConfig = getConfigForKey('translations');
const response = await fetch(translationsConfig.endpoint, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${token}`
},
credentials: 'include'
});
if (response.ok) {
const translationsData = await response.json();
if (translationsData && translationsData.length > 0) {
// Dynamic import to avoid circular dependency
const module = await import('@utils/printTranslations');
const { translationService } = await import('@services/translationService');
const merged = translationService.applyOverrides(
module.printTranslations,
translationsData
);
Object.assign(module.printTranslations, merged);
console.log('✅ Translations initialized:', translationsData.length, 'translations loaded');
}
}
} catch (error) {
console.error('Failed to apply translation overrides:', error);
}
}, 100);
*/
setAllLoaded(true);
};
loadAllNomenclatures();
// Also listen for auth token changes
const handleStorageChange = (e) => {
if (e.key === 'accessToken' && e.newValue && !hasStartedLoading) {
loadAllNomenclatures();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [hasStartedLoading]);
// SSE connection for real-time updates with auto-reconnect
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (!token) {
return; // Don't connect if not authenticated
}
let eventSource;
let reconnectTimeout;
let isIntentionallyClosed = false;
const connect = () => {
if (isIntentionallyClosed) return;
eventSource = new EventSource(`${API_BASE_URL}/sse-connect/?token=${token}`);
eventSource.onopen = () => {
// console.log('SSE connection opened');
};
eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleSSEUpdate(message);
} catch (error) {
console.error('Error parsing SSE message:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
eventSource.close();
// Reconnect after 5 seconds
if (!isIntentionallyClosed) {
console.log('SSE will reconnect in 5 seconds...');
reconnectTimeout = setTimeout(connect, 5000);
}
};
};
// Start initial connection
connect();
// Cleanup on unmount
return () => {
isIntentionallyClosed = true;
if (reconnectTimeout) clearTimeout(reconnectTimeout);
if (eventSource) eventSource.close();
};
}, []);
// Function to subscribe to invoice updates
const subscribeToUpdates = (callback) => {
subscribersRef.current.add(callback);
// Return unsubscribe function
return () => {
subscribersRef.current.delete(callback);
};
};
// Handle SSE updates
const handleSSEUpdate = (message) => {
if (message.type !== 'data_update') {
return; // Only handle data_update messages
}
const { object, operation, data } = message;
// Handle invoice updates - notify all subscribers
if (object === 'invoice' || object === 'notice') {
subscribersRef.current.forEach(callback => {
try {
callback(message);
} catch (error) {
console.error('Error in invoice update subscriber:', error);
}
});
return;
}
// Map object types to nomenclature keys using config
const nomenclatureKey = getNomenclatureKeyFromSSE(object);
if (!nomenclatureKey) {
console.warn('Unknown object type in SSE update:', object);
return;
}
// Special handling for translations: update printTranslations in real-time
// if (nomenclatureKey === 'translations' && (operation === 'insert' || operation === 'update')) {
// import('@utils/printTranslations').then(async (module) => {
// const key = data.key;
// if (module.printTranslations[key]) {
// module.printTranslations[key] = {
// bg: data.text_bg,
// en: data.text_en
// };
// }
// }).catch(error => {
// console.error('Failed to update printTranslations from SSE:', error);
// });
// }
setNomenclatures(prev => {
const currentArray = prev[nomenclatureKey] || [];
if (operation === 'insert') {
// Add new item if it doesn't exist
const exists = currentArray.some(item => item.id === data.id);
if (!exists) {
// Insert in sorted position
const sortedArray = insertSorted(currentArray, data, nomenclatureKey);
return {
...prev,
[nomenclatureKey]: sortedArray
};
}
return prev;
} else if (operation === 'update') {
// Update existing item and maintain sort order
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
return {
...prev,
[nomenclatureKey]: updatedArray
};
}
return prev;
});
};
const value = {
// Spread all nomenclature data
...nomenclatures,
// Loading states
loading,
allLoaded,
// Error states
error,
// Utility functions
subscribeToUpdates,
// State helpers
hasStartedLoading,
// Helper to check if any is loading
isAnyLoading: Object.values(loading).some(Boolean),
// Helper to check if any has errors
hasErrors: Object.values(error).some(Boolean)
};
return (
<NomenclatureContext.Provider value={value}>
{children}
</NomenclatureContext.Provider>
);
};

@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
function useSerialData() {
const [readings, setReadings] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const eventSource = new EventSource('http://localhost:5000/events');
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
console.log('Connected to serial bridge SSE');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === 'connected') {
console.log('SSE:', data.message);
} else {
// Add new reading to the beginning
setReadings(prev => [data, ...prev].slice(0, 100)); // Keep last 100
console.log('Received:', data);
}
} catch (err) {
console.error('Error parsing SSE data:', err);
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
setIsConnected(false);
setError('Failed to connect to serial bridge');
eventSource.close();
};
return () => {
eventSource.close();
};
}, []);
return { readings, isConnected, error };
}
export default useSerialData;

@ -0,0 +1,18 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,106 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: Add JWT token to all requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor: Handle 401 errors with token refresh
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
response => response,
async (error) => {
const originalRequest = error.config;
// If error is 401 and we haven't tried refreshing yet
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue this request while refresh is in progress
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// No refresh token, redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
try {
// Attempt to refresh the token
const response = await axios.post(`${API_BASE_URL}/api/token/refresh/`, {
refresh: refreshToken
});
const { access } = response.data;
// Update stored token
localStorage.setItem('accessToken', access);
// Update authorization header
api.defaults.headers.common['Authorization'] = `Bearer ${access}`;
originalRequest.headers.Authorization = `Bearer ${access}`;
// Process queued requests
processQueue(null, access);
isRefreshing = false;
// Retry original request
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
processQueue(refreshError, null);
isRefreshing = false;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default api;

@ -0,0 +1 @@
/c/dev_projects/ScalesApp/frontend

@ -0,0 +1,22 @@
# COM Port Settings
COM_PORT=COM3
BAUD_RATE=9600
TIMEOUT=1
READ_INTERVAL=0.5
# Backend Server Settings
BACKEND_URL=http://localhost:8000
API_ENDPOINT=/api/readings/
REQUEST_TIMEOUT=5
# Application Settings
DEBUG=False
LOG_LEVEL=INFO
# System Tray Settings
SHOW_WINDOW_ON_START=False
AUTO_CONNECT=True
# Retry Settings
MAX_RETRIES=3
RETRY_DELAY=5

@ -0,0 +1,71 @@
# Serial Bridge - COM Port Reader
This is the serial port reading application that runs as a system tray service.
## Features
- ✅ Reads data from COM ports
- ✅ Runs in system tray (no console window)
- ✅ Automatically posts data to Django backend
- ✅ Retry logic for failed posts
- ✅ Backend health checks
- ✅ Configurable via environment variables
- ✅ Can be packaged as a Windows .exe
## Setup
1. **Install Python dependencies:**
```bash
pip install -r requirements.txt
```
2. **Configure environment:**
```bash
copy .env.example .env
# Edit .env with your settings
```
3. **Run the application:**
```bash
python app.py
```
## Building as EXE
To create a standalone Windows executable:
```bash
pip install pyinstaller
pyinstaller serial_bridge.spec
```
The executable will be created in the `dist\ScalesApp\` folder.
## Configuration
Edit `.env` to configure:
- `COM_PORT`: COM port to read from (default: COM1)
- `BAUD_RATE`: Serial port baud rate (default: 9600)
- `BACKEND_URL`: Django backend URL (default: http://localhost:8000)
- `AUTO_CONNECT`: Automatically connect on startup (default: True)
- `DEBUG`: Enable debug logging (default: False)
## Architecture
```
COM Port → Serial Reader → Backend Client → Django API
System Tray Icon
```
## Logging
Logs are written to `serial_bridge.log` and console output.
## Troubleshooting
- **COM port not found**: Check `COM_PORT` setting and device connection
- **Cannot connect to backend**: Verify Django server is running on `BACKEND_URL`
- **Data not posting**: Check logs in `serial_bridge.log`
- **Tray icon not appearing**: Run as administrator, check Windows settings

@ -0,0 +1,231 @@
"""
Serial Bridge - SSE Server
Reads from COM port and streams data to React frontend via Server-Sent Events
No backend storage - direct streaming only
"""
import os
import logging
import serial
import serial.tools.list_ports
from dotenv import load_dotenv
from threading import Thread
from datetime import datetime
from flask import Flask, Response
from flask_cors import CORS, cross_origin
from queue import Queue
import json
import time
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=os.getenv('LOG_LEVEL', 'INFO'),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('serial_bridge.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Global state
data_queue = Queue()
connected_clients = []
is_running = False
reader_thread = None
class SerialReader:
def __init__(self, port, baudrate, timeout):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.ser = None
self.is_connected = False
def connect(self):
try:
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
self.is_connected = True
logger.info(f"[OK] Connected to {self.port} at {self.baudrate} baud")
return True
except Exception as e:
logger.error(f"[ERROR] Failed to connect to {self.port}: {e}")
self.is_connected = False
return False
def read_data(self):
if not self.is_connected:
return None
try:
if self.ser.in_waiting:
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line:
return {
'port': self.port,
'data': line,
'timestamp': datetime.now().isoformat(),
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
except Exception as e:
logger.error(f"Error reading from {self.port}: {e}")
return None
def disconnect(self):
if self.ser:
self.ser.close()
self.is_connected = False
logger.info(f"[OK] Disconnected from {self.port}")
def read_serial_data():
"""Continuously read from serial port and queue data"""
global is_running
reader = SerialReader(
port=os.getenv('COM_PORT', 'COM3'),
baudrate=int(os.getenv('BAUD_RATE', '9600')),
timeout=float(os.getenv('TIMEOUT', '1'))
)
if not reader.connect():
logger.error("Failed to connect to COM port")
return
read_interval = float(os.getenv('READ_INTERVAL', '0.5'))
while is_running:
try:
data = reader.read_data()
if data:
data_queue.put(data)
logger.info(f"[RX] {data['port']}: {data['data']}")
time.sleep(read_interval)
except KeyboardInterrupt:
logger.info("Serial reader interrupted")
break
except Exception as e:
logger.error(f"Error in serial reader: {e}")
reader.disconnect()
@app.route('/events')
@cross_origin()
def events():
"""SSE endpoint - streams serial data to connected clients"""
def generate():
client_id = id(object())
connected_clients.append(client_id)
logger.info(f"[CONNECT] Client connected. Total: {len(connected_clients)}")
try:
# Send connection message
yield f"data: {json.dumps({'status': 'connected', 'message': 'Connected to serial bridge'})}\n\n"
# Stream data from queue
while is_running:
try:
# Get data with timeout to allow graceful shutdown
data = data_queue.get(timeout=1)
yield f"data: {json.dumps(data)}\n\n"
logger.debug(f"[TX] Sent: {data['data']}")
except:
# Queue timeout - send heartbeat
yield f": heartbeat\n\n"
except GeneratorExit:
# Client disconnected
pass
finally:
if client_id in connected_clients:
connected_clients.remove(client_id)
logger.info(f"[DISCONNECT] Client disconnected. Total: {len(connected_clients)}")
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
}
)
@app.route('/status')
def status():
"""Get current status"""
return {
'status': 'running' if is_running else 'stopped',
'connected_clients': len(connected_clients),
'com_port': os.getenv('COM_PORT', 'COM3'),
'baud_rate': int(os.getenv('BAUD_RATE', '9600'))
}
@app.route('/ports')
def list_ports():
"""List available COM ports"""
ports = []
for port, desc, hwid in serial.tools.list_ports.comports():
ports.append({
'port': port,
'description': desc,
'hwid': hwid
})
return {'ports': ports}
def start_app():
"""Start the application"""
global is_running, reader_thread
logger.info("=" * 60)
logger.info("ScalesApp Serial Bridge - SSE Streaming")
logger.info("=" * 60)
logger.info(f"COM Port: {os.getenv('COM_PORT', 'COM3')}")
logger.info(f"Baud Rate: {os.getenv('BAUD_RATE', '9600')}")
logger.info(f"SSE Endpoint: http://127.0.0.1:5000/events")
logger.info("=" * 60)
is_running = True
# Start serial reader thread
reader_thread = Thread(target=read_serial_data, daemon=True)
reader_thread.start()
logger.info("[OK] Serial reader thread started\n")
# Start Flask app
debug = os.getenv('DEBUG', 'False').lower() == 'true'
app.run(host='127.0.0.1', port=5000, debug=debug, use_reloader=False)
def stop_app():
"""Stop the application"""
global is_running
is_running = False
logger.info("\n[OK] Application stopped")
if __name__ == '__main__':
try:
start_app()
except KeyboardInterrupt:
stop_app()

@ -0,0 +1,117 @@
"""
Backend API Client
Handles communication with Django backend
"""
import requests
import logging
from typing import Optional, Dict, Any
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
class BackendClient:
def __init__(self, base_url: str, timeout: int = 5, max_retries: int = 3):
self.base_url = base_url
self.timeout = timeout
self.max_retries = max_retries
self.session = self._create_session()
def _create_session(self) -> requests.Session:
"""Create a requests session with retry logic"""
session = requests.Session()
retry_strategy = Retry(
total=self.max_retries,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
def post_reading(self, port: str, data: str) -> bool:
"""Post a serial port reading to the backend"""
try:
url = f"{self.base_url}/api/readings/"
payload = {
'port': port,
'data': data
}
response = self.session.post(
url,
json=payload,
timeout=self.timeout
)
if response.status_code in [200, 201]:
logger.info(f"Successfully posted reading to backend")
return True
else:
logger.warning(f"Backend returned status {response.status_code}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Error posting reading to backend: {e}")
return False
def get_latest_reading(self, port: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get the latest reading from backend"""
try:
url = f"{self.base_url}/api/readings/latest/"
params = {'port': port} if port else {}
response = self.session.get(
url,
params=params,
timeout=self.timeout
)
if response.status_code == 200:
return response.json()
else:
logger.warning(f"Backend returned status {response.status_code}")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Error getting latest reading: {e}")
return None
def get_readings(self, port: Optional[str] = None, limit: int = 10) -> Optional[list]:
"""Get readings from backend"""
try:
url = f"{self.base_url}/api/readings/"
params = {'limit': limit}
if port:
params['port'] = port
response = self.session.get(
url,
params=params,
timeout=self.timeout
)
if response.status_code == 200:
return response.json()
else:
logger.warning(f"Backend returned status {response.status_code}")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Error getting readings: {e}")
return None
def health_check(self) -> bool:
"""Check if backend is available"""
try:
url = f"{self.base_url}/api/health/"
response = self.session.get(url, timeout=self.timeout)
return response.status_code == 200
except Exception as e:
logger.error(f"Backend health check failed: {e}")
return False

@ -0,0 +1,31 @@
"""
Configuration for Serial Bridge
"""
import os
from dotenv import load_dotenv
load_dotenv()
# COM Port Settings
COM_PORT = os.getenv('COM_PORT', 'COM1')
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
TIMEOUT = int(os.getenv('TIMEOUT', 1))
READ_INTERVAL = float(os.getenv('READ_INTERVAL', 0.5)) # seconds
# Backend Server Settings
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8000')
API_ENDPOINT = os.getenv('API_ENDPOINT', '/api/readings/')
REQUEST_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', 5))
# Application Settings
APP_NAME = 'ScalesApp Serial Bridge'
DEBUG = os.getenv('DEBUG', 'False') == 'True'
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
# System Tray Settings
SHOW_WINDOW_ON_START = os.getenv('SHOW_WINDOW_ON_START', 'False') == 'True'
AUTO_CONNECT = os.getenv('AUTO_CONNECT', 'True') == 'True'
# Retry Settings
MAX_RETRIES = int(os.getenv('MAX_RETRIES', 3))
RETRY_DELAY = int(os.getenv('RETRY_DELAY', 5)) # seconds

@ -0,0 +1,9 @@
Pillow==12.1.0
pyserial==3.5
python-dotenv==1.0.0
flask==3.1.0
requests==2.32.5
urllib3==2.6.3
pystray==0.19.5
psutil==7.2.1
PyInstaller==6.17.0

@ -0,0 +1,57 @@
"""
PyInstaller spec file for creating a Windows executable
Run: pyinstaller serial_bridge.spec
"""
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['serial', 'requests', 'pystray', 'PIL'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludedimports=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='ScalesApp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # No console window
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None, # You can add an icon path here
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='ScalesApp',
)

@ -0,0 +1,110 @@
"""
Serial Port Reader
Handles reading data from COM ports
"""
import serial
import threading
import logging
from typing import Callable, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class SerialPortReader:
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.serial_conn: Optional[serial.Serial] = None
self.is_connected = False
self.reader_thread: Optional[threading.Thread] = None
self.is_running = False
self.data_callback: Optional[Callable] = None
def connect(self) -> bool:
"""Connect to the serial port"""
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
self.is_connected = True
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
return True
except serial.SerialException as e:
logger.error(f"Failed to connect to {self.port}: {e}")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the serial port"""
self.is_running = False
if self.reader_thread:
self.reader_thread.join(timeout=2)
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
self.is_connected = False
logger.info(f"Disconnected from {self.port}")
def set_data_callback(self, callback: Callable):
"""Set callback function for received data"""
self.data_callback = callback
def start_reading(self):
"""Start reading from serial port in a background thread"""
if not self.is_connected:
if not self.connect():
return
self.is_running = True
self.reader_thread = threading.Thread(target=self._read_loop, daemon=True)
self.reader_thread.start()
logger.info("Serial reader started")
def stop_reading(self):
"""Stop reading from serial port"""
self.is_running = False
if self.reader_thread:
self.reader_thread.join(timeout=2)
logger.info("Serial reader stopped")
def _read_loop(self):
"""Main reading loop"""
while self.is_running and self.is_connected:
try:
if self.serial_conn and self.serial_conn.in_waiting > 0:
data = self.serial_conn.readline().decode('utf-8', errors='ignore').strip()
if data and self.data_callback:
self.data_callback({
'port': self.port,
'data': data,
'timestamp': datetime.now().isoformat()
})
except Exception as e:
logger.error(f"Error reading from {self.port}: {e}")
self.is_connected = False
break
def write_data(self, data: str) -> bool:
"""Write data to the serial port"""
try:
if self.is_connected and self.serial_conn:
if isinstance(data, str):
data = data.encode('utf-8')
self.serial_conn.write(data)
return True
except Exception as e:
logger.error(f"Error writing to {self.port}: {e}")
return False
def get_status(self) -> dict:
"""Get current status"""
return {
'port': self.port,
'connected': self.is_connected,
'running': self.is_running,
'baudrate': self.baudrate
}

@ -0,0 +1,82 @@
"""
System Tray Icon Handler
Manages the application in the system tray
"""
import pystray
import logging
from typing import Callable, Optional
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
class SystemTrayIcon:
def __init__(self, app_name: str):
self.app_name = app_name
self.icon: Optional[pystray.Icon] = None
self.status_text = "Initializing..."
self.on_quit: Optional[Callable] = None
self.on_show: Optional[Callable] = None
def _create_image(self, status: str = "ready") -> Image.Image:
"""Create a simple tray icon image"""
size = 64
color_map = {
'ready': (0, 200, 0),
'running': (0, 150, 200),
'error': (200, 0, 0),
'waiting': (200, 150, 0),
}
color = color_map.get(status, (128, 128, 128))
image = Image.new('RGB', (size, size), color)
draw = ImageDraw.Draw(image)
draw.rectangle([0, 0, size-1, size-1], outline=(0, 0, 0), width=2)
return image
def set_status(self, status: str, text: str):
"""Update the tray icon status"""
self.status_text = text
if self.icon:
self.icon.update_menu()
def create_menu(self) -> pystray.Menu:
"""Create the context menu for the tray icon"""
menu_items = [
pystray.MenuItem(f"📊 {self.app_name}", action=None),
pystray.MenuItem("" * 30, action=None),
pystray.MenuItem("Status: " + self.status_text, action=None),
pystray.MenuItem("" * 30, action=None),
pystray.MenuItem("Quit", self._on_quit_click),
]
return pystray.Menu(*menu_items)
def _on_quit_click(self, icon, item):
"""Handle quit click"""
if self.on_quit:
self.on_quit()
if self.icon:
self.icon.stop()
def run(self):
"""Run the tray icon"""
try:
self.icon = pystray.Icon(
name=self.app_name,
icon=self._create_image('running'),
menu=self.create_menu(),
title=self.app_name
)
self.icon.run()
except Exception as e:
logger.error(f"Error running tray icon: {e}")
def stop(self):
"""Stop the tray icon"""
if self.icon:
self.icon.stop()
def set_callbacks(self, on_quit: Optional[Callable] = None, on_show: Optional[Callable] = None):
"""Set callback functions"""
self.on_quit = on_quit
self.on_show = on_show

@ -0,0 +1,16 @@
# COM Port Test Writer configuration
COM_PORT=COM1
BAUD_RATE=9600
TIMEOUT=1
# Test Data Settings
WRITE_INTERVAL=1.0
TEST_DATA_TYPE=scales
# Scales data settings
SCALES_MIN=0
SCALES_MAX=100
SCALES_INCREMENT=0.5
# Debug
DEBUG=True

@ -0,0 +1,165 @@
# COM Port Test Writer - Simulates device sending data
This utility simulates a physical device (like scales) by continuously writing test data to a COM port.
## Features
- ✅ Writes test data continuously to COM port
- ✅ Multiple data types: scales, counter, random, mixed
- ✅ Configurable write interval
- ✅ Easy start/stop with Ctrl+C
- ✅ List available COM ports
- ✅ Command-line arguments
## Setup
1. **Install dependencies:**
```bash
pip install -r requirements.txt
```
2. **Configure environment (optional):**
```bash
copy .env.example .env
# Edit .env with your settings
```
## Usage
### List Available COM Ports
```bash
python test_writer.py --list
```
### Basic Usage (using configured port)
```bash
python test_writer.py
```
### Specify COM Port
```bash
python test_writer.py --port COM3
```
### Different Data Types
**Scales Data (default)** - Simulates scale readings
```bash
python test_writer.py --type scales
```
**Counter Data** - Incrementing numbers
```bash
python test_writer.py --type counter
```
**Random Data** - Random numeric values
```bash
python test_writer.py --type random
```
**Mixed Sensor Data** - Temperature, humidity, pressure
```bash
python test_writer.py --type mixed
```
### Custom Write Interval
```bash
python test_writer.py --interval 2.0 # Send every 2 seconds
python test_writer.py --interval 0.5 # Send every 0.5 seconds
```
### Custom Baud Rate
```bash
python test_writer.py --baud 115200
```
## Testing Workflow
1. **Start Serial Bridge** (reads COM port)
```bash
cd serial_bridge
python app.py
```
2. **Start Test Writer** (writes test data)
```bash
cd test_comport_writer
python test_writer.py --type scales --interval 1.0
```
3. **View in React Frontend**
- Open http://localhost:3000
- Data should appear in real-time
## Configuration
Edit `.env` for default settings:
```env
COM_PORT=COM1
BAUD_RATE=9600
WRITE_INTERVAL=1.0
TEST_DATA_TYPE=scales
SCALES_MIN=0
SCALES_MAX=100
```
## Example Output
```
==================================================
COM Port Test Writer
==================================================
Port: COM1
Baud Rate: 9600
Data Type: scales
Write Interval: 1.0s
==================================================
✓ Connected to COM1 at 9600 baud
Press Ctrl+C to stop writing data
→ Sent: 45.23 kg
→ Sent: 45.67 kg
→ Sent: 46.12 kg
→ Sent: 46.45 kg
...
(Press Ctrl+C to stop)
✓ Disconnected from COM1
✓ Test writer stopped
```
## Troubleshooting
- **"COM port not found"**: Check `COM_PORT` setting and ensure port exists
- **"Port already in use"**: Another application is using the port (close Serial Bridge temporarily)
- **No data appearing**: Ensure Serial Bridge is running and pointing to same COM port
- **Baud rate mismatch**: Ensure test writer and serial bridge use same baud rate
## Complete System Test
To test the complete flow:
```bash
# Terminal 1: Django Backend
cd backend
venv\Scripts\activate
python manage.py runserver
# Terminal 2: React Frontend
cd frontend
npm start
# Terminal 3: Serial Bridge
cd serial_bridge
venv\Scripts\activate
python app.py
# Terminal 4: Test Writer
cd test_comport_writer
python test_writer.py --type scales --interval 1.0
```
Then visit http://localhost:3000 to see real-time data flow.

@ -0,0 +1,24 @@
"""
Configuration for COM Port Test Writer
"""
import os
from dotenv import load_dotenv
load_dotenv()
# COM Port Settings
COM_PORT = os.getenv('COM_PORT', 'COM4')
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
TIMEOUT = int(os.getenv('TIMEOUT', 1))
# Test Data Settings
WRITE_INTERVAL = float(os.getenv('WRITE_INTERVAL', 1.0)) # seconds between writes
TEST_DATA_TYPE = os.getenv('TEST_DATA_TYPE', 'scales') # 'scales', 'counter', 'random'
# Scales data
SCALES_MIN = int(os.getenv('SCALES_MIN', 5000))
SCALES_MAX = int(os.getenv('SCALES_MAX', 20000))
SCALES_INCREMENT = float(os.getenv('SCALES_INCREMENT', 5.0))
# Application Settings
DEBUG = os.getenv('DEBUG', 'True') == 'True'

@ -0,0 +1,2 @@
pyserial==3.5
python-dotenv==1.0.0

@ -0,0 +1,215 @@
"""
COM Port Test Writer - Simulates data being sent to a COM port
"""
import serial
import time
import random
import logging
import sys
from typing import Optional
from config import (
COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL,
TEST_DATA_TYPE, SCALES_MIN, SCALES_MAX, SCALES_INCREMENT, DEBUG
)
# Setup logging
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
# format='%(asctime)s - %(levelname)s - %(message)s'
format='%(message)s'
)
logger = logging.getLogger(__name__)
class ComPortTestWriter:
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.serial_conn: Optional[serial.Serial] = None
self.is_connected = False
self.is_running = False
self.data_counter = 0
self.current_scales_value = SCALES_MIN
def connect(self) -> bool:
"""Connect to the serial port"""
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
self.is_connected = True
logger.info(f"✓ Connected to {self.port} at {self.baudrate} baud")
return True
except serial.SerialException as e:
logger.error(f"✗ Failed to connect to {self.port}: {e}")
logger.info("\nTip: Check that the COM port exists and is not in use.")
logger.info("You can list available ports with: python -m serial.tools.list_ports")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the serial port"""
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
self.is_connected = False
logger.info(f"✓ Disconnected from {self.port}")
def write_data(self, data: str) -> bool:
"""Write data to the serial port"""
try:
if self.is_connected and self.serial_conn:
# Add newline if not present
if not data.endswith('\n'):
data += '\n'
self.serial_conn.write(data.encode('utf-8'))
logger.info(f"→ Sent: {data.strip()}")
return True
except Exception as e:
logger.error(f"Error writing to {self.port}: {e}")
self.is_connected = False
return False
def generate_scales_data(self) -> str:
"""Generate simulated scales data"""
# Simulate scales value fluctuating slightly
variation = random.uniform(-0.1, 0.1)
self.current_scales_value += variation
# Keep within bounds
self.current_scales_value = max(SCALES_MIN, min(SCALES_MAX, self.current_scales_value))
return f"{self.current_scales_value:.2f} kg"
def generate_counter_data(self) -> str:
"""Generate incrementing counter data"""
self.data_counter += 1
return f"Count: {self.data_counter}"
def generate_random_data(self) -> str:
"""Generate random numeric data"""
value = random.uniform(10000, 50000)
return f"Value: {value:.2f}"
def generate_mixed_data(self) -> str:
"""Generate mixed sensor data"""
temperature = random.uniform(20, 30)
humidity = random.uniform(30, 70)
pressure = random.uniform(1010, 1020)
return f"T:{temperature:.1f}C H:{humidity:.1f}% P:{pressure:.1f}hPa"
def get_next_data(self) -> str:
"""Get the next data to send based on test type"""
if TEST_DATA_TYPE == 'scales':
return self.generate_scales_data()
elif TEST_DATA_TYPE == 'counter':
return self.generate_counter_data()
elif TEST_DATA_TYPE == 'random':
return self.generate_random_data()
elif TEST_DATA_TYPE == 'mixed':
return self.generate_mixed_data()
else:
return self.generate_scales_data()
def run(self):
"""Run the test writer"""
logger.info(f"\n{'='*50}")
logger.info(f"COM Port Test Writer")
logger.info(f"{'='*50}")
logger.info(f"Port: {self.port}")
logger.info(f"Baud Rate: {self.baudrate}")
logger.info(f"Data Type: {TEST_DATA_TYPE}")
logger.info(f"Write Interval: {WRITE_INTERVAL}s")
logger.info(f"{'='*50}\n")
if not self.connect():
logger.error("Failed to connect to COM port. Exiting.")
return
self.is_running = True
try:
logger.info("Press Ctrl+C to stop writing data\n")
while self.is_running:
try:
data = self.get_next_data()
self.write_data(data)
time.sleep(WRITE_INTERVAL)
except KeyboardInterrupt:
break
except Exception as e:
logger.error(f"Error in write loop: {e}")
break
except KeyboardInterrupt:
pass
finally:
self.is_running = False
self.disconnect()
logger.info("\n✓ Test writer stopped")
def list_available_ports():
"""List available COM ports"""
try:
from serial.tools import list_ports
ports = list_ports.comports()
if ports:
logger.info("\nAvailable COM ports:")
for port in ports:
logger.info(f" {port.device} - {port.description}")
else:
logger.info("No COM ports found")
except Exception as e:
logger.error(f"Error listing ports: {e}")
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description='Test COM port writer - continuously writes data to a COM port'
)
parser.add_argument(
'--port',
default=COM_PORT,
help=f'COM port to write to (default: {COM_PORT})'
)
parser.add_argument(
'--baud',
type=int,
default=BAUD_RATE,
help=f'Baud rate (default: {BAUD_RATE})'
)
parser.add_argument(
'--type',
choices=['scales', 'counter', 'random', 'mixed'],
default=TEST_DATA_TYPE,
help=f'Type of test data (default: {TEST_DATA_TYPE})'
)
parser.add_argument(
'--interval',
type=float,
default=WRITE_INTERVAL,
help=f'Write interval in seconds (default: {WRITE_INTERVAL})'
)
parser.add_argument(
'--list',
action='store_true',
help='List available COM ports and exit'
)
args = parser.parse_args()
if args.list:
list_available_ports()
return
writer = ComPortTestWriter(args.port, args.baud)
writer.run()
if __name__ == '__main__':
main()
Loading…
Cancel
Save