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 + + + + + + + + + + + + + +
+
+ +
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ + +
+ VECMOCON +
+ + +
+ + + Device ID: + + + + + + + + Last Recv — + + + + Online + + + On Backup + + + + + + + + +
+
+ + + +
+ +
+
+ + +
+ +
+ + Device ID: + + + +
+ + to + + +
+
+ + +
+
+

Total Swaps (Today)

+

142

+
+
+

Avg. Swap Time

+

2.1 min

+
+
+

Station Uptime

+

99.8%

+
+
+

Peak Hours

+

5–7 PM

+
+
+ + +
+ +
+
+

Swaps This Week

+ Mon → Sun +
+ +
+ +
+
Mon +
+
+
Tue +
+
+
Wed +
+
+
Thu +
+
+
Fri +
+
+
Sat +
+
+
Sun +
+
+
+ + +
+

Battery Health

+
+
+ + + + + + +
+ 250 + 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 + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Waiting... + + + + Connecting... + + + + + + + + + + + + + +
+
+ +
+ +
+
+ +
+
+
+ + +
+
+ + + + + \ 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 + + + + + + + + + + + +
+
+
+
+ +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Last Recv — + + + + Online + + + On Backup + + + + + + + + +
+ +
+ +
+ +
+
+ +
+
+
+ + +
+
+ + + + + + \ 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 + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + +
+
+ +
+ VECMOCON +

Swap Station Dashboard

+

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 = `
${e.message} Go Back
`; + 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 = `
${e.message}Go Back
`; + 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 + + + + + + + + + + + + + +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Waiting... + + + + Connecting... + + + + + + + + + + + + + +
+
+ + +
+ +
+
+ + +
+ + + +
+ +
+
+
+

RPC Request

+
+
+ +
+
+
+ +
+
+ + +
+
+
+

Events

+
+
+ +
+
+
+ +
+
+
+ + +
+ +
+
+ + + + 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 + + + + + + + + + + + +
+
+
+
+ + +
+
+ +
+

Select a Station

+
+ +
+ VECMOCON +
+ +
+
+ Online + Offline +
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+ +
+ + + +
+
+ + +
+ + +
+ + + + + + + + + + + + + + + +