diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..566becf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,58 @@
+# Python
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.env
+*.env
+*.sqlite3
+*.db
+instance/
+
+# Flask
+*.log
+*.pot
+*.mo
+
+# VS Code
+.vscode/
+
+# Node.js
+node_modules/
+package-lock.json
+
+# Frontend build
+/dist/
+/build/
+*.map
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Misc
+*.bak
+*.swp
+*.swo
+
+# Protobuf
+*.pb2.py
+*.pb2.pyi
+
+# Tailwind
+css/tailwind.css
+
+# Logs
+*.log
+logs/
+
+# Others
+*.coverage
+.coverage
+
+# Ignore test output
+*.out
+*.tmp
diff --git a/SwapStation_WebApp.code-workspace b/SwapStation_WebApp.code-workspace
new file mode 100644
index 0000000..876a149
--- /dev/null
+++ b/SwapStation_WebApp.code-workspace
@@ -0,0 +1,8 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..0e38b5e
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,28 @@
+# Use official Python image
+FROM python:3.11-slim
+
+# Set work directory
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements and install
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy backend code
+COPY . .
+
+# Expose Flask port
+EXPOSE 5000
+
+# Set environment variables
+ENV FLASK_APP=main.py
+ENV FLASK_ENV=production
+
+# Start the Flask app (use gunicorn for production)
+CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:5000", "--worker-class", "eventlet", "--workers", "1"]
diff --git a/backend/__pycache__/models.cpython-313.pyc b/backend/__pycache__/models.cpython-313.pyc
index cc15cfc..84f70fd 100644
Binary files a/backend/__pycache__/models.cpython-313.pyc and b/backend/__pycache__/models.cpython-313.pyc differ
diff --git a/backend/core/__pycache__/mqtt_client.cpython-313.pyc b/backend/core/__pycache__/mqtt_client.cpython-313.pyc
index 2b82dd2..6305caf 100644
Binary files a/backend/core/__pycache__/mqtt_client.cpython-313.pyc and b/backend/core/__pycache__/mqtt_client.cpython-313.pyc differ
diff --git a/backend/core/mqtt_client.py b/backend/core/mqtt_client.py
index b459470..5234e4d 100644
--- a/backend/core/mqtt_client.py
+++ b/backend/core/mqtt_client.py
@@ -10,7 +10,6 @@ class MqttClient:
This is a standard Python class, with no GUI dependencies.
"""
def __init__(self, broker, port, user, password, station_id, on_message_callback):
- super().__init__()
self.broker = broker
self.port = port
self.user = user
@@ -18,7 +17,6 @@ class MqttClient:
self.station_id = station_id
self.on_message_callback = on_message_callback
- # Generate a unique client ID to prevent connection conflicts
unique_id = str(uuid.uuid4())
self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}"
@@ -32,26 +30,32 @@ class MqttClient:
if self.user and self.password:
self.client.username_pw_set(self.user, self.password)
- def on_connect(self, client, userdata, flags, rc, properties):
+ self.is_connected = False
+ self.reconnect_delay = 1
+ self.max_reconnect_delay = 60
+ self.stop_thread = False
+
+ # --- CORRECTED CALLBACK SIGNATURES ---
+ def on_connect(self, client, userdata, flags, reason_code, properties):
"""Callback for when the client connects to the broker."""
- if rc == 0:
+ if reason_code == 0:
+ self.is_connected = True
+ self.reconnect_delay = 1
print(f"Successfully connected to MQTT broker for station: {self.station_id}")
- # Subscribe to all topics for this station using a wildcard
topic_base = f"VEC/batterySmartStation/v100/{self.station_id}/#"
+ # topic_base = f"VEC/batterySmartStation/v100/+/+"
self.client.subscribe(topic_base)
print(f"Subscribed to: {topic_base}")
else:
- print(f"Failed to connect to MQTT for station {self.station_id}, return code {rc}")
+ print(f"Failed to connect to MQTT for station {self.station_id}, return code {reason_code}")
- def on_disconnect(self, client, userdata, rc, properties):
+ def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
"""Callback for when the client disconnects."""
print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...")
- # Paho-MQTT's loop_start() handles automatic reconnection.
def on_message(self, client, userdata, msg):
"""Callback for when a message is received from the broker."""
try:
- # Pass the relevant data to the main application's handler
self.on_message_callback(self.station_id, msg.topic, msg.payload)
except Exception as e:
print(f"Error processing message in callback for topic {msg.topic}: {e}")
diff --git a/backend/main.py b/backend/main.py
index e5be621..e5d87fa 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,209 +1,25 @@
-# import os
-# import sys
-# import threading
-# import json
-# import csv
-# import io
-# from datetime import datetime
-# from flask import Flask, jsonify, request, Response
-# from flask_socketio import SocketIO
-# from dotenv import load_dotenv
-
-# # Import your custom core modules and the new models
-# from core.mqtt_client import MqttClient
-# from core.protobuf_decoder import ProtobufDecoder
-# from models import db, Station, User, MqttLog
-
-# # --- Load Environment Variables ---
-# load_dotenv()
-
-# # --- Pre-startup Check for Essential Configuration ---
-# DATABASE_URL = os.getenv("DATABASE_URL")
-# if not DATABASE_URL:
-# print("FATAL ERROR: DATABASE_URL is not set in .env file.")
-# sys.exit(1)
-
-# # --- Application Setup ---
-# app = Flask(__name__)
-# app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
-# app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
-# app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
-# db.init_app(app)
-# socketio = SocketIO(app, cors_allowed_origins="*")
-
-# # --- Global instances ---
-# decoder = ProtobufDecoder()
-# mqtt_clients = {}
-
-# # --- MQTT Message Handling ---
-# def on_message_handler(station_id, topic, payload):
-# """
-# Handles incoming MQTT messages, decodes them, writes to PostgreSQL,
-# and emits to WebSockets.
-# """
-# print(f"Main handler received message for station {station_id} on topic {topic}")
-
-# decoded_data = None
-# message_type = topic.split('/')[-1]
-
-# if message_type == 'PERIODIC':
-# decoded_data = decoder.decode_periodic(payload)
-# elif message_type == 'EVENTS':
-# decoded_data = decoder.decode_event(payload)
-# elif message_type == 'REQUEST':
-# decoded_data = decoder.decode_rpc_request(payload)
-
-# if decoded_data:
-# # 1. Write the data to PostgreSQL for historical storage
-# try:
-# with app.app_context():
-# log_entry = MqttLog(
-# station_id=station_id,
-# topic=topic,
-# payload=decoded_data
-# )
-# db.session.add(log_entry)
-# db.session.commit()
-# print(f"Successfully wrote data for {station_id} to PostgreSQL.")
-# except Exception as e:
-# print(f"Error writing to PostgreSQL: {e}")
-
-# # 2. Emit the data to the frontend for real-time view
-# socketio.emit('dashboard_update', {
-# 'stationId': station_id,
-# 'topic': topic,
-# 'data': decoded_data
-# }, room=station_id)
-
-# # --- (WebSocket and API routes remain the same) ---
-# @socketio.on('connect')
-# def handle_connect():
-# print('Client connected to WebSocket')
-
-# @socketio.on('disconnect')
-# def handle_disconnect():
-# print('Client disconnected')
-
-# @socketio.on('join_station_room')
-# def handle_join_station_room(data):
-# station_id = data.get('station_id')
-# if station_id:
-# from flask import request
-# socketio.join_room(station_id, request.sid)
-
-# @socketio.on('leave_station_room')
-# def handle_leave_station_room(data):
-# station_id = data.get('station_id')
-# if station_id:
-# from flask import request
-# socketio.leave_room(station_id, request.sid)
-
-# @app.route('/api/stations', methods=['GET'])
-# def get_stations():
-# try:
-# stations = Station.query.all()
-# return jsonify([{"id": s.station_id, "name": s.name} for s in stations])
-# except Exception as e:
-# return jsonify({"error": f"Database query failed: {e}"}), 500
-
-# # --- (CSV Export route remains the same) ---
-# @app.route('/api/logs/export', methods=['GET'])
-# def export_logs():
-# # ... (existing implementation)
-# pass
-
-# # --- Main Application Logic (UPDATED) ---
-# def start_mqtt_clients():
-# """
-# Initializes and starts an MQTT client for each station found in the database,
-# using the specific MQTT credentials stored for each station.
-# """
-# try:
-# with app.app_context():
-# # Get the full station objects, not just the IDs
-# stations = Station.query.all()
-# except Exception as e:
-# print(f"CRITICAL: Could not query stations from the database in MQTT thread: {e}")
-# return
-
-# for station in stations:
-# if station.station_id not in mqtt_clients:
-# print(f"Creating and starting MQTT client for station: {station.name} ({station.station_id})")
-
-# # Use the specific details from each station object in the database
-# client = MqttClient(
-# broker=station.mqtt_broker,
-# port=station.mqtt_port,
-# user=station.mqtt_user,
-# password=station.mqtt_password,
-# station_id=station.station_id,
-# on_message_callback=on_message_handler
-# )
-# client.start()
-# mqtt_clients[station.station_id] = client
-
-# if __name__ == '__main__':
-# try:
-# with app.app_context():
-# db.create_all()
-# if not Station.query.first():
-# print("No stations found. Adding a default station with default MQTT config.")
-# # Add a default station with MQTT details for first-time setup
-# default_station = Station(
-# station_id="V16000868210069259709",
-# name="Test Station 2",
-# mqtt_broker="mqtt-dev.upgrid.in",
-# mqtt_port=1883,
-# mqtt_user="guest",
-# mqtt_password="password"
-# )
-# db.session.add(default_station)
-# db.session.commit()
-# except Exception as e:
-# print(f"FATAL ERROR: Could not connect to PostgreSQL: {e}")
-# sys.exit(1)
-
-# mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
-# mqtt_thread.start()
-
-# print(f"Starting Flask-SocketIO server on http://localhost:5000")
-# socketio.run(app, host='0.0.0.0', port=5000)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
import os
import sys
import threading
import json
import csv
import io
+import time
from datetime import datetime
from flask import Flask, jsonify, request, Response
-from flask_socketio import SocketIO
+from flask_socketio import SocketIO, join_room
+from flask_cors import CORS
from dotenv import load_dotenv
# Import your custom core modules and the new models
from core.mqtt_client import MqttClient
from core.protobuf_decoder import ProtobufDecoder
from models import db, Station, User, MqttLog
+from flask_login import login_required, current_user, LoginManager
+from proto.vec_payload_chgSt_pb2 import (
+ rpcRequest,
+ jobType_e
+)
# --- Load Environment Variables ---
load_dotenv()
@@ -216,22 +32,41 @@ if not DATABASE_URL:
# --- Application Setup ---
app = Flask(__name__)
+# CORS(app)
+
+# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True)
+
+CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
+
+# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}})
+# This tells Flask: "For any route starting with /api/, allow requests
+# from the frontend running on http://localhost:5173".
+
+# ADD THESE LINES FOR FLASK-LOGIN
+login_manager = LoginManager()
+login_manager.init_app(app)
+
app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
db.init_app(app)
socketio = SocketIO(app, cors_allowed_origins="*")
+# --- User Loader for Flask-Login ---
+@login_manager.user_loader
+def load_user(user_id):
+ return User.query.get(int(user_id))
+
# --- Global instances ---
decoder = ProtobufDecoder()
mqtt_clients = {}
+last_message_timestamps = {}
+STATION_TIMEOUT_SECONDS = 10
-# --- MQTT Message Handling (UPDATED) ---
+# --- MQTT Message Handling ---
def on_message_handler(station_id, topic, payload):
- """
- Handles incoming MQTT messages, decodes them, writes to PostgreSQL,
- and emits to WebSockets.
- """
+ last_message_timestamps[station_id] = time.time()
+
print(f"Main handler received message for station {station_id} on topic {topic}")
decoded_data = None
@@ -245,13 +80,13 @@ def on_message_handler(station_id, topic, payload):
decoded_data = decoder.decode_rpc_request(payload)
if decoded_data:
- # 1. Write the data to PostgreSQL for historical storage
+ # print("DECODED DATA TO BE SENT:", decoded_data)
try:
with app.app_context():
log_entry = MqttLog(
station_id=station_id,
topic=topic,
- topic_type=message_type, # <-- Save the new topic_type
+ topic_type=message_type,
payload=decoded_data
)
db.session.add(log_entry)
@@ -260,7 +95,6 @@ def on_message_handler(station_id, topic, payload):
except Exception as e:
print(f"Error writing to PostgreSQL: {e}")
- # 2. Emit the data to the frontend for real-time view
socketio.emit('dashboard_update', {
'stationId': station_id,
'topic': topic,
@@ -272,16 +106,127 @@ def on_message_handler(station_id, topic, payload):
def handle_connect():
print('Client connected to WebSocket')
-# ... (other socketio handlers)
+# --- NEW: Function to handle joining a room and sending initial data ---
+@socketio.on('join_station_room')
+def handle_join_station_room(data):
+ station_id = data['station_id']
+ join_room(station_id)
+ print(f"Client joined room for station: {station_id}")
+
+ try:
+ # Find the most recent log entry for this station
+ latest_log = MqttLog.query.filter_by(
+ station_id=station_id,
+ topic_type='PERIODIC'
+ ).order_by(MqttLog.timestamp.desc()).first()
+
+ if latest_log:
+ # If we have a past log, send it immediately to the new client
+ print(f"Sending initial state for {station_id} to new client.")
+ socketio.emit('dashboard_update', {
+ 'stationId': station_id,
+ 'topic': latest_log.topic,
+ 'data': latest_log.payload
+ }, room=station_id)
+ except Exception as e:
+ print(f"Error querying or sending initial state for {station_id}: {e}")
+
+# --- API Routes ---
+@app.route('/api/login', methods=['POST'])
+def login():
+ """Handles user login."""
+ data = request.get_json()
+ if not data or not data.get('username') or not data.get('password'):
+ return jsonify({"message": "Username and password are required."}), 400
+
+ user = User.query.filter_by(username=data['username']).first()
+
+ if user and user.check_password(data['password']):
+ # In a real app, you would create a session token here (e.g., with Flask-Login)
+ return jsonify({"message": "Login successful"}), 200
+
+ return jsonify({"message": "Invalid username or password"}), 401
+
+# --- Admin-only: Add User ---
+@app.route('/api/users', methods=['POST'])
+# @login_required # Ensures the user is logged in
+def add_user():
+ # Check if the logged-in user is an admin
+ # if not current_user.is_admin:
+ # return jsonify({"message": "Admin access required."}), 403 # Forbidden
+
+ data = request.get_json()
+ username = data.get('username')
+ password = data.get('password')
+ is_admin = data.get('is_admin', False)
+
+ if not username or not password:
+ return jsonify({"message": "Username and password are required."}), 400
+ if User.query.filter_by(username=username).first():
+ return jsonify({"message": "Username already exists."}), 409 # Conflict
+
+ new_user = User(username=username, is_admin=is_admin)
+ new_user.set_password(password)
+ db.session.add(new_user)
+ db.session.commit()
+ return jsonify({"message": "User added successfully."}), 201
+
+# --- Admin-only: Add Station ---
+@app.route('/api/stations', methods=['POST'])
+# @login_required # Ensures the user is logged in
+def add_station():
+ # if not current_user.is_admin:
+ # return jsonify({"message": "Admin access required."}), 403
+
+ data = request.get_json()
+ # All fields are now expected from the frontend form
+ required_fields = ['station_id', 'name', 'location', 'mqtt_broker', 'mqtt_port']
+ if not all(field in data for field in required_fields):
+ return jsonify({"message": "Missing required station details."}), 400
+
+ if Station.query.filter_by(station_id=data['station_id']).first():
+ return jsonify({"message": "Station ID already exists."}), 409
+
+ new_station = Station(
+ station_id=data['station_id'],
+ name=data['name'],
+ location=data['location'],
+ mqtt_broker=data['mqtt_broker'],
+ mqtt_port=data['mqtt_port'],
+ mqtt_user=data.get('mqtt_user'),
+ mqtt_password=data.get('mqtt_password')
+ )
+ db.session.add(new_station)
+ db.session.commit()
+
+ # You might want to start the new MQTT client here as well
+ # start_single_mqtt_client(new_station)
+
+ return jsonify({"message": "Station added successfully."}), 201
+
@app.route('/api/stations', methods=['GET'])
def get_stations():
try:
stations = Station.query.all()
- return jsonify([{"id": s.station_id, "name": s.name} for s in stations])
+ station_list = []
+ for s in stations:
+ # --- NEW: More accurate heartbeat logic ---
+ last_msg_time = last_message_timestamps.get(s.station_id)
+ # A station is online only if we have received a message recently
+ is_online = last_msg_time is not None and (time.time() - last_msg_time) < STATION_TIMEOUT_SECONDS
+
+ station_list.append({
+ "id": s.station_id,
+ "name": s.name,
+ "location": s.location,
+ "status": "Online" if is_online else "Offline"
+ })
+ return jsonify(station_list)
except Exception as e:
return jsonify({"error": f"Database query failed: {e}"}), 500
+
# --- CSV Export route (UPDATED) ---
def _format_periodic_row(payload, num_slots=9):
"""
@@ -330,30 +275,47 @@ def _format_periodic_row(payload, num_slots=9):
@app.route('/api/logs/export', methods=['GET'])
def export_logs():
station_id = request.args.get('station_id')
- start_date_str = request.args.get('start_date')
- end_date_str = request.args.get('end_date')
+ start_datetime_str = request.args.get('start_datetime')
+ end_datetime_str = request.args.get('end_datetime')
log_type = request.args.get('log_type', 'PERIODIC')
- if not all([station_id, start_date_str, end_date_str]):
- return jsonify({"error": "Missing required parameters: station_id, start_date, end_date"}), 400
+ if not all([station_id, start_datetime_str, end_datetime_str]):
+ return jsonify({"error": "Missing required parameters"}), 400
try:
- start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
- end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
+ start_datetime = datetime.strptime(start_datetime_str, '%Y-%m-%dT%H:%M')
+ end_datetime = datetime.strptime(end_datetime_str, '%Y-%m-%dT%H:%M')
except ValueError:
- return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
+ return jsonify({"error": "Invalid datetime format"}), 400
- # UPDATED QUERY: Filter by the new 'topic_type' column for better performance
- query = MqttLog.query.filter(
- MqttLog.station_id == station_id,
- MqttLog.timestamp.between(start_date, end_date),
- MqttLog.topic_type == log_type
- )
+ # --- FIX 1: Correctly query for Events & RPC ---
+ if log_type == 'EVENT':
+ # If frontend asks for EVENT, search for both EVENTS and REQUEST in the DB
+ query = MqttLog.query.filter(
+ MqttLog.station_id == station_id,
+ MqttLog.timestamp.between(start_datetime, end_datetime),
+ MqttLog.topic_type.in_(['EVENTS', 'REQUEST'])
+ )
+ else: # Otherwise, query for PERIODIC
+ query = MqttLog.query.filter(
+ MqttLog.station_id == station_id,
+ MqttLog.timestamp.between(start_datetime, end_datetime),
+ MqttLog.topic_type == log_type
+ )
logs = query.order_by(MqttLog.timestamp.asc()).all()
+ if not logs:
+ return jsonify({"message": "No logs found for the selected criteria."}), 404
+
output = io.StringIO()
writer = csv.writer(output)
+
+ # --- FIX 2: Create a cleaner filename ---
+ station = Station.query.filter_by(station_id=station_id).first()
+ station_name = station.name.replace(' ', '_') if station else station_id
+ date_str = start_datetime.strftime('%Y-%m-%d')
+ filename = f"{station_name}_{log_type}_{date_str}.csv"
if log_type == 'PERIODIC':
base_header = ["Timestamp", "DeviceID", "StationDiagnosticCode"]
@@ -378,9 +340,88 @@ def export_logs():
return Response(
output,
mimetype="text/csv",
- headers={"Content-Disposition": f"attachment;filename=logs_{station_id}_{log_type}_{start_date_str}_to_{end_date_str}.csv"}
+ headers={"Content-Disposition": f"attachment;filename={filename}"}
)
+@socketio.on('rpc_request')
+def handle_rpc_request(payload):
+ """
+ Receives a command from the web dashboard, creates a Protobuf RPC request,
+ and publishes it to the station via MQTT.
+ """
+ station_id = payload.get('station_id')
+ command = payload.get('command')
+ data = payload.get('data') # This will be the slot_id or swap_pairs array
+
+ print(f"Received RPC request for station {station_id}: {command} with data {data}")
+
+ # Find the correct MQTT client for this station
+ mqtt_client = mqtt_clients.get(station_id)
+ if not mqtt_client or not mqtt_client.is_connected:
+ print(f"Cannot send RPC for {station_id}: MQTT client not connected.")
+ return # Or emit an error back to the user
+
+ # --- Create the Protobuf message based on the command ---
+ # This is where the logic from your snippet is implemented.
+ request_payload = rpcRequest(
+ ts=int(time.time()),
+ jobId=f"job_{int(time.time())}"
+ )
+
+ # Determine the jobType and set data based on the command string
+ if command == 'OPEN':
+ request_payload.jobType = jobType_e.JOBTYPE_GATE_OPEN_CLOSE
+ request_payload.slotInfo.slotId = data
+ request_payload.slotInfo.state = 1
+
+ elif command == 'CHG_ON':
+ # Replace this with the correct name from your .proto file
+ request_payload.jobType = jobType_e.JOBTYPE_CHARGER_ENABLE_DISABLE
+ request_payload.slotInfo.slotId = data
+ request_payload.slotInfo.state = 1 # State 1 for ON
+
+ elif command == 'CHG_OFF':
+ # Replace this with the correct name from your .proto file
+ request_payload.jobType = jobType_e.JOBTYPE_CHARGER_ENABLE_DISABLE
+ request_payload.slotInfo.slotId = data
+ request_payload.slotInfo.state = 0 # State 0 for OFF
+
+ elif command == 'START_SWAP':
+ # --- THIS IS THE CORRECTED LINE ---
+ request_payload.jobType = jobType_e.JOBTYPE_SWAP_START
+
+ if data and isinstance(data, list):
+ # Your logic for adding the swap pairs to the payload
+ for pair in data:
+ swap_info = request_payload.swapInfo.add()
+ swap_info.fromSlot = pair[0]
+ swap_info.toSlot = pair[1]
+
+ # --- NEW: Added handlers for Abort and Reset ---
+ elif command == 'ABORT_SWAP':
+ request_payload.jobType = jobType_e.JOBTYPE_TRANSACTION_ABORT
+
+ elif command == 'STATION_RESET':
+ request_payload.jobType = jobType_e.JOBTYPE_REBOOT
+
+ elif command == 'LANGUAGE_UPDATE':
+ request_payload.jobType = jobType_e.JOBTYPE_LANGUAGE_UPDATE
+ # Logic to map language string to enum would go here
+
+ else:
+ print(f"Unknown command: {command}")
+ return
+
+ # --- Serialize and Publish the Message ---
+ serialized_payload = request_payload.SerializeToString()
+
+ # Construct the MQTT topic
+ # NOTE: You may need to fetch client_id and version from your database
+ topic = f"VEC/batterySmartStation/v100/{station_id}/RPC/REQUEST"
+
+ print(f"Publishing to {topic}")
+ mqtt_client.client.publish(topic, serialized_payload)
+
# --- Main Application Logic ---
def start_mqtt_clients():
"""
@@ -413,15 +454,24 @@ if __name__ == '__main__':
try:
with app.app_context():
db.create_all()
+ # Add a default user if none exist
+ if not User.query.first():
+ print("No users found. Creating a default admin user.")
+ default_user = User(username='admin')
+ default_user.set_password('password')
+ db.session.add(default_user)
+ db.session.commit()
+
+ # Add a default station if none exist
if not Station.query.first():
- print("No stations found. Adding a default station with default MQTT config.")
+ print("No stations found. Adding a default station.")
default_station = Station(
- station_id="V16000868210069259709",
- name="Test Station 2",
- mqtt_broker="mqtt-dev.upgrid.in",
+ station_id="V16000862287077265957",
+ name="Test Station 1",
+ mqtt_broker="mqtt.vecmocon.com",
mqtt_port=1883,
- mqtt_user="guest",
- mqtt_password="password"
+ mqtt_user="your_username",
+ mqtt_password="your_password"
)
db.session.add(default_station)
db.session.commit()
diff --git a/backend/models.py b/backend/models.py
index 29be586..f9ab56d 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -1,5 +1,6 @@
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.dialects.postgresql import JSONB
+from werkzeug.security import generate_password_hash, check_password_hash
# Create a SQLAlchemy instance. This will be linked to the Flask app in main.py.
db = SQLAlchemy()
@@ -8,28 +9,34 @@ class User(db.Model):
"""Represents a user in the database."""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
- password_hash = db.Column(db.String(120), nullable=False)
+ password_hash = db.Column(db.String(256), nullable=False)
+ is_admin = db.Column(db.Boolean, default=False, nullable=False)
+
+ def set_password(self, password):
+ """Creates a secure hash of the password."""
+ self.password_hash = generate_password_hash(password)
+
+ def check_password(self, password):
+ """Checks if the provided password matches the stored hash."""
+ return check_password_hash(self.password_hash, password)
class Station(db.Model):
+ """Represents a battery swap station in the database."""
id = db.Column(db.Integer, primary_key=True)
station_id = db.Column(db.String(120), unique=True, nullable=False)
name = db.Column(db.String(120), nullable=True)
location = db.Column(db.String(200), nullable=True)
-
- # --- ADD THESE NEW FIELDS ---
mqtt_broker = db.Column(db.String(255), nullable=False)
mqtt_port = db.Column(db.Integer, nullable=False)
mqtt_user = db.Column(db.String(120), nullable=True)
mqtt_password = db.Column(db.String(120), nullable=True)
-# --- Table for MQTT Logs (without raw payload) ---
class MqttLog(db.Model):
"""Represents a single MQTT message payload for historical logging."""
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, server_default=db.func.now())
station_id = db.Column(db.String(120), nullable=False, index=True)
topic = db.Column(db.String(255), nullable=False)
- # --- NEW: Added topic_type for efficient filtering ---
topic_type = db.Column(db.String(50), nullable=False, index=True)
- # JSONB is a highly efficient way to store JSON data in PostgreSQL
payload = db.Column(JSONB)
+
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 56eeba8..e533ff0 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,7 +1,10 @@
Flask
Flask-SocketIO
Flask-SQLAlchemy
+Flask-Cors
+Flask-Login
psycopg2-binary
paho-mqtt
protobuf
-python-dotenv
\ No newline at end of file
+python-dotenv
+Werkzeug
\ No newline at end of file
diff --git a/backend/test.py b/backend/test.py
new file mode 100644
index 0000000..3c5a4f2
--- /dev/null
+++ b/backend/test.py
@@ -0,0 +1,164 @@
+import os
+import sys
+import threading
+import json
+import csv
+import io
+import time # Import the time module
+from datetime import datetime
+from flask import Flask, jsonify, request, Response
+from flask_socketio import SocketIO, join_room # <-- IMPORTANT: Add join_room
+from flask_cors import CORS
+from dotenv import load_dotenv
+
+# Import your custom core modules and the new models
+from core.mqtt_client import MqttClient
+from core.protobuf_decoder import ProtobufDecoder
+from models import db, Station, User, MqttLog
+from flask_login import LoginManager, login_required, current_user
+
+# --- Load Environment Variables ---
+load_dotenv()
+
+# --- Pre-startup Check for Essential Configuration ---
+DATABASE_URL = os.getenv("DATABASE_URL")
+if not DATABASE_URL:
+ print("FATAL ERROR: DATABASE_URL is not set in .env file.")
+ sys.exit(1)
+
+# --- Application Setup ---
+app = Flask(__name__)
+CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True)
+
+login_manager = LoginManager()
+login_manager.init_app(app)
+
+app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
+app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
+db.init_app(app)
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+# --- User Loader for Flask-Login ---
+@login_manager.user_loader
+def load_user(user_id):
+ return User.query.get(int(user_id))
+
+# --- Global instances ---
+decoder = ProtobufDecoder()
+mqtt_clients = {}
+last_message_timestamps = {}
+STATION_TIMEOUT_SECONDS = 90
+
+# --- MQTT Message Handling ---
+def on_message_handler(station_id, topic, payload):
+ last_message_timestamps[station_id] = time.time()
+
+ print(f"Main handler received message for station {station_id} on topic {topic}")
+
+ decoded_data = None
+ message_type = topic.split('/')[-1]
+
+ if message_type == 'PERIODIC':
+ decoded_data = decoder.decode_periodic(payload)
+ elif message_type == 'EVENTS':
+ decoded_data = decoder.decode_event(payload)
+ elif message_type == 'REQUEST':
+ decoded_data = decoder.decode_rpc_request(payload)
+
+ if decoded_data:
+ print("DECODED DATA TO BE SENT:", decoded_data)
+ try:
+ with app.app_context():
+ log_entry = MqttLog(
+ station_id=station_id,
+ topic=topic,
+ topic_type=message_type,
+ payload=decoded_data
+ )
+ db.session.add(log_entry)
+ db.session.commit()
+ print(f"Successfully wrote data for {station_id} to PostgreSQL.")
+ except Exception as e:
+ print(f"Error writing to PostgreSQL: {e}")
+
+ socketio.emit('dashboard_update', {
+ 'stationId': station_id,
+ 'topic': topic,
+ 'data': decoded_data
+ }, room=station_id)
+
+# --- WebSocket Handlers ---
+@socketio.on('connect')
+def handle_connect():
+ print('Client connected to WebSocket')
+
+# --- NEW: Function to handle joining a room and sending initial data ---
+@socketio.on('join_station_room')
+def handle_join_station_room(data):
+ station_id = data['station_id']
+ join_room(station_id)
+ print(f"Client joined room for station: {station_id}")
+
+ try:
+ # Find the most recent log entry for this station
+ latest_log = MqttLog.query.filter_by(
+ station_id=station_id,
+ topic_type='PERIODIC'
+ ).order_by(MqttLog.timestamp.desc()).first()
+
+ if latest_log:
+ # If we have a past log, send it immediately to the new client
+ print(f"Sending initial state for {station_id} to new client.")
+ socketio.emit('dashboard_update', {
+ 'stationId': station_id,
+ 'topic': latest_log.topic,
+ 'data': latest_log.payload
+ }, room=station_id)
+ except Exception as e:
+ print(f"Error querying or sending initial state for {station_id}: {e}")
+
+# ... (rest of your API routes remain the same) ...
+
+# --- API Routes ---
+@app.route('/api/login', methods=['POST'])
+def login():
+ # ... (code omitted for brevity)
+ pass
+
+@app.route('/api/users', methods=['POST'])
+# @login_required # Temporarily disabled for testing
+def add_user():
+ # ... (code omitted for brevity)
+ pass
+
+@app.route('/api/stations', methods=['POST'])
+# @login_required # Temporarily disabled for testing
+def add_station():
+ # ... (code omitted for brevity)
+ pass
+
+@app.route('/api/stations', methods=['GET'])
+def get_stations():
+ try:
+ stations = Station.query.all()
+ station_list = []
+ for s in stations:
+ last_msg_time = last_message_timestamps.get(s.station_id)
+ is_online = last_msg_time is not None and (time.time() - last_msg_time) < STATION_TIMEOUT_SECONDS
+
+ station_list.append({
+ "id": s.station_id,
+ "name": s.name,
+ "location": s.location,
+ "status": "Online" if is_online else "Offline"
+ })
+ return jsonify(station_list)
+ except Exception as e:
+ return jsonify({"error": f"Database query failed: {e}"}), 500
+
+# ... (your CSV export and MQTT client start functions remain the same) ...
+
+if __name__ == '__main__':
+ # ... (your main startup logic remains the same) ...
+ pass
\ No newline at end of file
diff --git a/frontend/analytics.html b/frontend/analytics.html
new file mode 100644
index 0000000..0dffba5
--- /dev/null
+++ b/frontend/analytics.html
@@ -0,0 +1,317 @@
+
+
+
+
+
+ Swap Station – Analytics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+ Device ID:
+ —
+
+
+
+
+ Last Recv —
+
+
+
+ Online
+
+
+
On Backup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Swaps (Today)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Batteries
+
+
+
+
+
+
Good
+
Warning
+
Poor
+
+
+
+
+
+
+
+
diff --git a/frontend/assets/vec_logo.png b/frontend/assets/vec_logo.png
new file mode 100644
index 0000000..55ec978
Binary files /dev/null and b/frontend/assets/vec_logo.png differ
diff --git a/frontend/css/style.css b/frontend/css/style.css
new file mode 100644
index 0000000..8d25093
--- /dev/null
+++ b/frontend/css/style.css
@@ -0,0 +1,54 @@
+
\ No newline at end of file
diff --git a/frontend/css/tailwind.css b/frontend/css/tailwind.css
new file mode 100644
index 0000000..fa1becb
--- /dev/null
+++ b/frontend/css/tailwind.css
@@ -0,0 +1,137 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ====== POLISH PACK (drop-in) ========================================== */
+
+/* Subtle app background movement */
+body::before,
+body::after{
+ content:"";
+ position:fixed; inset:auto;
+ width:34rem; height:34rem; filter:blur(70px);
+ pointer-events:none; z-index:-1; opacity:.18;
+ border-radius:9999px;
+}
+body::before{ left:-12rem; top:-10rem; background:radial-gradient(ellipse at center,#10b98155 0%,#10b98100 60%);}
+body::after{ right:-14rem; bottom:-12rem; background:radial-gradient(ellipse at center,#06b6d455 0%,#06b6d400 60%);}
+
+/* ---------- Header chips & status row ---------- */
+.header-chip{
+ --bg: rgba(255,255,255,.06);
+ --bd: rgba(255,255,255,.14);
+ --fg: #e5e7eb;
+ display:inline-flex; align-items:center; gap:.4rem;
+ padding:.35rem .6rem; border-radius:.65rem;
+ border:1px solid var(--bd); background:var(--bg); color:var(--fg);
+ font-weight:700; font-size:.72rem; letter-spacing:.02em;
+ box-shadow:0 0 0 1px rgba(255,255,255,.02) inset;
+ transition:filter .18s ease, transform .18s ease, border-color .18s ease;
+}
+.header-chip svg{ width:.9rem; height:.9rem; opacity:.9 }
+.header-chip:hover{ filter:brightness(1.06); border-color:#34d39955 }
+
+/* Variants */
+.header-chip--online{ --bg:rgba(16,185,129,.15); --bd:rgba(16,185,129,.35); --fg:#a7f3d0 }
+.header-chip--backup{ --bg:rgba(245,158,11,.12); --bd:rgba(245,158,11,.35); --fg:#fde68a }
+.header-chip--warn{ --bg:rgba(244,63,94,.12); --bd:rgba(244,63,94,.4); --fg:#fecaca }
+
+/* ---------- Chamber cards ---------- */
+.chamber-card{
+ border:1px solid rgba(255,255,255,.10);
+ background:linear-gradient(180deg,rgba(2,6,23,.45),rgba(2,6,23,.35));
+ border-radius:14px; transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease;
+ display:flex; flex-direction:column; min-height: 225px;
+}
+.chamber-card:hover{
+ border-color:#34d39966;
+ box-shadow:0 6px 30px rgba(0,0,0,.35), 0 0 0 1px rgba(16,185,129,.15) inset;
+ transform:translateY(-1px);
+}
+
+/* keep all 9 chambers equal height inside the grid */
+#chambersGrid{ align-content:start }
+#chambersGrid > * { min-height: 0 } /* fix Firefox grid stretching */
+
+/* Chamber title bar */
+.chamber-card .title-bar{
+ display:flex; align-items:center; justify-content:center;
+ gap:.5rem; padding:.35rem .5rem; margin:-.25rem -.25rem .4rem;
+ border-bottom:1px solid rgba(255,255,255,.08);
+}
+.chamber-card .title-chip{
+ font-weight:800; font-size:.78rem; letter-spacing:.02em;
+ background:rgba(20,83,45,.35); color:#bbf7d0;
+ padding:.25rem .55rem; border-radius:.5rem; border:1px solid rgba(16,185,129,.35);
+ box-shadow:0 0 0 1px rgba(16,185,129,.12) inset;
+}
+
+/* BAT ID emphasis row */
+.bat-id-row{
+ display:flex; align-items:center; justify-content:space-between;
+ gap:.75rem; padding:.4rem .55rem; border-radius:.55rem;
+ border:1px dashed rgba(255,255,255,.14);
+ background:rgba(2,6,23,.35);
+}
+.bat-id-row .label{ color:#93a3af; font-size:.7rem; font-weight:700; letter-spacing:.03em }
+.bat-id-row .value{
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-weight:800; font-size:.9rem; letter-spacing:.06em;
+ color:#e5fff4; text-shadow:0 0 12px rgba(16,185,129,.25);
+}
+
+/* Two-column “key: value” rows inside a chamber */
+.kv{ display:flex; align-items:baseline; justify-content:space-between; gap:.75rem }
+.kv .k{ color:#9ca3af; font-size:.72rem; }
+.kv .v{ color:#e5e7eb; font-weight:700; font-size:.85rem }
+
+/* Pills (battery / charger status) */
+.pill{ padding:.28rem .5rem; border-radius:.5rem; border:1px solid; font-weight:800; font-size:.7rem; letter-spacing:.02em }
+.pill--ok{ background:rgba(16,185,129,.15); color:#a7f3d0; border-color:rgba(16,185,129,.35) }
+.pill--idle{ background:rgba(148,163,184,.12); color:#cbd5e1; border-color:rgba(148,163,184,.35) }
+.pill--bad{ background:rgba(244,63,94,.16); color:#fecaca; border-color:rgba(244,63,94,.38) }
+.pill--sky{ background:rgba(56,189,248,.15); color:#bae6fd; border-color:rgba(56,189,248,.38) }
+
+/* Door chip */
+.door-chip{ color:#fff; font-weight:800; font-size:.72rem; padding:.22rem .55rem; border-radius:.45rem }
+.door-chip--open{ background:#22c55e }
+.door-chip--closed{ background:#ef4444 }
+
+/* Action buttons inside card */
+.card-actions .btn{
+ font-size:.72rem; font-weight:800; padding:.45rem .55rem; border-radius:.6rem;
+ border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06);
+}
+.card-actions .btn:hover{ border-color:#34d39966; box-shadow:0 0 0 1px rgba(52,211,153,.2) inset }
+
+/* ---------- Right column (panels) ---------- */
+.panel{
+ border:1px solid rgba(255,255,255,.10);
+ background:linear-gradient(180deg,rgba(2,6,23,.45),rgba(2,6,23,.35));
+ border-radius:14px;
+}
+.panel h3{ font-weight:800; letter-spacing:.02em }
+
+/* Station reset emphasis */
+#station-reset-btn{
+ border:1px solid rgba(244,63,94,.45)!important; color:#fecaca!important; background:rgba(244,63,94,.08)!important;
+}
+#station-reset-btn:hover{ background:rgba(244,63,94,.18)!important; box-shadow:0 0 0 2px rgba(244,63,94,.25) inset }
+
+/* Instance log terminal feel */
+.instance-log{
+ background:#020617; color:#d1fae5; border:1px solid rgba(16,185,129,.15);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size:.78rem; line-height:1.35; border-radius:.6rem; padding:.75rem;
+ box-shadow:inset 0 1px 0 rgba(16,185,129,.06);
+}
+
+/* Nice thin scrollbars (Chrome/Edge) */
+*::-webkit-scrollbar{ height:10px; width:10px }
+*::-webkit-scrollbar-thumb{ background:linear-gradient(#1f2937,#111827); border:2px solid #0b1220; border-radius:12px }
+*::-webkit-scrollbar-thumb:hover{ background:linear-gradient(#243041,#131a2a) }
+
+/* Motion-reduction respect */
+@media (prefers-reduced-motion: reduce){
+ .header-chip, .chamber-card{ transition:none }
+}
diff --git a/frontend/dashboard.html b/frontend/dashboard.html
new file mode 100644
index 0000000..9d7b595
--- /dev/null
+++ b/frontend/dashboard.html
@@ -0,0 +1,356 @@
+
+
+
+
+
+ Swap Station – Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+ Device ID:
+ —
+
+
+
+
+ Waiting...
+
+
+
+ Connecting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BATTERY
+ --
+
+
SOC—
+
Voltage—
+
Bat Temp—
+
Bat Fault—
+
+
+
+
CHARGER
+ --
+
+
Current—
+
Slot Temp—
+
Chg Temp—
+
Chg Fault—
+
+
+
+
+
+
+
+
--
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/dashboard_copy.html b/frontend/dashboard_copy.html
new file mode 100644
index 0000000..a9a1b34
--- /dev/null
+++ b/frontend/dashboard_copy.html
@@ -0,0 +1,434 @@
+
+
+
+
+
+ Swap Station – Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+ Device ID:
+ —
+
+
+
+
+ Last Recv —
+
+
+
+ Online
+
+
+
On Backup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BATTERY
+
+
+
SOC—
+
Voltage—
+
Bat Temp—
+
Bat Fault—
+
+
+
+
+
CHARGER
+
+
+
Current—
+
Slot Temp—
+
Chg Temp—
+
Chg Fault—
+
+
+
+
+
CLOSED
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..a1f392e
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+ Swap Station – Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
Sign in to access your stations
+
+
+
+
+
+
+
+
+
+ © VECMOCON • All rights reserved.
+
+
+
+
+
+
diff --git a/frontend/js/auth.js b/frontend/js/auth.js
new file mode 100644
index 0000000..e3f1071
--- /dev/null
+++ b/frontend/js/auth.js
@@ -0,0 +1,36 @@
+// frontend/js/auth.js
+document.addEventListener('DOMContentLoaded', () => {
+ const loginForm = document.getElementById('login-form');
+ const errorMessageDiv = document.getElementById('error-message');
+
+ loginForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ errorMessageDiv.classList.add('hidden');
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ try {
+ const response = await fetch('http://localhost:5000/api/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ // Save user to localStorage and go to station selection
+ localStorage.setItem('user', JSON.stringify(data.user));
+ window.location.href = 'station_selection.html';
+ } else {
+ errorMessageDiv.textContent = data.message || 'Login failed.';
+ errorMessageDiv.classList.remove('hidden');
+ }
+ } catch (error) {
+ errorMessageDiv.textContent = 'Failed to connect to the server.';
+ errorMessageDiv.classList.remove('hidden');
+ console.error('Login error:', error);
+ }
+ });
+});
diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js
new file mode 100644
index 0000000..2c184a5
--- /dev/null
+++ b/frontend/js/dashboard.js
@@ -0,0 +1,618 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // --- CONFIGURATION ---
+ const SOCKET_URL = "http://localhost:5000";
+ const API_BASE = "http://localhost:5000/api"; // Added for API calls
+
+ // --- DOM ELEMENT REFERENCES ---
+ const grid = document.getElementById('chambersGrid');
+ const chamberTmpl = document.getElementById('chamberTemplate');
+ const logTextArea = document.getElementById('instance-log');
+ const connChip = document.getElementById('connection-status-chip');
+ const stationNameEl = document.getElementById('station-name');
+ const stationLocationEl = document.getElementById('station-location');
+ const deviceIdEl = document.getElementById('device-id');
+ const lastUpdateEl = document.getElementById('last-update-status');
+ const stationDiagCodeEl = document.getElementById('station-diag-code');
+ const backupPowerChip = document.getElementById('backup-power-chip');
+ const diagFlagsGrid = document.getElementById('diag-flags-grid');
+ const audioSelect = document.getElementById('audio-command-select');
+
+ // Header Buttons
+ const refreshBtn = document.getElementById('refreshBtn');
+ const downloadBtn = document.getElementById('downloadBtn');
+ const logoutBtn = document.getElementById('logout-btn');
+
+ // --- STATE ---
+ let selectedStation = null;
+ let socket;
+ let statusPollingInterval; // To hold our interval timer
+ let chamberData = Array(9).fill({ batteryPresent: false });
+
+ // The list of errors from your Python code
+ const DIAGNOSTIC_ERRORS = [
+ "Lock Power Cut", "Main Power Cut",
+ "Relayboard CAN", "DB CAN Recv",
+ "MB Can Recv", "Smoke Alarm",
+ "Water Alarm", "Phase Failure",
+ "Earth Leakage"
+ ];
+
+ // --- NEW: SWAP PROCESS ELEMENTS & LOGIC ---
+ const swapIdleText = document.getElementById('swap-idle-text');
+ const swapPairsList = document.getElementById('swap-pairs-list');
+ const startSwapBtn = document.getElementById('start-swap-btn');
+ const abortSwapBtn = document.getElementById('abort-swap-btn');
+ const clearSwapBtn = document.getElementById('clear-swap-btn');
+ const resetBtn = document.getElementById('station-reset-btn');
+
+ let currentPair = [], swapPairs = [];
+
+ // --- SWAP UI LOGIC ---
+ function updateSwapUI() {
+ const isBuilding = currentPair.length > 0 || swapPairs.length > 0;
+ if (swapIdleText) swapIdleText.style.display = isBuilding ? 'none' : 'block';
+ if (startSwapBtn) startSwapBtn.disabled = swapPairs.length === 0;
+
+ const pairedOut = swapPairs.map(p => p[0]), pairedIn = swapPairs.map(p => p[1]);
+ document.querySelectorAll('.chamber-card').forEach(card => {
+ const n = parseInt(card.dataset.chamberId, 10);
+ card.classList.remove('paired', 'pending');
+ if (pairedOut.includes(n) || pairedIn.includes(n)) card.classList.add('paired');
+ else if (currentPair.includes(n)) card.classList.add('pending');
+ });
+
+ if (swapPairsList) {
+ swapPairsList.innerHTML = '';
+ if (isBuilding) {
+ swapPairs.forEach(p => {
+ const e = document.createElement('div');
+ e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5';
+ e.innerHTML = `${p[0]}→${p[1]}`;
+ swapPairsList.appendChild(e);
+ });
+ if (currentPair.length > 0) {
+ const e = document.createElement('div');
+ e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5 ring-2 ring-sky-500';
+ e.innerHTML = `${currentPair[0]}→?`;
+ swapPairsList.appendChild(e);
+ }
+ }
+ }
+ }
+
+ function handleChamberClick(num) {
+ // Deselection logic
+ if (currentPair.length === 1 && currentPair[0] === num) {
+ currentPair = [];
+ updateSwapUI();
+ return;
+ }
+ const isAlreadyPaired = swapPairs.flat().includes(num);
+ if (isAlreadyPaired) {
+ swapPairs = swapPairs.filter(pair => !pair.includes(num));
+ updateSwapUI();
+ return;
+ }
+
+ // Selection logic
+ if (swapPairs.length >= 4) return alert('Maximum of 4 swap pairs reached.');
+
+ // Note: Live validation would go here. Removed for testing.
+
+ currentPair.push(num);
+ if (currentPair.length === 2) {
+ swapPairs.push([...currentPair]);
+ currentPair = [];
+ }
+ updateSwapUI();
+ }
+
+ function clearSelection() {
+ currentPair = [];
+ swapPairs = [];
+ updateSwapUI();
+ }
+
+
+ // --- HELPER FUNCTIONS (Your original code is unchanged) ---
+
+ const applyButtonFeedback = (button) => {
+ if (!button) return;
+ button.classList.add('btn-feedback');
+ setTimeout(() => {
+ button.classList.remove('btn-feedback');
+ }, 150);
+ };
+
+ // And ensure your sendCommand function does NOT have the feedback logic
+ const sendCommand = (command, data = null) => {
+ if (!selectedStation || !socket || !socket.connected) {
+ logToInstance(`Cannot send command '${command}', not connected.`, "error");
+ return;
+ }
+ const payload = { station_id: selectedStation.id, command: command, data: data };
+ socket.emit('rpc_request', payload);
+ logToInstance(`Sent command: ${command}`, 'cmd');
+ };
+
+ const setChipStyle = (element, text, style) => {
+ if (!element) return;
+ const baseClass = element.className.split(' ')[0];
+ element.textContent = text;
+ element.className = `${baseClass} chip-${style}`;
+ };
+ const logToInstance = (message, type = 'info') => {
+ if (!logTextArea) return;
+ const timestamp = new Date().toLocaleTimeString();
+ const typeIndicator = type === 'error' ? '[ERROR]' : type === 'cmd' ? '[CMD]' : '[INFO]';
+ const newLog = `[${timestamp}] ${typeIndicator} ${message}\n`;
+ logTextArea.value = newLog + logTextArea.value;
+ };
+
+ const updateChamberUI = (card, slot) => {
+ if (!card || !slot) return;
+
+ const filledState = card.querySelector('.filled-state');
+ const emptyState = card.querySelector('.empty-state');
+ const doorPill = card.querySelector('.door-pill');
+
+ // Always update door status
+ doorPill.textContent = slot.doorStatus ? 'OPEN' : 'CLOSED';
+ doorPill.className = slot.doorStatus ? 'door-pill door-open' : 'door-pill door-close';
+
+ const slotTempText = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
+
+ if (slot.batteryPresent) {
+ filledState.style.display = 'flex';
+ emptyState.style.display = 'none';
+
+ // Update the detailed view
+ card.querySelector('.slot-temp').textContent = slotTempText;
+ card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
+ card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
+
+ // --- Populate the detailed view ---
+ card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
+ card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
+ card.querySelector('.voltage').textContent = `${((slot.voltage || 0) / 1000).toFixed(1)} V`;
+ card.querySelector('.bat-temp').textContent = `${((slot.batteryMaxTemp || 0) / 10).toFixed(1)} °C`;
+ card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
+
+ card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`;
+ card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
+ card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
+
+ const batPill = card.querySelector('.battery-status-pill');
+ batPill.innerHTML = ` Present`;
+ batPill.className = 'battery-status-pill chip chip-emerald';
+
+ const chgPill = card.querySelector('.charger-status-pill');
+ if (slot.chargerMode === 1) {
+ chgPill.innerHTML = ` Charging`;
+ chgPill.className = 'charger-status-pill chip chip-sky';
+ } else {
+ chgPill.innerHTML = ` Idle`;
+ chgPill.className = 'charger-status-pill chip chip-slate';
+ }
+
+ } else {
+ // Show the empty view
+ filledState.style.display = 'none';
+ emptyState.style.display = 'flex';
+
+ // --- DEBUGGING LOGIC ---
+ const tempElement = card.querySelector('.slot-temp-empty');
+ if (tempElement) {
+ tempElement.textContent = slotTempText;
+ // console.log(`Chamber ${slot.chamberNo}: Found .slot-temp-empty, setting text to: ${slotTempText}`);
+ } else {
+ // console.error(`Chamber ${slot.chamberNo}: Element .slot-temp-empty NOT FOUND! Check your HTML template.`);
+ }
+ }
+
+ // Check if the icon library is loaded and then render the icons
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons();
+ } else {
+ console.error('Lucide icon script is not loaded. Please check dashboard.html');
+ }
+ };
+
+ // --- NEW: Function to decode the SDC and update the UI ---
+ const updateDiagnosticsUI = (sdcCode) => {
+ if (!diagFlagsGrid) return;
+ diagFlagsGrid.innerHTML = ''; // Clear previous statuses
+
+ DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
+ // Use bitwise AND to check if the bit at this index is set
+ const isActive = (sdcCode & (1 << index)) !== 0;
+
+ const div = document.createElement('div');
+ div.textContent = errorText;
+
+ // Apply different styles based on whether the alarm is active
+ if (isActive) {
+ div.className = 'text-rose-300 text-center font-semibold';
+ } else {
+ div.className = 'text-slate-500 text-center';
+ }
+ diagFlagsGrid.appendChild(div);
+ });
+ };
+
+ const resetDashboardUI = () => {
+ grid.querySelectorAll('.chamber-card').forEach(card => {
+ card.querySelector('.bat-id-big').textContent = 'Waiting...';
+ card.querySelector('.soc').textContent = '—';
+ card.querySelector('.voltage').textContent = '—';
+ card.querySelector('.bat-temp').textContent = '—';
+ card.querySelector('.bat-fault').textContent = '—';
+ card.querySelector('.current').textContent = '—';
+ card.querySelector('.slot-temp').textContent = '—';
+ card.querySelector('.chg-fault').textContent = '—';
+
+ // Show the "empty" view by default when resetting
+ card.querySelector('.filled-state').style.display = 'none';
+ card.querySelector('.empty-state').style.display = 'flex';
+ });
+ logToInstance("Station is offline. Clearing stale data.", "error");
+ };
+
+ // --- NEW: This function polls the API for the true station status ---
+ const checkStationStatus = async () => {
+ if (!selectedStation) return;
+ try {
+ const response = await fetch(`${API_BASE}/stations`);
+ if (!response.ok) return;
+ const stations = await response.json();
+ const thisStation = stations.find(s => s.id === selectedStation.id);
+
+ if (thisStation && connChip) {
+
+ stationNameEl.textContent = thisStation.name;
+ stationLocationEl.textContent = thisStation.location;
+
+ if (thisStation.status === 'Online') {
+ connChip.innerHTML = ` Online`;
+ connChip.className = 'cham_chip cham_chip-emerald';
+ } else {
+ connChip.innerHTML = ` Offline`;
+ connChip.className = 'cham_chip cham_chip-rose';
+ lastUpdateEl.textContent = "Waiting for data...";
+ resetDashboardUI();
+ }
+ }
+ } catch (error) {
+ console.error("Failed to fetch station status:", error);
+ }
+ };
+
+ // --- DOWNLOAD MODAL LOGIC ---
+ const showDownloadModal = () => {
+ const modalOverlay = document.createElement('div');
+ modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
+
+ modalOverlay.innerHTML = `
+
+
Export Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(modalOverlay);
+
+ const startInput = document.getElementById('start-datetime');
+ const endInput = document.getElementById('end-datetime');
+
+ // --- NEW: Initialize flatpickr on the inputs ---
+ const fpConfig = {
+ enableTime: true,
+ dateFormat: "Y-m-d\\TH:i", // Format needed by the backend
+ time_24hr: true
+ };
+ const fpStart = flatpickr(startInput, fpConfig);
+ const fpEnd = flatpickr(endInput, fpConfig);
+
+ // --- (The rest of the function is the same) ---
+ const now = new Date();
+ const oneHourAgo = new Date(now.getTime() - 3600 * 1000);
+ fpStart.setDate(oneHourAgo, true);
+ fpEnd.setDate(now, true);
+
+ modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
+ button.addEventListener('click', () => {
+ const range = button.dataset.range;
+ const now = new Date();
+ let start = new Date();
+
+ if (range === 'today') {
+ start.setHours(0, 0, 0, 0);
+ } else if (range === 'yesterday') {
+ start.setDate(start.getDate() - 1);
+ start.setHours(0, 0, 0, 0);
+ now.setDate(now.getDate() - 1);
+ now.setHours(23, 59, 59, 999);
+ } else {
+ start.setHours(now.getHours() - parseInt(range, 10));
+ }
+
+ fpStart.setDate(start, true);
+ fpEnd.setDate(now, true);
+ });
+ });
+
+ document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
+
+ document.getElementById('confirm-download').onclick = async () => {
+ const logType = document.getElementById('log-type').value;
+ const startDateStr = document.getElementById('start-datetime').value;
+ const endDateStr = document.getElementById('end-datetime').value;
+ const confirmBtn = document.getElementById('confirm-download');
+
+ if (!startDateStr || !endDateStr) {
+ alert('Please select both a start and end date/time.');
+ return;
+ }
+
+ // --- Validation Logic ---
+ const selectedStartDate = new Date(startDateStr);
+ const selectedEndDate = new Date(endDateStr);
+ const currentDate = new Date();
+
+ if (selectedStartDate > currentDate) {
+ alert('Error: The start date cannot be in the future.');
+ return;
+ }
+ if (selectedEndDate > currentDate) {
+ alert('Error: The end date cannot be in the future.');
+ return;
+ }
+ if (selectedStartDate >= selectedEndDate) {
+ alert('Error: The start date must be earlier than the end date.');
+ return;
+ }
+
+ // --- Fetch and Download Logic ---
+ confirmBtn.textContent = 'Fetching...';
+ confirmBtn.disabled = true;
+
+ const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
+
+ try {
+ const response = await fetch(downloadUrl);
+
+ if (response.ok) { // Status 200, CSV file received
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = url;
+
+ const logType = document.getElementById('log-type').value;
+ const dateStr = startDateStr.split('T')[0]; // Get just the date part
+ let filename = `${selectedStation.name || selectedStation.id}_${logType}_${dateStr}.csv`;
+
+ const disposition = response.headers.get('Content-Disposition');
+ if (disposition && disposition.indexOf('attachment') !== -1) {
+ const filenameMatch = disposition.match(/filename="(.+?)"/);
+ if (filenameMatch && filenameMatch.length === 2) {
+ filename = filenameMatch[1];
+ }
+ }
+ a.download = filename;
+
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ document.body.removeChild(modalOverlay);
+
+ } else { // Status 404, no data found
+ const errorData = await response.json();
+ alert(`Could not download: ${errorData.message}`);
+ }
+ } catch (error) {
+ alert('An unexpected error occurred. Please check the console.');
+ console.error('Download error:', error);
+ } finally {
+ confirmBtn.textContent = 'Download CSV';
+ confirmBtn.disabled = false;
+ }
+ };
+ };
+
+ // --- MAIN LOGIC (Your original code is unchanged) ---
+ const initializeDashboard = () => {
+ try {
+ selectedStation = JSON.parse(localStorage.getItem('selected_station'));
+ if (!selectedStation || !selectedStation.id) {
+ window.location.href = './index.html';
+ }
+ stationNameEl.textContent = selectedStation.name || 'Unknown Station';
+ stationLocationEl.textContent = selectedStation.location || 'No location';
+ deviceIdEl.textContent = selectedStation.id;
+ } catch (e) {
+ document.body.innerHTML = ``;
+ return;
+ }
+
+ grid.innerHTML = '';
+ for (let i = 1; i <= 9; i++) {
+ const node = chamberTmpl.content.cloneNode(true);
+ const card = node.querySelector('.chamber-card');
+ card.dataset.chamberId = i;
+ card.querySelector('.slotNo').textContent = i;
+ grid.appendChild(node);
+ }
+
+ logToInstance(`Dashboard initialized for station: ${selectedStation.name} (${selectedStation.id})`);
+
+ // --- EVENT LISTENERS ---
+
+ // NEW: A single, global click listener for all button feedback
+ document.addEventListener('click', (event) => {
+ const button = event.target.closest('.btn');
+ if (button) {
+ applyButtonFeedback(button);
+ }
+ });
+
+ // Chamber Card and Swap Selection
+ document.querySelectorAll('.chamber-card').forEach(card => {
+ // Listener for swap selection
+ card.addEventListener('click', () => handleChamberClick(parseInt(card.dataset.chamberId, 10)));
+
+ // Listeners for command buttons inside the card
+ card.querySelectorAll('.btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const commandText = e.currentTarget.textContent.trim();
+ const command = commandText.replace(' ', '_');
+ const chamberNum = parseInt(e.currentTarget.closest('.chamber-card').dataset.chamberId, 10);
+
+ if (command === 'OPEN') {
+ if (confirm(`Are you sure you want to open door for Chamber ${chamberNum}?`)) {
+ sendCommand(command, chamberNum);
+ } else {
+ logToInstance(`Open Door command for Chamber ${chamberNum} cancelled.`);
+ }
+ } else {
+ sendCommand(command, chamberNum);
+ }
+ });
+ });
+ });
+
+ // Swap Panel Buttons
+ if (clearSwapBtn) clearSwapBtn.addEventListener('click', clearSelection);
+ if (startSwapBtn) {
+ startSwapBtn.addEventListener('click', () => {
+ if (swapPairs.length > 0) {
+ const formattedPairs = swapPairs.map(p => `[${p[0]},${p[1]}]`).join(',');
+ sendCommand('START_SWAP', formattedPairs);
+ clearSelection();
+ }
+ });
+ }
+ if (abortSwapBtn) abortSwapBtn.addEventListener('click', () => sendCommand('ABORT_SWAP'));
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ if (confirm('Are you sure you want to reset the station?')) {
+ sendCommand('STATION_RESET');
+ }
+ });
+ }
+
+ // Header Buttons
+ if (refreshBtn) refreshBtn.addEventListener('click', () => window.location.reload());
+ if (logoutBtn) {
+ logoutBtn.addEventListener('click', () => {
+ localStorage.clear();
+ window.location.href = './index.html';
+ });
+ }
+ if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
+
+ // Audio Command Button (assuming it exists in your HTML)
+ const sendAudioBtn = document.getElementById('send-audio-btn');
+ if (sendAudioBtn) {
+ sendAudioBtn.addEventListener('click', () => {
+ const language = document.getElementById('audio-command-select').value;
+ sendCommand('LANGUAGE_UPDATE', language);
+ });
+ }
+
+ updateSwapUI();
+ connectSocket();
+ checkStationStatus();
+ statusPollingInterval = setInterval(checkStationStatus, 10000);
+ };
+
+ const connectSocket = () => {
+ socket = io(SOCKET_URL);
+
+ // --- CHANGED: No longer sets status to "Online" on its own ---
+ socket.on('connect', () => {
+ logToInstance("Successfully connected to the backend WebSocket.");
+ socket.emit('join_station_room', { station_id: selectedStation.id });
+ });
+
+ // --- CHANGED: Sets status to "Offline" correctly on disconnect ---
+ socket.on('disconnect', () => {
+ logToInstance("Disconnected from the backend WebSocket.", "error");
+ connChip.innerHTML = ` Offline`;
+ connChip.className = 'cham_chip cham_chip-rose';
+ });
+
+ socket.on('dashboard_update', (message) => {
+ // console.log("DEBUG: Received 'dashboard_update' message:", message);
+ const { stationId, data } = message;
+
+ if (stationId !== selectedStation.id) {
+ console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
+ return;
+ }
+
+ lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
+ stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
+
+ // Show/hide the backup power chip based on the payload data
+ if (data.backupSupplyStatus === 1) {
+ backupPowerChip.textContent = 'On Backup';
+ backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
+ } else {
+ backupPowerChip.textContent = 'On Mains Power';
+ backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
+ }
+
+ lastUpdateEl.textContent = new Date().toLocaleTimeString();
+ stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
+
+ // --- NEW: Call the function to update the diagnostics grid ---
+ updateDiagnosticsUI(data.stationDiagnosticCode || 0);
+
+ if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
+ data.slotLevelPayload.forEach((slotData, index) => {
+ const slotId = index + 1;
+ chamberData[slotId - 1] = slotData; // Keep live data in sync
+ const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
+ if (card) {
+ updateChamberUI(card, slotData);
+ }
+ });
+ }
+ });
+
+ };
+ // --- SCRIPT EXECUTION ---
+ initializeDashboard();
+});
\ No newline at end of file
diff --git a/frontend/js/logs.js b/frontend/js/logs.js
new file mode 100644
index 0000000..ec64195
--- /dev/null
+++ b/frontend/js/logs.js
@@ -0,0 +1,123 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // --- CONFIGURATION ---
+ const SOCKET_URL = "http://localhost:5000";
+ const API_BASE = "http://localhost:5000/api";
+
+ // --- DOM ELEMENT REFERENCES ---
+ const stationNameEl = document.getElementById('station-name');
+ const stationLocationEl = document.getElementById('station-location');
+ const deviceIdEl = document.getElementById('device-id');
+ const lastUpdateEl = document.getElementById('last-update-status');
+ const connChip = document.getElementById('connection-status-chip');
+ const requestLogArea = document.getElementById('request-log-area');
+ const eventLogArea = document.getElementById('event-log-area');
+ const clearReqBtn = document.getElementById('clear-req');
+ const clearEvtBtn = document.getElementById('clear-evt');
+ const clearAllBtn = document.getElementById('clear-all');
+ const refreshBtn = document.getElementById('refreshBtn');
+ const downloadBtn = document.getElementById('downloadBtn');
+ const logoutBtn = document.getElementById('logout-btn');
+ const resetBtn = document.getElementById('station-reset-btn');
+
+ // --- STATE ---
+ let selectedStation = null;
+ let socket;
+ let statusPollingInterval;
+
+ // --- HELPER FUNCTIONS ---
+ const prependLog = (textarea, data) => {
+ if (!textarea) return;
+ const timestamp = new Date().toLocaleTimeString();
+ const formattedJson = JSON.stringify(data, null, 2);
+ const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
+ textarea.value = newLog + textarea.value;
+ };
+
+ const sendCommand = (command, data = null) => {
+ if (!selectedStation || !socket || !socket.connected) {
+ console.error(`Cannot send command '${command}', not connected.`);
+ return;
+ }
+ const payload = { station_id: selectedStation.id, command: command, data: data };
+ socket.emit('rpc_request', payload);
+ };
+
+ const checkStationStatus = async () => {
+ if (!selectedStation) return;
+ try {
+ const response = await fetch(`${API_BASE}/stations`);
+ if (!response.ok) return;
+ const stations = await response.json();
+ const thisStation = stations.find(s => s.id === selectedStation.id);
+ if (thisStation) {
+ stationNameEl.textContent = thisStation.name;
+ stationLocationEl.textContent = thisStation.location;
+ if (thisStation.status === 'Online') {
+ connChip.innerHTML = ` Online`;
+ connChip.className = 'cham_chip cham_chip-emerald';
+ } else {
+ connChip.innerHTML = ` Offline`;
+ connChip.className = 'cham_chip cham_chip-rose';
+ }
+ }
+ } catch (error) { console.error("Failed to fetch station status:", error); }
+ };
+
+ // --- INITIALIZATION ---
+ try {
+ selectedStation = JSON.parse(localStorage.getItem('selected_station'));
+ if (!selectedStation || !selectedStation.id) {
+ throw new Error('No station selected. Please go back to the selection page.');
+ }
+ deviceIdEl.textContent = selectedStation.id;
+ } catch (e) {
+ document.body.innerHTML = ``;
+ return;
+ }
+
+ // --- SOCKET.IO CONNECTION ---
+ socket = io(SOCKET_URL);
+ socket.on('connect', () => {
+ console.log("Connected to WebSocket for logs.");
+ socket.emit('join_station_room', { station_id: selectedStation.id });
+ });
+
+ socket.on('dashboard_update', (message) => {
+ const { stationId, topic, data } = message;
+ if (stationId !== selectedStation.id) return;
+
+ lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
+
+ if (topic.endsWith('EVENTS')) {
+ prependLog(eventLogArea, data);
+ } else if (topic.endsWith('REQUEST')) {
+ prependLog(requestLogArea, data);
+ }
+ });
+
+ // --- BUTTON EVENT LISTENERS ---
+ if(clearReqBtn) clearReqBtn.addEventListener('click', () => requestLogArea.value = '');
+ if(clearEvtBtn) clearEvtBtn.addEventListener('click', () => eventLogArea.value = '');
+ if(clearAllBtn) clearAllBtn.addEventListener('click', () => {
+ requestLogArea.value = '';
+ eventLogArea.value = '';
+ });
+ if(logoutBtn) logoutBtn.addEventListener('click', () => {
+ localStorage.clear();
+ window.location.href = 'index.html';
+ });
+ if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
+ if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
+ if(resetBtn) resetBtn.addEventListener('click', () => {
+ if (confirm('Are you sure you want to reset the station?')) {
+ sendCommand('STATION_RESET');
+ }
+ });
+
+ // --- STARTUP ---
+ checkStationStatus();
+ statusPollingInterval = setInterval(checkStationStatus, 10000);
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons();
+ }
+});
\ No newline at end of file
diff --git a/frontend/js/main.js b/frontend/js/main.js
new file mode 100644
index 0000000..a3b23fb
--- /dev/null
+++ b/frontend/js/main.js
@@ -0,0 +1,12 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const logoutButton = document.getElementById('logout-button');
+
+ if (logoutButton) {
+ logoutButton.addEventListener('click', () => {
+ // Clear the user's session from local storage
+ localStorage.removeItem('user');
+ // Redirect to the login page
+ window.location.href = 'index.html';
+ });
+ }
+});
diff --git a/frontend/js/station_selection.js b/frontend/js/station_selection.js
new file mode 100644
index 0000000..cf8c7f8
--- /dev/null
+++ b/frontend/js/station_selection.js
@@ -0,0 +1,114 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // --- DOM ELEMENTS ---
+ const stationsGrid = document.getElementById('stations-grid');
+ const stationTemplate = document.getElementById('stationCardTemplate');
+ const errorMessage = document.getElementById('error-message');
+ const stationCountEl = document.getElementById('station-count');
+ // Note: SocketIO is not needed on this page anymore
+
+ let allStations = []; // To store the master list of stations
+
+ // --- AUTHENTICATION & USER INFO (Your existing code is perfect) ---
+ const user = JSON.parse(localStorage.getItem('user'));
+ if (!user) {
+ window.location.href = 'index.html'; // Redirect if not logged in
+ return;
+ }
+
+ // User info and logout button logic... (omitted for brevity, no changes needed)
+
+ // --- ADMIN FEATURES (Your existing code is perfect) ---
+ // Admin button and add station card logic... (omitted for brevity, no changes needed)
+
+
+ // --- HELPER FUNCTIONS ---
+ const getStatusAttributes = (status) => {
+ switch (status) {
+ case 'Online': return { color: 'text-green-500', bgColor: 'bg-green-100/60 dark:bg-green-500/10', icon: 'power' };
+ case 'Offline': return { color: 'text-red-500', bgColor: 'bg-red-100/60 dark:bg-red-500/10', icon: 'power-off' };
+ default: return { color: 'text-gray-500', bgColor: 'bg-gray-100/60 dark:bg-gray-500/10', icon: 'help-circle' };
+ }
+ };
+
+ const handleStationSelect = (stationId) => {
+ window.location.href = `dashboard.html?station_id=${stationId}`;
+ };
+
+ // This function now only renders the initial grid
+ const renderStations = (stations) => {
+ stationsGrid.innerHTML = '';
+ stationCountEl.textContent = `${stations.length} stations found. Select one to monitor.`;
+
+ stations.forEach(station => {
+ const status = getStatusAttributes(station.status);
+ const card = document.createElement('div');
+ card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out cursor-pointer flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]";
+ card.id = `station-${station.id}`;
+ card.onclick = () => handleStationSelect(station.id);
+
+ card.innerHTML = `
+
+
+
${station.name}
+
+
+ ${station.status}
+
+
+
${station.id}
+
+ `;
+ stationsGrid.appendChild(card);
+ });
+ lucide.createIcons();
+ };
+
+ // --- NEW: Function to update statuses without redrawing everything ---
+ const updateStationStatuses = (stations) => {
+ stations.forEach(station => {
+ const card = document.getElementById(`station-${station.id}`);
+ if (card) {
+ const status = getStatusAttributes(station.status);
+ const statusBadge = card.querySelector('.status-badge');
+ const statusText = card.querySelector('.status-text');
+ const statusIcon = card.querySelector('i[data-lucide]');
+
+ if (statusBadge && statusText && statusIcon) {
+ statusBadge.className = `status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}`;
+ statusText.textContent = station.status;
+ statusIcon.setAttribute('data-lucide', status.icon);
+ }
+ }
+ });
+ lucide.createIcons(); // Re-render icons if any changed
+ };
+
+ // --- DATA FETCHING & STATUS POLLING ---
+ const loadAndPollStations = async () => {
+ try {
+ const response = await fetch('http://localhost:5000/api/stations');
+ if (!response.ok) throw new Error('Failed to fetch stations');
+ const stations = await response.json();
+
+ // Check if this is the first time loading data
+ if (allStations.length === 0) {
+ allStations = stations;
+ renderStations(allStations); // Initial full render
+ } else {
+ allStations = stations;
+ updateStationStatuses(allStations); // Subsequent, efficient updates
+ }
+
+ } catch (error) {
+ console.error(error);
+ stationCountEl.textContent = 'Could not load stations. Is the backend running?';
+ // Stop polling on error
+ if (pollingInterval) clearInterval(pollingInterval);
+ }
+ };
+
+ // --- INITIALIZATION ---
+ loadAndPollStations(); // Load immediately on page start
+ // Then, set an interval to refresh the statuses every 10 seconds
+ const pollingInterval = setInterval(loadAndPollStations, 10000);
+});
\ No newline at end of file
diff --git a/frontend/logs.html b/frontend/logs.html
new file mode 100644
index 0000000..f6a3aac
--- /dev/null
+++ b/frontend/logs.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+ Swap Station – Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+ Device ID:
+ —
+
+
+
+
+ Waiting...
+
+
+
+ Connecting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..e4773f6
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "frontend",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "devDependencies": {
+ "tailwindcss": "^4.1.12"
+ }
+}
diff --git a/frontend/station_selection.html b/frontend/station_selection.html
new file mode 100644
index 0000000..cf3ced9
--- /dev/null
+++ b/frontend/station_selection.html
@@ -0,0 +1,368 @@
+
+
+
+
+
+ Swap Station – Select a Station
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+ Online
+ Offline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No stations match your filters.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+