commit 412139f02d1a51fd7228fb2fd823d94580d01568 Author: Kirubakaran Date: Thu Aug 21 00:26:09 2025 +0530 chore: initial project setup - Added base project structure (core, ui, proto, assets, logs, utils) - Added requirements.txt for dependencies - Added main.py entry point - Configured .gitignore to exclude __pycache__, build, dist, venv, logs, and .spec files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04fee25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python cache +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Build / distribution +build/ +dist/ +*.egg-info/ + +# PyInstaller spec files +*.spec + +# Virtual environment +venv/ +.env/ +.venv/ + +# Logs +logs/ + +# IDE / editor specific +.vscode/ +.idea/ +*.swp +*.swo diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..0389c52 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..90501c0 Binary files /dev/null and b/assets/icon.png differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/csv_logger.py b/core/csv_logger.py new file mode 100644 index 0000000..70daa80 --- /dev/null +++ b/core/csv_logger.py @@ -0,0 +1,159 @@ +# In core/csv_logger.py + +import csv +import json +import os +import queue +from datetime import datetime +from PyQt6.QtCore import QObject, pyqtSlot, QTimer +from google.protobuf.json_format import MessageToDict +from proto.vec_payload_chgSt_pb2 import eventPayload, rpcRequest + +class CsvLogger(QObject): + def __init__(self, base_log_directory, session_name): + super().__init__() + self.base_log_directory = base_log_directory + self.session_name = session_name + os.makedirs(self.base_log_directory, exist_ok=True) + + self.files = {} + self.writers = {} + self.queue = queue.Queue() + self.timer = QTimer(self) + self.timer.timeout.connect(self._process_queue) + self.num_slots = 9 + + def start_logging(self): + self.timer.start(100) + print(f"✅ CSV logging service started for session: {self.session_name}") + + def _get_writer(self, device_id, file_group): + writer_key = (device_id, file_group) + if writer_key in self.writers: + return self.writers[writer_key] + + try: + session_dir = os.path.join(self.base_log_directory, device_id, self.session_name) + os.makedirs(session_dir, exist_ok=True) + filepath = os.path.join(session_dir, f"{file_group}.csv") + + self.files[writer_key] = open(filepath, 'w', newline='', encoding='utf-8') + writer = csv.writer(self.files[writer_key]) + self.writers[writer_key] = writer + + if file_group == 'PERIODIC': + # --- Programmatically build the WIDE header --- + base_header = ["Timestamp", "DeviceID", "StationDiagnosticCode"] + slot_fields = [ + "BatteryID", "BatteryPresent", "ChargerPresent", "DoorStatus", "DoorLockStatus", + "Voltage_V", "Current_A", "SOC_Percent", "BatteryTemp_C", "SlotTemp_C", + "BatteryFaultCode", "ChargerFaultCode", "BatteryMode", "ChargerMode" + ] + slot_header = [f"Slot{i}_{field}" for i in range(1, self.num_slots + 1) for field in slot_fields] + header = base_header + slot_header + ["RawHexPayload"] + else: # Header for EVENTS_RPC + header = ["Timestamp", "Topic", "Payload_JSON", "RawHexPayload"] + + writer.writerow(header) + print(f"---> New log file created: {filepath}") + return writer + except Exception as e: + print(f"❌ Failed to create CSV writer for {writer_key}: {e}") + return None + + @pyqtSlot(list) + def log_data(self, data_list): + self.queue.put(data_list) + + def _process_queue(self): + while not self.queue.empty(): + # ========================================================== + # ===== EDITED SECTION STARTS HERE ========================= + # ========================================================== + item = None # Define item outside the try block for better error reporting + try: + # First, get the whole item from the queue. + item = self.queue.get() + + # Now, try to unpack it. This is where the ValueError can happen. + timestamp_obj, topic, data, raw_payload = item + + # The rest of your logic remains the same + parts = topic.split('/') + if len(parts) < 5: continue + device_id = parts[3] + file_group = 'PERIODIC' if topic.endswith('/PERIODIC') else 'EVENTS_RPC' + + writer = self._get_writer(device_id, file_group) + if not writer: continue + + if file_group == 'PERIODIC': + # --- Build one single WIDE row --- + row_data = [ + datetime.fromtimestamp(data.get("ts")).strftime("%Y-%m-%d %H:%M:%S"), + device_id, + data.get("stationDiagnosticCode", "N/A") + ] + + all_slots_data = [] + slots = data.get("slotLevelPayload", []) + num_slot_fields = 14 + + for i in range(self.num_slots): + if i < len(slots): + slot = slots[i] + all_slots_data.extend([ + slot.get('batteryIdentification', ''), + "TRUE" if slot.get("batteryPresent") == 1 else "FALSE", + "TRUE" if slot.get("chargerPresent") == 1 else "FALSE", + "OPEN" if slot.get("doorStatus") == 1 else "CLOSED", + "LOCKED" if slot.get("doorLockStatus") == 1 else "UNLOCKED", + slot.get('voltage', 0) / 1000.0, + slot.get('current', 0) / 1000.0, + slot.get('soc', 0), + slot.get('batteryMaxTemp', 0) / 10.0, + slot.get('slotTemperature', 0) / 10.0, + slot.get('batteryFaultCode', 0), + slot.get('chargerFaultCode', 0), + slot.get('batteryMode', 0), + slot.get('chargerMode', 0) + ]) + else: + all_slots_data.extend([''] * num_slot_fields) + + final_row = row_data + all_slots_data + [raw_payload.hex()] + writer.writerow(final_row) + else: + # Logic for EVENTS and RPC remains the same + payload_json_string = json.dumps(data) + row = [ + timestamp_obj.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + topic, + payload_json_string, + raw_payload.hex() + ] + writer.writerow(row) + + writer_key = (device_id, file_group) + if file_handle := self.files.get(writer_key): + file_handle.flush() + + except ValueError: + # This specifically catches the unpacking error and prints a helpful message. + print(f"❌ Error: Malformed item in log queue. Expected 4 values, but got {len(item)}. Item: {item}") + continue # Continue to the next item in the queue + + except Exception as e: + # A general catch-all for any other unexpected errors. + # This message is safe because it doesn't use variables from the try block. + print(f"❌ An unexpected error occurred in the logger thread: {e}") + continue + + def stop_logging(self): + self.timer.stop() + self._process_queue() + for file in self.files.values(): + file.close() + self.files.clear() + self.writers.clear() + print(f"🛑 CSV logging stopped for session: {self.session_name}") \ No newline at end of file diff --git a/core/mqtt_client.py b/core/mqtt_client.py new file mode 100644 index 0000000..0620158 --- /dev/null +++ b/core/mqtt_client.py @@ -0,0 +1,131 @@ +# In core/mqtt_client.py +import socket +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot +import paho.mqtt.client as mqtt + +class MqttClient(QObject): + # --- MODIFIED SIGNAL: Now sends a bool and a string --- + connection_status_changed = pyqtSignal(bool, str) + message_received = pyqtSignal(str, bytes) + connection_error = pyqtSignal(str) + stop_logging_signal = pyqtSignal() + connected = pyqtSignal() + disconnected = pyqtSignal() + + def __init__(self, broker, port, user, password, client_id): + super().__init__() + self.broker = broker + self.port = port + + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id) + if user and password: + self.client.username_pw_set(user, password) + + self.client.on_connect = self.on_connect + self.client.on_disconnect = self.on_disconnect + self.client.on_message = self.on_message + # self.client.on_subscribe = self.on_subscribe + + def on_connect(self, client, userdata, flags, rc, properties): + if rc == 0: + print("Connection to MQTT Broker successful!") + # --- MODIFIED EMIT: Send a success message --- + self.connection_status_changed.emit(True, "✅ Connected") + else: + print(f"Failed to connect, return code {rc}\n") + # --- MODIFIED EMIT: Send a failure message --- + self.connection_status_changed.emit(False, f"❌ Connection failed (Code: {rc})") + self.stop_logging_signal.emit() # Emit the signal + self.disconnected.emit() + + def on_message(self, client, userdata, msg): + # print(f"Received {len(msg.payload)} bytes of binary data from topic `{msg.topic}`") + self.message_received.emit(msg.topic, msg.payload) + + def on_disconnect(self, client, userdata, flags, rc, properties): + print("Disconnected from MQTT Broker.") + # Change the icon in the line below from 🔌 to 🔴 ❌ 🚫 💔 + self.connection_status_changed.emit(False, "💔 Disconnected") + self.disconnected.emit() + + # --- MODIFIED connect_to_broker METHOD --- + def connect_to_broker(self): + print(f"Attempting to connect to {self.broker}:{self.port}...") + try: + self.client.connect(self.broker, self.port, 60) + self.client.loop_start() + except socket.gaierror: + msg = "Host not found. Check internet." + print(f"❌ Connection Error: {msg}") + self.connection_status_changed.emit(False, f"❌ {msg}") + except (socket.error, TimeoutError): + msg = "Connection failed. Server offline?" + print(f"❌ Connection Error: {msg}") + self.connection_status_changed.emit(False, f"❌ {msg}") + except Exception as e: + msg = f"An unexpected error occurred: {e}" + print(f"❌ {msg}") + self.connection_status_changed.emit(False, f"❌ Error") + + + def run(self): + """ + Connects to the broker and starts the network loop. + Handles all common connection errors gracefully. + """ + print(f"Attempting to connect to {self.broker}:{self.port}...") + try: + # 1. Attempt to connect + self.client.connect(self.broker, self.port, 60) + + # 2. Run the blocking network loop + # This will run until self.client.disconnect() is called + self.client.loop_forever() + + except socket.gaierror: + msg = "Host not found. Check the broker address or your internet connection." + print(f"❌ {msg}") + self.connection_error.emit(msg) # Report error to the main window + + except (socket.error, ConnectionRefusedError): + msg = "Connection refused. Is the server offline or the port incorrect?" + print(f"❌ {msg}") + self.connection_error.emit(msg) + + except TimeoutError: + msg = "Connection timed out. The server is not responding." + print(f"❌ {msg}") + self.connection_error.emit(msg) + + except Exception as e: + # Catch any other unexpected errors during connection or loop + msg = f"An unexpected error occurred: {e}" + print(f"❌ {msg}") + self.connection_error.emit(msg) + + # def on_subscribe(self, client, userdata, mid, reason_code_list, properties): + # """Callback function for when the broker responds to a subscription request.""" + # if reason_code_list[0].is_failure: + # print(f"❌ Broker rejected subscription: {reason_code_list[0]}") + # else: + # print(f"✅ Broker accepted subscription with QoS: {reason_code_list[0].value}") + + # --- (The rest of the file remains the same) --- + @pyqtSlot() + def disconnect_from_broker(self): + """Stops the MQTT client's network loop.""" + if self.client: + self.client.loop_stop() + self.client.disconnect() + print("Stopping MQTT network loop.") + + def subscribe_to_topic(self, topic): # Add qos parameter + print(f"Subscribing to topic: {topic}") + self.client.subscribe(topic) + + def publish_message(self, topic, payload): + self.client.publish(topic, payload) + + def cleanup(self): + print("Stopping MQTT network loop.") + self.client.loop_stop() \ No newline at end of file diff --git a/logo/black.png b/logo/black.png new file mode 100644 index 0000000..af3961d Binary files /dev/null and b/logo/black.png differ diff --git a/logo/v_logo.png b/logo/v_logo.png new file mode 100644 index 0000000..9de36e5 Binary files /dev/null and b/logo/v_logo.png differ diff --git a/logo/vec_logo.png b/logo/vec_logo.png new file mode 100644 index 0000000..55ec978 Binary files /dev/null and b/logo/vec_logo.png differ diff --git a/logo/vec_logo_svg.svg b/logo/vec_logo_svg.svg new file mode 100644 index 0000000..e3f588f --- /dev/null +++ b/logo/vec_logo_svg.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/white.jpeg b/logo/white.jpeg new file mode 100644 index 0000000..46061dd Binary files /dev/null and b/logo/white.jpeg differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..851922b --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +import sys +from PyQt6.QtWidgets import QApplication +from ui.main_window import MainWindow + +if __name__ == "__main__": + app = QApplication(sys.argv) + + # --- DYNAMIC SCALING LOGIC --- + BASE_HEIGHT = 1080.0 + screen = app.primaryScreen() + available_height = screen.availableGeometry().height() + scale_factor = max(0.7, available_height / BASE_HEIGHT) + + window = MainWindow(scale_factor=scale_factor) + window.showMaximized() + sys.exit(app.exec()) \ No newline at end of file diff --git a/proto/__init__.py b/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proto/vec_payload_chgSt.options b/proto/vec_payload_chgSt.options new file mode 100644 index 0000000..1a01715 --- /dev/null +++ b/proto/vec_payload_chgSt.options @@ -0,0 +1,28 @@ + +slotLevelPayload.batteryIdentification max_length: 32 + +mainPayload.switchStatus max_count: 9 fixed_count:true +mainPayload.sessionId max_length: 64 +mainPayload.slotLevelPayload max_count:9 fixed_count:true +mainPayload.deviceId max_length: 30 + +eventPayload.deviceId max_length: 30 +eventPayload.sessionId max_length: 64 + +eventData_s.nfcUuid max_length: 64 +eventData_s.batteryIdentification max_length: 32 + + + +rpcResponse.deviceId max_length:30 +rpcResponse.jobId max_length:64 + + +rpcRequest.jobId max_length:64 + +rpcData_s.slotsData max_count: 18 fixed_count:true +rpcData_s.sessionId max_length: 64 + + +nfcPayload_s.manufacturingData max_length: 16 +nfcPayload_s.customData max_length: 128 \ No newline at end of file diff --git a/proto/vec_payload_chgSt.proto b/proto/vec_payload_chgSt.proto new file mode 100644 index 0000000..521b42c --- /dev/null +++ b/proto/vec_payload_chgSt.proto @@ -0,0 +1,162 @@ +syntax = "proto2"; + +enum eventType_e { + EVENT_SWAP_START = 0x200; + EVENT_BATTERY_ENTRY = 0x201; + EVENT_BATTERY_EXIT = 0x202; + EVENT_ACTIVITY_FAILED = 0x203; + EVENT_SWAP_ABORTED = 0x204; + EVENT_BATFAULT_ALARM = 0x205; + EVENT_SLOT_LOCK_ENEGAGED = 0x206; + EVENT_SWAP_ENDED = 0x207; + EVENT_CHGFAULT_ALARM = 0x208; + EVENT_NFC_SCAN = 0x209; + EVENT_SLOT_LOCK_DISENEGAGED = 0x20A; + EVENT_REVERSE_SWAP = 0x20B; +} + +enum jobType_e { + JOBTYPE_NONE = 0; + JOBTYPE_GET_STATUS_OF_A_JOB = 0x01; + JOBTYPE_SWAP_START = 0x100; + JOBTYPE_CHARGER_ENABLE_DISABLE = 0x101; + JOBTYPE_GATE_OPEN_CLOSE = 0x102; + JOBTYPE_TRANSACTION_ABORT = 0x103; + JOBTYPE_REBOOT = 0x104; + JOBTYPE_SWAP_DENY = 0x105; + JOBTYPE_LANGUAGE_UPDATE = 0x106; +} + +enum jobResult_e { + JOB_RESULT_UNKNOWN = 0; + JOB_RESULT_SUCCESS = 1; + JOB_RESULT_REJECTED = 2; + JOB_RESULT_TIMEOUT = 3; +} + +enum jobStatus_e { + JOB_STATUS_IDLE = 0; + JOB_STATUS_PENDING = 1; + JOB_STATUS_EXECUTING = 2; + JOB_STATUS_EXECUTED = 3; +} + +message slotLevelPayload{ + optional uint32 batteryPresent = 1; + optional uint32 chargerPresent = 2; + optional uint32 doorLockStatus = 3; + optional uint32 doorStatus = 4; + optional uint32 voltage = 5; + optional int32 current = 6; + optional uint32 batteryFaultCode = 7; + optional uint32 chargerFaultCode = 8; + optional int32 batteryMaxTemp = 9; + optional int32 chargerMaxTemp= 10; + optional string batteryIdentification = 11; + optional uint32 batteryMode = 12; + optional uint32 chargerMode = 13; + optional int32 slotTemperature = 14; + optional uint32 gasSensor = 15; + optional uint32 soc=16; + optional uint32 ts = 17; +} + +message mainPayload{ + required uint32 ts = 1; + required string deviceId = 2; + required string sessionId = 3; + repeated slotLevelPayload slotLevelPayload = 4; + optional uint32 backupSupplyStatus = 5; + repeated uint32 switchStatus = 6; + optional uint32 stationStatus = 7; + optional uint32 stationDiagnosticCode = 8; + repeated float coordinates = 9; +} + +enum swapAbortReason_e{ + ABORT_UNKNOWN=0; + ABORT_BAT_EXIT_TIMEOUT=1; + ABORT_BAT_ENTRY_TIMEOUT=2; + ABORT_DOOR_CLOSE_TIMEOUT=3; + ABORT_DOOR_OPEN_TIMEOUT=4; + ABORT_INVALID_PARAM=5; + ABORT_REMOTE_REQUESTED=6; + ABORT_INVALID_BATTERY=7; +} + +enum swapDenyReason_e{ + SWAP_DENY_INSUFFICIENT_BAL=1; + SWAP_DENY_INVALID_NFC=2; + SWAP_DENY_BATTERY_UNAVAILABLE=3; +} + +enum languageType_e{ + LANGUAGE_TYPE_ENGLISH = 1; + LANGUAGE_TYPE_HINDI = 2; + LANGUAGE_TYPE_KANNADA = 3; + LANGUAGE_TYPE_TELUGU = 4; +} + +message nfcPayload_s{ + required string manufacturingData = 1; + required string customData = 2; +} + +message eventData_s { + optional nfcPayload_s nfcData = 1; + optional string batteryIdentification = 2; + optional uint32 activityFailureReason = 3; + optional swapAbortReason_e swapAbortReason = 4; + optional uint32 swapTime = 5; + optional uint32 faultCode = 6; + optional uint32 doorStatus = 7; + optional uint32 slotId = 8; +} + +message eventPayload { + required uint32 ts = 1; + required string deviceId = 2; + required eventType_e eventType = 3; + required string sessionId = 4; + optional eventData_s eventData = 5; +} + +message rpcData_s { + optional string sessionId = 1; + repeated uint32 slotsData = 2; +} + +message slotControl_s { + required uint32 slotId = 1; + required uint32 state = 2; +} + +message getJobStatusByJobId_s{ + required string jobId = 1; +} + +message rpcRequest { + required uint32 ts = 1; + required string jobId = 2; + required jobType_e jobType = 3; + optional rpcData_s rpcData = 4; + optional slotControl_s slotInfo = 5; + optional swapDenyReason_e swapDeny = 8; + optional getJobStatusByJobId_s getJobStatusByJobId = 9; + optional languageType_e languageType = 10; +} + +message jobStatusByJobIdResponse_s{ + required string jobId = 1; + required jobStatus_e jobStatus = 2; + required jobResult_e jobResult = 3; +} + +message rpcResponse { + required uint32 ts = 1; + required string deviceId = 2; + required string jobId = 3; + required jobStatus_e jobStatus = 4; + required jobResult_e jobResult = 5; + optional jobStatusByJobIdResponse_s jobStatusByJobIdResponse = 6; +} \ No newline at end of file diff --git a/proto/vec_payload_chgSt_pb2.py b/proto/vec_payload_chgSt_pb2.py new file mode 100644 index 0000000..a18742f --- /dev/null +++ b/proto/vec_payload_chgSt_pb2.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: vec_payload_chgSt.proto +# Protobuf Python Version: 6.32.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 32, + 0, + '', + 'vec_payload_chgSt.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17vec_payload_chgSt.proto\"\x82\x03\n\x10slotLevelPayload\x12\x16\n\x0e\x62\x61tteryPresent\x18\x01 \x01(\r\x12\x16\n\x0e\x63hargerPresent\x18\x02 \x01(\r\x12\x16\n\x0e\x64oorLockStatus\x18\x03 \x01(\r\x12\x12\n\ndoorStatus\x18\x04 \x01(\r\x12\x0f\n\x07voltage\x18\x05 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x06 \x01(\x05\x12\x18\n\x10\x62\x61tteryFaultCode\x18\x07 \x01(\r\x12\x18\n\x10\x63hargerFaultCode\x18\x08 \x01(\r\x12\x16\n\x0e\x62\x61tteryMaxTemp\x18\t \x01(\x05\x12\x16\n\x0e\x63hargerMaxTemp\x18\n \x01(\x05\x12\x1d\n\x15\x62\x61tteryIdentification\x18\x0b \x01(\t\x12\x13\n\x0b\x62\x61tteryMode\x18\x0c \x01(\r\x12\x13\n\x0b\x63hargerMode\x18\r \x01(\r\x12\x17\n\x0fslotTemperature\x18\x0e \x01(\x05\x12\x11\n\tgasSensor\x18\x0f \x01(\r\x12\x0b\n\x03soc\x18\x10 \x01(\r\x12\n\n\x02ts\x18\x11 \x01(\r\"\xe8\x01\n\x0bmainPayload\x12\n\n\x02ts\x18\x01 \x02(\r\x12\x10\n\x08\x64\x65viceId\x18\x02 \x02(\t\x12\x11\n\tsessionId\x18\x03 \x02(\t\x12+\n\x10slotLevelPayload\x18\x04 \x03(\x0b\x32\x11.slotLevelPayload\x12\x1a\n\x12\x62\x61\x63kupSupplyStatus\x18\x05 \x01(\r\x12\x14\n\x0cswitchStatus\x18\x06 \x03(\r\x12\x15\n\rstationStatus\x18\x07 \x01(\r\x12\x1d\n\x15stationDiagnosticCode\x18\x08 \x01(\r\x12\x13\n\x0b\x63oordinates\x18\t \x03(\x02\"=\n\x0cnfcPayload_s\x12\x19\n\x11manufacturingData\x18\x01 \x02(\t\x12\x12\n\ncustomData\x18\x02 \x02(\t\"\xe1\x01\n\x0b\x65ventData_s\x12\x1e\n\x07nfcData\x18\x01 \x01(\x0b\x32\r.nfcPayload_s\x12\x1d\n\x15\x62\x61tteryIdentification\x18\x02 \x01(\t\x12\x1d\n\x15\x61\x63tivityFailureReason\x18\x03 \x01(\r\x12+\n\x0fswapAbortReason\x18\x04 \x01(\x0e\x32\x12.swapAbortReason_e\x12\x10\n\x08swapTime\x18\x05 \x01(\r\x12\x11\n\tfaultCode\x18\x06 \x01(\r\x12\x12\n\ndoorStatus\x18\x07 \x01(\r\x12\x0e\n\x06slotId\x18\x08 \x01(\r\"\x81\x01\n\x0c\x65ventPayload\x12\n\n\x02ts\x18\x01 \x02(\r\x12\x10\n\x08\x64\x65viceId\x18\x02 \x02(\t\x12\x1f\n\teventType\x18\x03 \x02(\x0e\x32\x0c.eventType_e\x12\x11\n\tsessionId\x18\x04 \x02(\t\x12\x1f\n\teventData\x18\x05 \x01(\x0b\x32\x0c.eventData_s\"1\n\trpcData_s\x12\x11\n\tsessionId\x18\x01 \x01(\t\x12\x11\n\tslotsData\x18\x02 \x03(\r\".\n\rslotControl_s\x12\x0e\n\x06slotId\x18\x01 \x02(\r\x12\r\n\x05state\x18\x02 \x02(\r\"&\n\x15getJobStatusByJobId_s\x12\r\n\x05jobId\x18\x01 \x02(\t\"\x84\x02\n\nrpcRequest\x12\n\n\x02ts\x18\x01 \x02(\r\x12\r\n\x05jobId\x18\x02 \x02(\t\x12\x1b\n\x07jobType\x18\x03 \x02(\x0e\x32\n.jobType_e\x12\x1b\n\x07rpcData\x18\x04 \x01(\x0b\x32\n.rpcData_s\x12 \n\x08slotInfo\x18\x05 \x01(\x0b\x32\x0e.slotControl_s\x12#\n\x08swapDeny\x18\x08 \x01(\x0e\x32\x11.swapDenyReason_e\x12\x33\n\x13getJobStatusByJobId\x18\t \x01(\x0b\x32\x16.getJobStatusByJobId_s\x12%\n\x0clanguageType\x18\n \x01(\x0e\x32\x0f.languageType_e\"m\n\x1ajobStatusByJobIdResponse_s\x12\r\n\x05jobId\x18\x01 \x02(\t\x12\x1f\n\tjobStatus\x18\x02 \x02(\x0e\x32\x0c.jobStatus_e\x12\x1f\n\tjobResult\x18\x03 \x02(\x0e\x32\x0c.jobResult_e\"\xbb\x01\n\x0brpcResponse\x12\n\n\x02ts\x18\x01 \x02(\r\x12\x10\n\x08\x64\x65viceId\x18\x02 \x02(\t\x12\r\n\x05jobId\x18\x03 \x02(\t\x12\x1f\n\tjobStatus\x18\x04 \x02(\x0e\x32\x0c.jobStatus_e\x12\x1f\n\tjobResult\x18\x05 \x02(\x0e\x32\x0c.jobResult_e\x12=\n\x18jobStatusByJobIdResponse\x18\x06 \x01(\x0b\x32\x1b.jobStatusByJobIdResponse_s*\xc8\x02\n\x0b\x65ventType_e\x12\x15\n\x10\x45VENT_SWAP_START\x10\x80\x04\x12\x18\n\x13\x45VENT_BATTERY_ENTRY\x10\x81\x04\x12\x17\n\x12\x45VENT_BATTERY_EXIT\x10\x82\x04\x12\x1a\n\x15\x45VENT_ACTIVITY_FAILED\x10\x83\x04\x12\x17\n\x12\x45VENT_SWAP_ABORTED\x10\x84\x04\x12\x19\n\x14\x45VENT_BATFAULT_ALARM\x10\x85\x04\x12\x1d\n\x18\x45VENT_SLOT_LOCK_ENEGAGED\x10\x86\x04\x12\x15\n\x10\x45VENT_SWAP_ENDED\x10\x87\x04\x12\x19\n\x14\x45VENT_CHGFAULT_ALARM\x10\x88\x04\x12\x13\n\x0e\x45VENT_NFC_SCAN\x10\x89\x04\x12 \n\x1b\x45VENT_SLOT_LOCK_DISENEGAGED\x10\x8a\x04\x12\x17\n\x12\x45VENT_REVERSE_SWAP\x10\x8b\x04*\x85\x02\n\tjobType_e\x12\x10\n\x0cJOBTYPE_NONE\x10\x00\x12\x1f\n\x1bJOBTYPE_GET_STATUS_OF_A_JOB\x10\x01\x12\x17\n\x12JOBTYPE_SWAP_START\x10\x80\x02\x12#\n\x1eJOBTYPE_CHARGER_ENABLE_DISABLE\x10\x81\x02\x12\x1c\n\x17JOBTYPE_GATE_OPEN_CLOSE\x10\x82\x02\x12\x1e\n\x19JOBTYPE_TRANSACTION_ABORT\x10\x83\x02\x12\x13\n\x0eJOBTYPE_REBOOT\x10\x84\x02\x12\x16\n\x11JOBTYPE_SWAP_DENY\x10\x85\x02\x12\x1c\n\x17JOBTYPE_LANGUAGE_UPDATE\x10\x86\x02*n\n\x0bjobResult_e\x12\x16\n\x12JOB_RESULT_UNKNOWN\x10\x00\x12\x16\n\x12JOB_RESULT_SUCCESS\x10\x01\x12\x17\n\x13JOB_RESULT_REJECTED\x10\x02\x12\x16\n\x12JOB_RESULT_TIMEOUT\x10\x03*m\n\x0bjobStatus_e\x12\x13\n\x0fJOB_STATUS_IDLE\x10\x00\x12\x16\n\x12JOB_STATUS_PENDING\x10\x01\x12\x18\n\x14JOB_STATUS_EXECUTING\x10\x02\x12\x17\n\x13JOB_STATUS_EXECUTED\x10\x03*\xea\x01\n\x11swapAbortReason_e\x12\x11\n\rABORT_UNKNOWN\x10\x00\x12\x1a\n\x16\x41\x42ORT_BAT_EXIT_TIMEOUT\x10\x01\x12\x1b\n\x17\x41\x42ORT_BAT_ENTRY_TIMEOUT\x10\x02\x12\x1c\n\x18\x41\x42ORT_DOOR_CLOSE_TIMEOUT\x10\x03\x12\x1b\n\x17\x41\x42ORT_DOOR_OPEN_TIMEOUT\x10\x04\x12\x17\n\x13\x41\x42ORT_INVALID_PARAM\x10\x05\x12\x1a\n\x16\x41\x42ORT_REMOTE_REQUESTED\x10\x06\x12\x19\n\x15\x41\x42ORT_INVALID_BATTERY\x10\x07*p\n\x10swapDenyReason_e\x12\x1e\n\x1aSWAP_DENY_INSUFFICIENT_BAL\x10\x01\x12\x19\n\x15SWAP_DENY_INVALID_NFC\x10\x02\x12!\n\x1dSWAP_DENY_BATTERY_UNAVAILABLE\x10\x03*y\n\x0elanguageType_e\x12\x19\n\x15LANGUAGE_TYPE_ENGLISH\x10\x01\x12\x17\n\x13LANGUAGE_TYPE_HINDI\x10\x02\x12\x19\n\x15LANGUAGE_TYPE_KANNADA\x10\x03\x12\x18\n\x14LANGUAGE_TYPE_TELUGU\x10\x04') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vec_payload_chgSt_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_EVENTTYPE_E']._serialized_start=1778 + _globals['_EVENTTYPE_E']._serialized_end=2106 + _globals['_JOBTYPE_E']._serialized_start=2109 + _globals['_JOBTYPE_E']._serialized_end=2370 + _globals['_JOBRESULT_E']._serialized_start=2372 + _globals['_JOBRESULT_E']._serialized_end=2482 + _globals['_JOBSTATUS_E']._serialized_start=2484 + _globals['_JOBSTATUS_E']._serialized_end=2593 + _globals['_SWAPABORTREASON_E']._serialized_start=2596 + _globals['_SWAPABORTREASON_E']._serialized_end=2830 + _globals['_SWAPDENYREASON_E']._serialized_start=2832 + _globals['_SWAPDENYREASON_E']._serialized_end=2944 + _globals['_LANGUAGETYPE_E']._serialized_start=2946 + _globals['_LANGUAGETYPE_E']._serialized_end=3067 + _globals['_SLOTLEVELPAYLOAD']._serialized_start=28 + _globals['_SLOTLEVELPAYLOAD']._serialized_end=414 + _globals['_MAINPAYLOAD']._serialized_start=417 + _globals['_MAINPAYLOAD']._serialized_end=649 + _globals['_NFCPAYLOAD_S']._serialized_start=651 + _globals['_NFCPAYLOAD_S']._serialized_end=712 + _globals['_EVENTDATA_S']._serialized_start=715 + _globals['_EVENTDATA_S']._serialized_end=940 + _globals['_EVENTPAYLOAD']._serialized_start=943 + _globals['_EVENTPAYLOAD']._serialized_end=1072 + _globals['_RPCDATA_S']._serialized_start=1074 + _globals['_RPCDATA_S']._serialized_end=1123 + _globals['_SLOTCONTROL_S']._serialized_start=1125 + _globals['_SLOTCONTROL_S']._serialized_end=1171 + _globals['_GETJOBSTATUSBYJOBID_S']._serialized_start=1173 + _globals['_GETJOBSTATUSBYJOBID_S']._serialized_end=1211 + _globals['_RPCREQUEST']._serialized_start=1214 + _globals['_RPCREQUEST']._serialized_end=1474 + _globals['_JOBSTATUSBYJOBIDRESPONSE_S']._serialized_start=1476 + _globals['_JOBSTATUSBYJOBIDRESPONSE_S']._serialized_end=1585 + _globals['_RPCRESPONSE']._serialized_start=1588 + _globals['_RPCRESPONSE']._serialized_end=1775 +# @@protoc_insertion_point(module_scope) diff --git a/proto/vec_payload_chgSt_pb2.pyi b/proto/vec_payload_chgSt_pb2.pyi new file mode 100644 index 0000000..64a2210 --- /dev/null +++ b/proto/vec_payload_chgSt_pb2.pyi @@ -0,0 +1,287 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class eventType_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EVENT_SWAP_START: _ClassVar[eventType_e] + EVENT_BATTERY_ENTRY: _ClassVar[eventType_e] + EVENT_BATTERY_EXIT: _ClassVar[eventType_e] + EVENT_ACTIVITY_FAILED: _ClassVar[eventType_e] + EVENT_SWAP_ABORTED: _ClassVar[eventType_e] + EVENT_BATFAULT_ALARM: _ClassVar[eventType_e] + EVENT_SLOT_LOCK_ENEGAGED: _ClassVar[eventType_e] + EVENT_SWAP_ENDED: _ClassVar[eventType_e] + EVENT_CHGFAULT_ALARM: _ClassVar[eventType_e] + EVENT_NFC_SCAN: _ClassVar[eventType_e] + EVENT_SLOT_LOCK_DISENEGAGED: _ClassVar[eventType_e] + EVENT_REVERSE_SWAP: _ClassVar[eventType_e] + +class jobType_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + JOBTYPE_NONE: _ClassVar[jobType_e] + JOBTYPE_GET_STATUS_OF_A_JOB: _ClassVar[jobType_e] + JOBTYPE_SWAP_START: _ClassVar[jobType_e] + JOBTYPE_CHARGER_ENABLE_DISABLE: _ClassVar[jobType_e] + JOBTYPE_GATE_OPEN_CLOSE: _ClassVar[jobType_e] + JOBTYPE_TRANSACTION_ABORT: _ClassVar[jobType_e] + JOBTYPE_REBOOT: _ClassVar[jobType_e] + JOBTYPE_SWAP_DENY: _ClassVar[jobType_e] + JOBTYPE_LANGUAGE_UPDATE: _ClassVar[jobType_e] + +class jobResult_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + JOB_RESULT_UNKNOWN: _ClassVar[jobResult_e] + JOB_RESULT_SUCCESS: _ClassVar[jobResult_e] + JOB_RESULT_REJECTED: _ClassVar[jobResult_e] + JOB_RESULT_TIMEOUT: _ClassVar[jobResult_e] + +class jobStatus_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + JOB_STATUS_IDLE: _ClassVar[jobStatus_e] + JOB_STATUS_PENDING: _ClassVar[jobStatus_e] + JOB_STATUS_EXECUTING: _ClassVar[jobStatus_e] + JOB_STATUS_EXECUTED: _ClassVar[jobStatus_e] + +class swapAbortReason_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ABORT_UNKNOWN: _ClassVar[swapAbortReason_e] + ABORT_BAT_EXIT_TIMEOUT: _ClassVar[swapAbortReason_e] + ABORT_BAT_ENTRY_TIMEOUT: _ClassVar[swapAbortReason_e] + ABORT_DOOR_CLOSE_TIMEOUT: _ClassVar[swapAbortReason_e] + ABORT_DOOR_OPEN_TIMEOUT: _ClassVar[swapAbortReason_e] + ABORT_INVALID_PARAM: _ClassVar[swapAbortReason_e] + ABORT_REMOTE_REQUESTED: _ClassVar[swapAbortReason_e] + ABORT_INVALID_BATTERY: _ClassVar[swapAbortReason_e] + +class swapDenyReason_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SWAP_DENY_INSUFFICIENT_BAL: _ClassVar[swapDenyReason_e] + SWAP_DENY_INVALID_NFC: _ClassVar[swapDenyReason_e] + SWAP_DENY_BATTERY_UNAVAILABLE: _ClassVar[swapDenyReason_e] + +class languageType_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + LANGUAGE_TYPE_ENGLISH: _ClassVar[languageType_e] + LANGUAGE_TYPE_HINDI: _ClassVar[languageType_e] + LANGUAGE_TYPE_KANNADA: _ClassVar[languageType_e] + LANGUAGE_TYPE_TELUGU: _ClassVar[languageType_e] +EVENT_SWAP_START: eventType_e +EVENT_BATTERY_ENTRY: eventType_e +EVENT_BATTERY_EXIT: eventType_e +EVENT_ACTIVITY_FAILED: eventType_e +EVENT_SWAP_ABORTED: eventType_e +EVENT_BATFAULT_ALARM: eventType_e +EVENT_SLOT_LOCK_ENEGAGED: eventType_e +EVENT_SWAP_ENDED: eventType_e +EVENT_CHGFAULT_ALARM: eventType_e +EVENT_NFC_SCAN: eventType_e +EVENT_SLOT_LOCK_DISENEGAGED: eventType_e +EVENT_REVERSE_SWAP: eventType_e +JOBTYPE_NONE: jobType_e +JOBTYPE_GET_STATUS_OF_A_JOB: jobType_e +JOBTYPE_SWAP_START: jobType_e +JOBTYPE_CHARGER_ENABLE_DISABLE: jobType_e +JOBTYPE_GATE_OPEN_CLOSE: jobType_e +JOBTYPE_TRANSACTION_ABORT: jobType_e +JOBTYPE_REBOOT: jobType_e +JOBTYPE_SWAP_DENY: jobType_e +JOBTYPE_LANGUAGE_UPDATE: jobType_e +JOB_RESULT_UNKNOWN: jobResult_e +JOB_RESULT_SUCCESS: jobResult_e +JOB_RESULT_REJECTED: jobResult_e +JOB_RESULT_TIMEOUT: jobResult_e +JOB_STATUS_IDLE: jobStatus_e +JOB_STATUS_PENDING: jobStatus_e +JOB_STATUS_EXECUTING: jobStatus_e +JOB_STATUS_EXECUTED: jobStatus_e +ABORT_UNKNOWN: swapAbortReason_e +ABORT_BAT_EXIT_TIMEOUT: swapAbortReason_e +ABORT_BAT_ENTRY_TIMEOUT: swapAbortReason_e +ABORT_DOOR_CLOSE_TIMEOUT: swapAbortReason_e +ABORT_DOOR_OPEN_TIMEOUT: swapAbortReason_e +ABORT_INVALID_PARAM: swapAbortReason_e +ABORT_REMOTE_REQUESTED: swapAbortReason_e +ABORT_INVALID_BATTERY: swapAbortReason_e +SWAP_DENY_INSUFFICIENT_BAL: swapDenyReason_e +SWAP_DENY_INVALID_NFC: swapDenyReason_e +SWAP_DENY_BATTERY_UNAVAILABLE: swapDenyReason_e +LANGUAGE_TYPE_ENGLISH: languageType_e +LANGUAGE_TYPE_HINDI: languageType_e +LANGUAGE_TYPE_KANNADA: languageType_e +LANGUAGE_TYPE_TELUGU: languageType_e + +class slotLevelPayload(_message.Message): + __slots__ = ("batteryPresent", "chargerPresent", "doorLockStatus", "doorStatus", "voltage", "current", "batteryFaultCode", "chargerFaultCode", "batteryMaxTemp", "chargerMaxTemp", "batteryIdentification", "batteryMode", "chargerMode", "slotTemperature", "gasSensor", "soc", "ts") + BATTERYPRESENT_FIELD_NUMBER: _ClassVar[int] + CHARGERPRESENT_FIELD_NUMBER: _ClassVar[int] + DOORLOCKSTATUS_FIELD_NUMBER: _ClassVar[int] + DOORSTATUS_FIELD_NUMBER: _ClassVar[int] + VOLTAGE_FIELD_NUMBER: _ClassVar[int] + CURRENT_FIELD_NUMBER: _ClassVar[int] + BATTERYFAULTCODE_FIELD_NUMBER: _ClassVar[int] + CHARGERFAULTCODE_FIELD_NUMBER: _ClassVar[int] + BATTERYMAXTEMP_FIELD_NUMBER: _ClassVar[int] + CHARGERMAXTEMP_FIELD_NUMBER: _ClassVar[int] + BATTERYIDENTIFICATION_FIELD_NUMBER: _ClassVar[int] + BATTERYMODE_FIELD_NUMBER: _ClassVar[int] + CHARGERMODE_FIELD_NUMBER: _ClassVar[int] + SLOTTEMPERATURE_FIELD_NUMBER: _ClassVar[int] + GASSENSOR_FIELD_NUMBER: _ClassVar[int] + SOC_FIELD_NUMBER: _ClassVar[int] + TS_FIELD_NUMBER: _ClassVar[int] + batteryPresent: int + chargerPresent: int + doorLockStatus: int + doorStatus: int + voltage: int + current: int + batteryFaultCode: int + chargerFaultCode: int + batteryMaxTemp: int + chargerMaxTemp: int + batteryIdentification: str + batteryMode: int + chargerMode: int + slotTemperature: int + gasSensor: int + soc: int + ts: int + def __init__(self, batteryPresent: _Optional[int] = ..., chargerPresent: _Optional[int] = ..., doorLockStatus: _Optional[int] = ..., doorStatus: _Optional[int] = ..., voltage: _Optional[int] = ..., current: _Optional[int] = ..., batteryFaultCode: _Optional[int] = ..., chargerFaultCode: _Optional[int] = ..., batteryMaxTemp: _Optional[int] = ..., chargerMaxTemp: _Optional[int] = ..., batteryIdentification: _Optional[str] = ..., batteryMode: _Optional[int] = ..., chargerMode: _Optional[int] = ..., slotTemperature: _Optional[int] = ..., gasSensor: _Optional[int] = ..., soc: _Optional[int] = ..., ts: _Optional[int] = ...) -> None: ... + +class mainPayload(_message.Message): + __slots__ = ("ts", "deviceId", "sessionId", "slotLevelPayload", "backupSupplyStatus", "switchStatus", "stationStatus", "stationDiagnosticCode", "coordinates") + TS_FIELD_NUMBER: _ClassVar[int] + DEVICEID_FIELD_NUMBER: _ClassVar[int] + SESSIONID_FIELD_NUMBER: _ClassVar[int] + SLOTLEVELPAYLOAD_FIELD_NUMBER: _ClassVar[int] + BACKUPSUPPLYSTATUS_FIELD_NUMBER: _ClassVar[int] + SWITCHSTATUS_FIELD_NUMBER: _ClassVar[int] + STATIONSTATUS_FIELD_NUMBER: _ClassVar[int] + STATIONDIAGNOSTICCODE_FIELD_NUMBER: _ClassVar[int] + COORDINATES_FIELD_NUMBER: _ClassVar[int] + ts: int + deviceId: str + sessionId: str + slotLevelPayload: _containers.RepeatedCompositeFieldContainer[slotLevelPayload] + backupSupplyStatus: int + switchStatus: _containers.RepeatedScalarFieldContainer[int] + stationStatus: int + stationDiagnosticCode: int + coordinates: _containers.RepeatedScalarFieldContainer[float] + def __init__(self, ts: _Optional[int] = ..., deviceId: _Optional[str] = ..., sessionId: _Optional[str] = ..., slotLevelPayload: _Optional[_Iterable[_Union[slotLevelPayload, _Mapping]]] = ..., backupSupplyStatus: _Optional[int] = ..., switchStatus: _Optional[_Iterable[int]] = ..., stationStatus: _Optional[int] = ..., stationDiagnosticCode: _Optional[int] = ..., coordinates: _Optional[_Iterable[float]] = ...) -> None: ... + +class nfcPayload_s(_message.Message): + __slots__ = ("manufacturingData", "customData") + MANUFACTURINGDATA_FIELD_NUMBER: _ClassVar[int] + CUSTOMDATA_FIELD_NUMBER: _ClassVar[int] + manufacturingData: str + customData: str + def __init__(self, manufacturingData: _Optional[str] = ..., customData: _Optional[str] = ...) -> None: ... + +class eventData_s(_message.Message): + __slots__ = ("nfcData", "batteryIdentification", "activityFailureReason", "swapAbortReason", "swapTime", "faultCode", "doorStatus", "slotId") + NFCDATA_FIELD_NUMBER: _ClassVar[int] + BATTERYIDENTIFICATION_FIELD_NUMBER: _ClassVar[int] + ACTIVITYFAILUREREASON_FIELD_NUMBER: _ClassVar[int] + SWAPABORTREASON_FIELD_NUMBER: _ClassVar[int] + SWAPTIME_FIELD_NUMBER: _ClassVar[int] + FAULTCODE_FIELD_NUMBER: _ClassVar[int] + DOORSTATUS_FIELD_NUMBER: _ClassVar[int] + SLOTID_FIELD_NUMBER: _ClassVar[int] + nfcData: nfcPayload_s + batteryIdentification: str + activityFailureReason: int + swapAbortReason: swapAbortReason_e + swapTime: int + faultCode: int + doorStatus: int + slotId: int + def __init__(self, nfcData: _Optional[_Union[nfcPayload_s, _Mapping]] = ..., batteryIdentification: _Optional[str] = ..., activityFailureReason: _Optional[int] = ..., swapAbortReason: _Optional[_Union[swapAbortReason_e, str]] = ..., swapTime: _Optional[int] = ..., faultCode: _Optional[int] = ..., doorStatus: _Optional[int] = ..., slotId: _Optional[int] = ...) -> None: ... + +class eventPayload(_message.Message): + __slots__ = ("ts", "deviceId", "eventType", "sessionId", "eventData") + TS_FIELD_NUMBER: _ClassVar[int] + DEVICEID_FIELD_NUMBER: _ClassVar[int] + EVENTTYPE_FIELD_NUMBER: _ClassVar[int] + SESSIONID_FIELD_NUMBER: _ClassVar[int] + EVENTDATA_FIELD_NUMBER: _ClassVar[int] + ts: int + deviceId: str + eventType: eventType_e + sessionId: str + eventData: eventData_s + def __init__(self, ts: _Optional[int] = ..., deviceId: _Optional[str] = ..., eventType: _Optional[_Union[eventType_e, str]] = ..., sessionId: _Optional[str] = ..., eventData: _Optional[_Union[eventData_s, _Mapping]] = ...) -> None: ... + +class rpcData_s(_message.Message): + __slots__ = ("sessionId", "slotsData") + SESSIONID_FIELD_NUMBER: _ClassVar[int] + SLOTSDATA_FIELD_NUMBER: _ClassVar[int] + sessionId: str + slotsData: _containers.RepeatedScalarFieldContainer[int] + def __init__(self, sessionId: _Optional[str] = ..., slotsData: _Optional[_Iterable[int]] = ...) -> None: ... + +class slotControl_s(_message.Message): + __slots__ = ("slotId", "state") + SLOTID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + slotId: int + state: int + def __init__(self, slotId: _Optional[int] = ..., state: _Optional[int] = ...) -> None: ... + +class getJobStatusByJobId_s(_message.Message): + __slots__ = ("jobId",) + JOBID_FIELD_NUMBER: _ClassVar[int] + jobId: str + def __init__(self, jobId: _Optional[str] = ...) -> None: ... + +class rpcRequest(_message.Message): + __slots__ = ("ts", "jobId", "jobType", "rpcData", "slotInfo", "swapDeny", "getJobStatusByJobId", "languageType") + TS_FIELD_NUMBER: _ClassVar[int] + JOBID_FIELD_NUMBER: _ClassVar[int] + JOBTYPE_FIELD_NUMBER: _ClassVar[int] + RPCDATA_FIELD_NUMBER: _ClassVar[int] + SLOTINFO_FIELD_NUMBER: _ClassVar[int] + SWAPDENY_FIELD_NUMBER: _ClassVar[int] + GETJOBSTATUSBYJOBID_FIELD_NUMBER: _ClassVar[int] + LANGUAGETYPE_FIELD_NUMBER: _ClassVar[int] + ts: int + jobId: str + jobType: jobType_e + rpcData: rpcData_s + slotInfo: slotControl_s + swapDeny: swapDenyReason_e + getJobStatusByJobId: getJobStatusByJobId_s + languageType: languageType_e + def __init__(self, ts: _Optional[int] = ..., jobId: _Optional[str] = ..., jobType: _Optional[_Union[jobType_e, str]] = ..., rpcData: _Optional[_Union[rpcData_s, _Mapping]] = ..., slotInfo: _Optional[_Union[slotControl_s, _Mapping]] = ..., swapDeny: _Optional[_Union[swapDenyReason_e, str]] = ..., getJobStatusByJobId: _Optional[_Union[getJobStatusByJobId_s, _Mapping]] = ..., languageType: _Optional[_Union[languageType_e, str]] = ...) -> None: ... + +class jobStatusByJobIdResponse_s(_message.Message): + __slots__ = ("jobId", "jobStatus", "jobResult") + JOBID_FIELD_NUMBER: _ClassVar[int] + JOBSTATUS_FIELD_NUMBER: _ClassVar[int] + JOBRESULT_FIELD_NUMBER: _ClassVar[int] + jobId: str + jobStatus: jobStatus_e + jobResult: jobResult_e + def __init__(self, jobId: _Optional[str] = ..., jobStatus: _Optional[_Union[jobStatus_e, str]] = ..., jobResult: _Optional[_Union[jobResult_e, str]] = ...) -> None: ... + +class rpcResponse(_message.Message): + __slots__ = ("ts", "deviceId", "jobId", "jobStatus", "jobResult", "jobStatusByJobIdResponse") + TS_FIELD_NUMBER: _ClassVar[int] + DEVICEID_FIELD_NUMBER: _ClassVar[int] + JOBID_FIELD_NUMBER: _ClassVar[int] + JOBSTATUS_FIELD_NUMBER: _ClassVar[int] + JOBRESULT_FIELD_NUMBER: _ClassVar[int] + JOBSTATUSBYJOBIDRESPONSE_FIELD_NUMBER: _ClassVar[int] + ts: int + deviceId: str + jobId: str + jobStatus: jobStatus_e + jobResult: jobResult_e + jobStatusByJobIdResponse: jobStatusByJobIdResponse_s + def __init__(self, ts: _Optional[int] = ..., deviceId: _Optional[str] = ..., jobId: _Optional[str] = ..., jobStatus: _Optional[_Union[jobStatus_e, str]] = ..., jobResult: _Optional[_Union[jobResult_e, str]] = ..., jobStatusByJobIdResponse: _Optional[_Union[jobStatusByJobIdResponse_s, _Mapping]] = ...) -> None: ... diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6483d9e Binary files /dev/null and b/requirements.txt differ diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..15f411f --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,1521 @@ +import datetime +import json +import os +import sys +import time +from functools import partial + +from PyQt6.QtCore import pyqtSignal, QThread, Qt, QPropertyAnimation, QEasingCurve, QSettings, pyqtSlot +from PyQt6.QtGui import QIcon, QFont, QPixmap, QCloseEvent +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, + QGroupBox, QFormLayout, QLineEdit, QPushButton, QLabel, QSpacerItem, + QSizePolicy, QGridLayout, QMessageBox, QComboBox, QPlainTextEdit, + QCheckBox, QFileDialog, QLayout, QFrame, QSizePolicy, QGraphicsOpacityEffect, QVBoxLayout, QTextBrowser, QScrollArea +) +from PyQt6.QtSvgWidgets import QSvgWidget +from google.protobuf.json_format import MessageToDict +from math import floor + + +# Make sure your proto import is correct for your project structure +from proto.vec_payload_chgSt_pb2 import mainPayload as periodicData, eventPayload, rpcRequest, jobType_e, eventType_e, languageType_e +from .styles import get_light_theme_styles, get_dark_theme_styles +from .widgets import ChamberWidget +from core.mqtt_client import MqttClient +from core.csv_logger import CsvLogger + +def resource_path(relative_path): + """ Get absolute path to resource, works for dev and for PyInstaller """ + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +class MainWindow(QMainWindow): + log_data_signal = pyqtSignal(list) + + def __init__(self, scale_factor=1.0): + super().__init__() + # self.setWindowIcon(QIcon("logo/v_logo.png")) + self.scale_factor = scale_factor + self.setWindowTitle("Battery Swap Station Dashboard") + self.setWindowIcon(QIcon(resource_path("assets/icon.ico"))) + + self.settings = QSettings("VECMOCON", "BatterySwapDashboard") + + self.is_dark_theme = True + + self.mqtt_thread = None + self.mqtt_client = None + self.swap_sequence = [] + self.logger_thread = None + self.csv_logger = None + self.swap_button_clicks = {i: 0 for i in range(1, 10)} + + self.animation = None + self.instance_log_area = None + + self.DIAGNOSTIC_ERRORS = [ + "Lock Power Cut Failure", + "Main Power Cut Failure", + "Relayboard Can Failure", + "DB Can Failure", + "MB Can Reception Failure", + "Smoke Alarm", + "Water Alarm", + "Phase Failure", + "Earth Leakage" + ] + + self.AUDIO_LANGUAGES = {"English": 1, "Hindi": 2, "Kannada": 3, "Telugu": 4, "Tamil": 5} + + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + self.main_layout = QVBoxLayout(self.central_widget) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(0) + + self.create_status_bar() + + self.tabs = QTabWidget() + self.main_layout.addWidget(self.tabs) + + self.config_tab = QWidget() + self.main_tab = QWidget() + self.logs_tab = QWidget() + self.help_tab = QWidget() + self.about_tab = QWidget() + + self.tabs.addTab(self.config_tab, "Config") + self.tabs.addTab(self.main_tab, "Main") + self.tabs.addTab(self.logs_tab, "Logs") + self.tabs.addTab(self.help_tab, "Help") + self.tabs.addTab(self.about_tab, "About") + + self.setup_config_ui() + self.setup_main_ui() + self.setup_logs_ui() + self.setup_help_ui() + self.setup_about_ui() + + self.load_settings() + self._apply_theme() + + def setup_help_ui(self): + """ + Polished Help page: + - Card-style container with title + - Sections: Quick Start, Warnings, Troubleshooting, Shortcuts + - Colored callouts for warnings/tips + - Scrollable if content grows + """ + # Clear existing layout + def clear_layout(w: QWidget): + lay = w.layout() + if not lay: return + while lay.count(): + it = lay.takeAt(0) + if it.widget(): it.widget().deleteLater() + elif it.layout(): + while it.layout().count(): + sub = it.layout().takeAt(0) + if sub.widget(): sub.widget().deleteLater() + it.layout().deleteLater() + lay.deleteLater() + + clear_layout(self.help_tab) + + root = QVBoxLayout(self.help_tab) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + # Scroll area so long help stays usable + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + + host = QWidget() + scroll_lay = QVBoxLayout(host) + scroll_lay.setContentsMargins(0, 0, 0, 0) + scroll_lay.setSpacing(12) + + # Card container + card = QFrame() + card.setObjectName("helpCard") + card.setStyleSheet(""" + #helpCard { + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 12px; + } + QLabel.title { + color: #eaeaea; + font-weight: 700; + font-size: 18px; + } + QLabel.subtitle { + color: #c8c8c8; + font-size: 12px; + } + QFrame.divider { + background: #3a3a3a; + min-height: 1px; max-height: 1px; border: none; + } + QLabel.h3 { + color: #e6e6e6; + font-weight: 600; + margin-top: 6px; + } + QLabel.body { + color: #dcdcdc; + } + /* Callouts */ + QFrame.warn { + background: #3b2e1b; + border: 1px solid #ffb74d; + border-radius: 8px; + } + QLabel.warnTitle { color: #ffd561; font-weight: 700; } + QLabel.warnText { color: #f0e0c0; } + QFrame.tip { + background: #1f3326; + border: 1px solid #62d39b; + border-radius: 8px; + } + QLabel.tipTitle { color: #a8f5c9; font-weight: 700; } + QLabel.tipText { color: #d6ffe9; } + QLabel.link { color: #6aa9ff; } + """) + card_lay = QVBoxLayout(card) + card_lay.setContentsMargins(18, 16, 18, 16) + card_lay.setSpacing(12) + + # Header + title = QLabel("Help & User Guide") + title.setProperty("class", "title") + subtitle = QLabel("Follow these steps to get connected, monitor the station, and troubleshoot issues.") + subtitle.setProperty("class", "subtitle") + + card_lay.addWidget(title) + card_lay.addWidget(subtitle) + + div1 = QFrame(); div1.setObjectName("divider"); div1.setFrameShape(QFrame.Shape.NoFrame); div1.setProperty("class", "divider") + card_lay.addWidget(div1) + + # Quick Start + qs_title = QLabel("Quick Start") + qs_title.setProperty("class", "h3") + qs = QLabel( + "" + ) + qs.setOpenExternalLinks(True) + qs.setProperty("class", "body") + + card_lay.addWidget(qs_title) + card_lay.addWidget(qs) + + # Tips (green) + tip_box = QFrame(); tip_box.setObjectName("tip"); tip_box.setProperty("class", "tip") + tip_lay = QVBoxLayout(tip_box); tip_lay.setContentsMargins(12, 10, 12, 10) + tip_h = QLabel("💡 Tips") + tip_h.setProperty("class", "tipTitle") + tip_b = QLabel( + "" + ) + tip_b.setProperty("class", "tipText") + tip_b.setOpenExternalLinks(True) + tip_lay.addWidget(tip_h) + tip_lay.addWidget(tip_b) + card_lay.addWidget(tip_box) + + # Warnings (amber) + warn_box = QFrame(); warn_box.setObjectName("warn"); warn_box.setProperty("class", "warn") + warn_lay = QVBoxLayout(warn_box); warn_lay.setContentsMargins(12, 10, 12, 10) + warn_h = QLabel("⚠️ Important Warnings") + warn_h.setProperty("class", "warnTitle") + warn_b = QLabel( + "" + ) + warn_b.setProperty("class", "warnText") + warn_lay.addWidget(warn_h) + warn_lay.addWidget(warn_b) + card_lay.addWidget(warn_box) + + # Troubleshooting + tr_title = QLabel("Troubleshooting") + tr_title.setProperty("class", "h3") + tr = QLabel( + "" + ) + tr.setProperty("class", "body") + card_lay.addWidget(tr_title) + card_lay.addWidget(tr) + + # Footer + div2 = QFrame(); div2.setObjectName("divider"); div2.setProperty("class", "divider") + card_lay.addWidget(div2) + foot = QLabel("Need help? Email kirubakaran@vecmocon.com") + foot.setOpenExternalLinks(True) + foot.setProperty("class", "body") + card_lay.addWidget(foot) + + scroll_lay.addWidget(card) + scroll_lay.addStretch(1) + scroll.setWidget(host) + root.addWidget(scroll) + + def setup_about_ui(self): + """ + A clean, native-Qt About page: + - Card-style container with rounded corners + - Bold title + subtitle + - Small badges for version/build + - Left/right aligned key–value grid + """ + + # Clear existing layout if re-building + def clear_layout(w: QWidget): + lay = w.layout() + if lay: + while lay.count(): + item = lay.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + while item.layout().count(): + sub = item.layout().takeAt(0) + if sub.widget(): + sub.widget().deleteLater() + item.layout().deleteLater() + lay.deleteLater() + + clear_layout(self.about_tab) + + build_num = "4.0" + + # ---- Main container ---- + root = QVBoxLayout(self.about_tab) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(12) + + card = QFrame() + card.setObjectName("aboutCard") + card.setFrameShape(QFrame.Shape.NoFrame) + card.setStyleSheet(""" + #aboutCard { + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 12px; + } + QLabel#title { + color: #eaeaea; + font-weight: 700; + } + QLabel#subtitle { + color: #c7c7c7; + } + QLabel#label { + color: #b6b6b6; + font-weight: 600; + } + QLabel#value { + color: #e6e6e6; + } + QLabel.badge { + background: #343a40; + border: 1px solid #454d55; + border-radius: 999px; + padding: 2px 10px; + color: #e6e6e6; + font-size: 11px; + } + QFrame#divider { + background: #3a3a3a; + min-height: 1px; + max-height: 1px; + border: none; + } + QLabel.link { + color: #6aa9ff; + } + """) + card_lay = QVBoxLayout(card) + card_lay.setContentsMargins(18, 16, 18, 16) + card_lay.setSpacing(10) + + # ---- Header ---- + title = QLabel("About This Application") + title.setObjectName("title") + tfont = QFont() + tfont.setPointSize(16) + tfont.setBold(True) + title.setFont(tfont) + + subtitle = QLabel("Battery Swap Station Dashboard") + subtitle.setObjectName("subtitle") + + header = QVBoxLayout() + header.setSpacing(4) + header.addWidget(title) + header.addWidget(subtitle) + + # badges row + badges = QHBoxLayout() + badges.setSpacing(8) + ver = QLabel(f"Version: {build_num}") + ver.setObjectName("") + ver.setProperty("class", "badge") + ver.setAlignment(Qt.AlignmentFlag.AlignCenter) + ver.setMinimumHeight(20) + ver.setStyleSheet("") + + badges.addWidget(ver, 0, Qt.AlignmentFlag.AlignLeft) + badges.addStretch(1) + + card_lay.addLayout(header) + card_lay.addLayout(badges) + + # divider + div = QFrame() + div.setObjectName("divider") + card_lay.addWidget(div) + + # ---- Key–Value grid ---- + grid = QGridLayout() + grid.setHorizontalSpacing(24) + grid.setVerticalSpacing(10) + + def add_row(r, key, val, is_link=False): + l = QLabel(key) + l.setObjectName("label") + v = QLabel(val) + v.setObjectName("value") + if is_link: + v.setText(f'{val}') + v.setOpenExternalLinks(True) + v.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) + v.setObjectName("") # style via link class + v.setProperty("class", "link") + grid.addWidget(l, r, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + grid.addWidget(v, r, 1, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + + add_row(0, "Company", "VECMOCON TECHNOLOGIES") + add_row(1, "Developed by", "Kirubakaran S") + add_row(2, "Support", "kirubakaran@vecmocon.com", is_link=True) # mailto auto-handled by client + add_row(3, "Website", "https://www.vecmocon.com", is_link=True) + + card_lay.addLayout(grid) + + # footer + footer = QLabel("© 2025 VECMOCON TECHNOLOGIES. All rights reserved. • Made with ♥ for reliability & clarity.") + footer.setAlignment(Qt.AlignmentFlag.AlignLeft) + footer.setObjectName("subtitle") # subtle color + card_lay.addSpacing(6) + card_lay.addWidget(footer) + + # center the card horizontally + root.addWidget(card) + root.addStretch(1) + + + def load_settings(self): + + broker = self.settings.value("broker_address", "DEFAULT_BROKER") + port = self.settings.value("port", "DEFAULT_PORT") + username = self.settings.value("username", "DEFAULT_USERNAME") + password = self.settings.value("password", "DEFAULT_PASSWORD") + + client_id = self.settings.value("client_id", "DEFAULT_CLIENT_ID") + version = self.settings.value("version", "DEFAULT_VERSION") + device_id = self.settings.value("device_id", "DEFAULT_DEVICE_ID") + + self.broker_input.setText(broker) + self.port_input.setText(port) + self.username_input.setText(username) + self.password_input.setText(password) + + self.client_id_input.setText(client_id) + self.version_input.setText(version) + self.device_id_input.setText(device_id) + + self.settings.sync() + + # --- START: New Decoding Logic Integration --- + + def _read_varint(self, b: bytes, i: int): + """Helper to read a varint from a raw byte buffer.""" + shift = 0 + val = 0 + while True: + if i >= len(b): raise ValueError("truncated varint") + c = b[i] + i += 1 + val |= (c & 0x7F) << shift + if not (c & 0x80): break + shift += 7 + if shift > 64: raise ValueError("varint too long") + return val, i + + def _skip_field(self, b: bytes, i: int, wt: int): + """Helper to skip a field in the buffer based on its wire type.""" + if wt == 0: # VARINT + _, i = self._read_varint(b, i) + return i + if wt == 1: # 64-BIT + return i + 8 + if wt == 2: # LENGTH-DELIMITED + ln, i = self._read_varint(b, i) + return i + ln + if wt == 5: # 32-BIT + return i + 4 + raise ValueError(f"unsupported wire type to skip: {wt}") + + def _extract_field3_varint(self, b: bytes): + """Manually parses the byte string to find the integer value of field number 3.""" + i = 0 + n = len(b) + while i < n: + key, i2 = self._read_varint(b, i) + wt = key & 0x7 + fn = key >> 3 + i = i2 + if fn == 3 and wt == 0: + v, _ = self._read_varint(b, i) + return v + i = self._skip_field(b, i, wt) + return None + + def _decode_event_payload(self, payload_bytes: bytes) -> str: + """ + Decodes an event payload robustly, ensuring the correct eventType is used. + """ + # 1. Standard parsing to get a base dictionary + msg = eventPayload() + msg.ParseFromString(payload_bytes) + d = MessageToDict(msg, preserving_proto_field_name=True) + + # 2. Manually extract the true enum value from the raw bytes (Authoritative value) + wire_num = self._extract_field3_varint(payload_bytes) + wire_name = None + if wire_num is not None: + try: + # Find the string name corresponding to the integer value + wire_name = eventType_e.Name(wire_num) + except ValueError: + # If the number is valid but not in our .proto file, use the number itself + wire_name = f"UNKNOWN_ENUM_VALUE_{wire_num}" + + # 3. Always prefer the manually extracted "wire value" + if wire_name: + d["eventType"] = wire_name + + # 4. Ensure consistent structure with default values + ed = d.setdefault("eventData", {}) + ed.setdefault("nfcData", None) + ed.setdefault("batteryIdentification", "") + ed.setdefault("activityFailureReason", 0) + ed.setdefault("swapAbortReason", "ABORT_UNKNOWN") + ed.setdefault("swapTime", 0) + ed.setdefault("faultCode", 0) + ed.setdefault("doorStatus", 0) + ed.setdefault("slotId", 0) + + # 5. Reorder for clean logs and return as a formatted JSON string + ordered = { + "ts": d.get("ts"), + "deviceId": d.get("deviceId"), + "eventType": d.get("eventType"), + "sessionId": d.get("sessionId"), + "eventData": d.get("eventData"), + } + return json.dumps(ordered, indent=2, ensure_ascii=False) + + # --- END: New Decoding Logic Integration --- + + def _apply_theme(self): + if self.is_dark_theme: + self.theme_button.setText("☀️") + self.theme_button.setToolTip("Switch to Light Theme") + self.setStyleSheet(get_dark_theme_styles(self.scale_factor)) + else: + self.theme_button.setText("🌙") + self.theme_button.setToolTip("Switch to Dark Theme") + self.setStyleSheet(get_light_theme_styles(self.scale_factor)) + self.timestamp_label.setStyleSheet("color: #ecf0f1; background-color: transparent;") + self.apply_config_tab_styles() + + def _on_fade_finished(self, overlay): + """Safely delete the overlay widget after the animation is done.""" + overlay.deleteLater() + self.animation = None # Clear the animation reference + + def toggle_theme(self): + """Toggles the UI theme with a smooth cross-fade animation.""" + # Prevent starting a new animation if one is already running + if self.animation and self.animation.state() == self.animation.State.Running: + return + + # 1. Take a "screenshot" of the current UI + pixmap = self.central_widget.grab() + + # 2. Create a temporary overlay label with that screenshot + overlay = QLabel(self.central_widget) + overlay.setPixmap(pixmap) + overlay.setGeometry(self.central_widget.geometry()) + overlay.show() + overlay.raise_() + + # 3. Immediately apply the new theme underneath the overlay + self.is_dark_theme = not self.is_dark_theme + self._apply_theme() + + # 4. Set up the opacity effect and animation for the overlay + opacity_effect = QGraphicsOpacityEffect(overlay) + overlay.setGraphicsEffect(opacity_effect) + + self.animation = QPropertyAnimation(opacity_effect, b"opacity") + self.animation.setDuration(500) # Animation duration in milliseconds + self.animation.setStartValue(1.0) + self.animation.setEndValue(0.0) + self.animation.setEasingCurve(QEasingCurve.Type.InOutQuad) + + # 5. Connect the animation's finish signal to our cleanup method + # We use a lambda to pass the overlay widget to the cleanup function + self.animation.finished.connect(lambda: self._on_fade_finished(overlay)) + + # 6. Start the fade-out animation + self.animation.start(self.animation.DeletionPolicy.DeleteWhenStopped) + + def create_status_bar(self): + status_bar_widget = QWidget() + status_bar_widget.setStyleSheet(f"background-color: #2c3e50; padding: {int(6*self.scale_factor)}px;") + status_bar_layout = QHBoxLayout(status_bar_widget) + status_bar_layout.setContentsMargins(10, 0, 10, 0) + left_layout = QHBoxLayout() + logo_label = QLabel("BSS Dashboard") + logo_label.setFont(QFont("Arial", max(9, int(11 * self.scale_factor)), QFont.Weight.Bold)) + logo_label.setStyleSheet("color: #ecf0f1; background-color: transparent;") + self.timestamp_label = QLabel("Last Update: N/A") + self.timestamp_label.setFont(QFont("Arial", max(8, int(9 * self.scale_factor)))) + left_layout.addWidget(logo_label) + left_layout.addWidget(self.timestamp_label) + left_layout.addStretch() + + company_logo = QSvgWidget(resource_path("logo/vec_logo_svg.svg")) + company_logo.setStyleSheet("background: transparent;") + ds = company_logo.renderer().defaultSize() + target_h = max(24, int(36 * self.scale_factor)) + target_w = int(ds.width() * (target_h / ds.height())) if ds.height() > 0 else target_h + company_logo.setFixedSize(target_w, target_h) + + right_layout = QHBoxLayout() + right_layout.addStretch() + self.connect_button = QPushButton("Connect") + self.disconnect_button = QPushButton("Disconnect") + self.connect_button.setObjectName("ConnectButton") + self.disconnect_button.setObjectName("DisconnectButton") + self.disconnect_button.setEnabled(False) + self.theme_button = QPushButton("🌙") + btn_size = max(28, int(35 * self.scale_factor)) + self.theme_button.setFixedSize(btn_size, btn_size) + self.theme_button.setFont(QFont("Arial", max(10, int(14 * self.scale_factor)))) + self.theme_button.setStyleSheet("border: none; background-color: transparent; color: white;") + self.theme_button.clicked.connect(self.toggle_theme) + right_layout.addWidget(self.connect_button) + right_layout.addWidget(self.disconnect_button) + right_layout.addWidget(self.theme_button) + status_bar_layout.addLayout(left_layout, 1) + status_bar_layout.addWidget(company_logo, 1) + status_bar_layout.addLayout(right_layout, 1) + self.main_layout.addWidget(status_bar_widget) + self.connect_button.clicked.connect(self.connect_to_mqtt) + self.disconnect_button.clicked.connect(self.disconnect_from_mqtt) + + def apply_config_tab_styles(self): + font_size_group = max(10, int(12 * self.scale_factor)) + font_size_widgets = max(9, int(10 * self.scale_factor)) + text_color = "#f0f0f0" if self.is_dark_theme else "#000" + self.config_tab.setStyleSheet(f""" + QGroupBox {{ font-size: {font_size_group}pt; font-weight: bold; }} + QLabel, QLineEdit, QPushButton, QCheckBox {{ + font-size: {font_size_widgets}pt; + color: {text_color}; + }} + """) + + def setup_config_ui(self): + layout = QVBoxLayout(self.config_tab) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + self.apply_config_tab_styles() + + mqtt_group = QGroupBox("MQTT Server Credentials") + form_layout = QFormLayout() + self.broker_input = QLineEdit("mqtt.vecmocon.com") + self.port_input = QLineEdit("1883") + self.username_input = QLineEdit("mqtt_username") + self.password_input = QLineEdit("mqtt_password") + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + form_layout.addRow("Broker Address:", self.broker_input) + form_layout.addRow("Port:", self.port_input) + form_layout.addRow("Username:", self.username_input) + form_layout.addRow("Password:", self.password_input) + mqtt_group.setLayout(form_layout) + + topic_group = QGroupBox("Topic Information") + form_layout_topic = QFormLayout() + self.client_id_input = QLineEdit("batterySmartStation") + self.version_input = QLineEdit("100") + self.device_id_input = QLineEdit("V16000862287077265957") + form_layout_topic.addRow("Client ID:", self.client_id_input) + form_layout_topic.addRow("Version:", self.version_input) + form_layout_topic.addRow("Device ID:", self.device_id_input) + + # --- NEW: Add display fields for the generated topics --- + line_separator = QFrame() + line_separator.setFrameShape(QFrame.Shape.HLine) + line_separator.setFrameShadow(QFrame.Shadow.Sunken) + form_layout_topic.addRow(line_separator) + + # form_layout_topic.addRow(QLabel("")) # Separator + form_layout_topic.addRow(QLabel("Generated Topis 📡")) + + self.periodic_topic_display = QLineEdit() + self.periodic_topic_display.setReadOnly(True) + self.periodic_topic_display.setObjectName("TopicDisplay") + + self.events_topic_display = QLineEdit() + self.events_topic_display.setReadOnly(True) + self.events_topic_display.setObjectName("TopicDisplay") + + self.rpcRequest_topic_display = QLineEdit() + self.rpcRequest_topic_display.setReadOnly(True) + self.rpcRequest_topic_display.setObjectName("TopicDisplay") + + form_layout_topic.addRow("Periodic Topic:", self.periodic_topic_display) + form_layout_topic.addRow("Events Topic:", self.events_topic_display) + form_layout_topic.addRow("RPC Request Topic:", self.rpcRequest_topic_display) + + topic_group.setLayout(form_layout_topic) + + # --- NEW: Connect input field changes to the update method --- + self.client_id_input.textChanged.connect(self._update_topic_display) + self.version_input.textChanged.connect(self._update_topic_display) + self.device_id_input.textChanged.connect(self._update_topic_display) + + logs_group = QGroupBox("Save Logs to CSV") + # ... (the rest of the method is the same) + logs_form_layout = QFormLayout() + self.save_logs_checkbox = QCheckBox("Enable Logging") + self.save_logs_checkbox.setChecked(True) + default_log_filename = f"Station_Mqtt_Dashboard_log_{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv" + # self.log_filename_input = QLineEdit(default_log_filename) + log_dir_layout = QHBoxLayout() + script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + default_log_dir = os.path.join(script_dir, "logs") + self.log_dir_input = QLineEdit(default_log_dir) + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self.select_log_directory) + log_dir_layout.addWidget(self.log_dir_input) + log_dir_layout.addWidget(browse_btn) + logs_form_layout.addRow(self.save_logs_checkbox) + # logs_form_layout.addRow("Log Filename:", self.log_filename_input) + logs_form_layout.addRow("Output Directory:", log_dir_layout) + logs_group.setLayout(logs_form_layout) + + layout.addWidget(mqtt_group) + layout.addWidget(topic_group) + layout.addWidget(logs_group) + + # --- NEW: Call the update function once to set initial values --- + self._update_topic_display() + + def select_log_directory(self): + directory = QFileDialog.getExistingDirectory(self, "Select Log Directory") + if directory: + self.log_dir_input.setText(directory) + + def _update_topic_display(self): + """Constructs and displays the full MQTT topics based on user input.""" + client_id = self.client_id_input.text() + version = self.version_input.text() + device_id = self.device_id_input.text() + + # Construct the topic strings + periodic_topic = f"VEC/{client_id}/{version}/{device_id}/PERIODIC" + events_topic = f"VEC/{client_id}/{version}/{device_id}/EVENTS" + rpcRequest_topic = f"VEC/{client_id}/{version}/{device_id}/RPC/REQUEST" + + # Update the read-only display fields + self.periodic_topic_display.setText(periodic_topic) + self.events_topic_display.setText(events_topic) + self.rpcRequest_topic_display.setText(rpcRequest_topic) + + def setup_main_ui(self): + page_layout = QVBoxLayout(self.main_tab) + page_layout.setContentsMargins( + int(8 * self.scale_factor), int(8 * self.scale_factor), + int(8 * self.scale_factor), int(8 * self.scale_factor) + ) + + top_bar_layout = QHBoxLayout() + top_bar_layout.addWidget(QLabel("LAST RECV TS:")) + self.last_recv_ts_field = QLineEdit("No Data") + self.last_recv_ts_field.setReadOnly(True) + top_bar_layout.addWidget(self.last_recv_ts_field) + top_bar_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)) + + top_bar_layout.addWidget(QLabel("Backup Supply:")) + self.backup_supply_indicator = QLabel("N/A") + self.backup_supply_indicator.setFixedSize(80, 25) + self.backup_supply_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.backup_supply_indicator.setStyleSheet( + "background-color: gray; color: white; border-radius: 5px; font-weight: bold;" + ) + top_bar_layout.addWidget(self.backup_supply_indicator) + + # backup_label = QLabel("Backup Supply:") + # backup_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + # top_bar_layout.addWidget(backup_label) + + # self.backup_supply_indicator = QLabel("🔴 OFF") + # self.backup_supply_indicator.setStyleSheet("color: white; font-weight: bold;") + # self.backup_supply_indicator.setFont(QFont("Segoe UI Emoji", 8, QFont.Weight.Bold)) + # top_bar_layout.addWidget(self.backup_supply_indicator) + + refresh_btn = QPushButton("⟳ Refresh") + refresh_btn.setObjectName("RefreshButton") + refresh_btn.clicked.connect(self.reset_dashboard_ui) + top_bar_layout.addWidget(refresh_btn) + + reset_btn = QPushButton("Station Reset") + reset_btn.setObjectName("ResetButton") + reset_btn.clicked.connect(self.confirm_station_reset) + top_bar_layout.addWidget(reset_btn) + + main_content_layout = QHBoxLayout() + + self.chamber_widgets = [] + grid_widget = QWidget() + grid_layout = QGridLayout(grid_widget) + grid_layout.setSpacing(max(5, int(8 * self.scale_factor))) + for i in range(9): + chamber = ChamberWidget(f"CHAMBER - {i+1}", self.scale_factor) + chamber_num = i + 1 + chamber.open_door_requested.connect(partial(self.handle_open_door, chamber_num)) + chamber.chg_on_requested.connect(partial(self.handle_charger_control, chamber_num, True)) + chamber.chg_off_requested.connect(partial(self.handle_charger_control, chamber_num, False)) + self.chamber_widgets.append(chamber) + row, col = divmod(i, 3) + grid_layout.addWidget(chamber, row, col) + + diag_panel = QWidget() + diag_panel_layout = QVBoxLayout(diag_panel) + + alarms_group = QGroupBox("System Diagnostics") + alarms_group.setFont(QFont("Arial", max(9, int(11*self.scale_factor)), QFont.Weight.Bold)) + alarms_layout = QVBoxLayout(alarms_group) + alarms_layout.setSpacing(max(5, int(8 * self.scale_factor))) + sdc_layout = QHBoxLayout() + sdc_layout.addWidget(QLabel("SDC Value:")) + self.sdc_field = self._create_main_status_field() + sdc_layout.addWidget(self.sdc_field) + alarms_layout.addLayout(sdc_layout) + self.diag_labels = {} + for error_text in self.DIAGNOSTIC_ERRORS: + label = QLabel(error_text) + label.setProperty("alarm", "inactive") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setFont(QFont("Arial", max(8, int(10 * self.scale_factor)))) + alarms_layout.addWidget(label) + self.diag_labels[error_text] = label + + swap_group = QGroupBox("Swap Process") + swap_group.setFont(QFont("Arial", max(9, int(11*self.scale_factor)), QFont.Weight.Bold)) + swap_layout = QVBoxLayout(swap_group) + swap_layout.setSpacing(max(4, int(6 * self.scale_factor))) + swap_layout.setContentsMargins( + int(8 * self.scale_factor), int(8 * self.scale_factor), + int(8 * self.scale_factor), int(8 * self.scale_factor) + ) + top_pad = QSpacerItem( + 0, + max(16, int(22 * self.scale_factor)), + QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Fixed + ) + swap_layout.addItem(top_pad) + + swap_display_layout = QHBoxLayout() + self.swap_display = QLineEdit() + self.swap_display.setReadOnly(True) + self.swap_display.setPlaceholderText("Click to build sequence...") + swap_display_layout.addWidget(self.swap_display) + swap_layout.addLayout(swap_display_layout) + + swap_grid = QGridLayout() + # swap_grid.setVerticalSpacing(max(0, int(4 * self.scale_factor))) + swap_grid.setSpacing(int(2 * self.scale_factor)) + swap_grid.setContentsMargins(0, 0, 0, 0) + swap_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + + self.swap_buttons = {} + btn_size = max(40, int(60 * self.scale_factor)) + + for i in range(1, 10): + btn = QPushButton(str(i)) + btn.setFont(QFont("Arial", max(12, int(14*self.scale_factor)))) + btn.setFixedSize(btn_size, btn_size) + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + btn.clicked.connect(self.on_swap_button_clicked) + self.swap_buttons[i] = btn + row, col = divmod(i-1, 3) + swap_grid.addWidget(btn, row, col, Qt.AlignmentFlag.AlignCenter) + + grid_container_layout = QHBoxLayout() + grid_container_layout.addStretch(1) # Add a stretchable "spring" on the left + grid_container_layout.addLayout(swap_grid) # Add the compact grid in the middle + grid_container_layout.addStretch(1) # Add a stretchable "spring" on the right + + # Add the container (with the springs) to the main vertical layout + swap_layout.addLayout(grid_container_layout) + + # swap_layout.addLayout(swap_grid, 0) + swap_layout.addStretch(1) + + button_row_layout = QHBoxLayout() + button_row_layout.setSpacing(max(4, int(6 * self.scale_factor))) + + self.start_swap_btn = QPushButton("Start Swap") + self.start_swap_btn.setObjectName("StartSwapButton") + self.start_swap_btn.clicked.connect(self.start_swap) + + self.abort_swap_btn = QPushButton("Abort Swap") + self.abort_swap_btn.setObjectName("AbortSwapButton") + self.abort_swap_btn.clicked.connect(self.abort_swap) + + for btn in (self.start_swap_btn, self.abort_swap_btn): + btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + btn.setMinimumHeight(max(28, int(36 * self.scale_factor))) + + button_row_layout.addWidget(self.start_swap_btn) + button_row_layout.addWidget(self.abort_swap_btn) + + swap_layout.addLayout(button_row_layout) + + self.update_swap_buttons_state() + + log_group = QGroupBox("Instance Log") + log_group.setFont(QFont("Arial", max(9, int(11*self.scale_factor)), QFont.Weight.Bold)) + log_layout = QVBoxLayout(log_group) + + self.instance_log_area = QPlainTextEdit() + self.instance_log_area.setReadOnly(True) + self.instance_log_area.setObjectName("InstanceLog") + log_layout.addWidget(self.instance_log_area) + + audio_group = QGroupBox("Audio Command") + audio_group.setFont(QFont("Arial", max(9, int(11*self.scale_factor)), QFont.Weight.Bold)) + audio_layout = QHBoxLayout(audio_group) + self.audio_combo = QComboBox() + self.audio_combo.addItems(self.AUDIO_LANGUAGES.keys()) + send_audio_btn = QPushButton("➤") + send_audio_btn.setObjectName("SendAudioButton") + btn_size = max(28, int(35 * self.scale_factor)) + send_audio_btn.setFixedSize(btn_size, btn_size) + send_audio_btn.clicked.connect(self.send_audio_command) + audio_layout.addWidget(self.audio_combo) + audio_layout.addWidget(send_audio_btn) + + diag_panel_layout.addWidget(alarms_group, 2) + diag_panel_layout.addWidget(swap_group, 3) # Make sure you have swap_group defined + diag_panel_layout.addWidget(audio_group, 1) # Make sure you have audio_group defined + diag_panel_layout.addWidget(log_group, 4) + + main_content_layout.addWidget(grid_widget, 1) + main_content_layout.addWidget(diag_panel, 0) + page_layout.addLayout(top_bar_layout) + page_layout.addLayout(main_content_layout) + + def log_to_instance_view(self, message: str): + """Adds a formatted message to the instance log on the main tab.""" + if not self.instance_log_area: + return + + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + formatted_message = f"[{timestamp}] {message}" + self.instance_log_area.appendPlainText(formatted_message) + + # Keep the log from growing forever (max 100 lines) + if self.instance_log_area.blockCount() > 100: + cursor = self.instance_log_area.textCursor() + cursor.movePosition(cursor.MoveOperation.Start) + cursor.select(cursor.SelectionType.BlockUnderCursor) + cursor.removeSelectedText() + + def setup_logs_ui(self): + layout = QHBoxLayout(self.logs_tab) + + # --- Setup the Request Logs area --- + request_group = QGroupBox("Request Logs (Dashboard -> MQTT Server)") + request_layout = QVBoxLayout(request_group) + self.request_log_area = QPlainTextEdit() + self.request_log_area.setReadOnly(True) + self.request_log_area.setObjectName("LogPanel") + request_layout.addWidget(self.request_log_area) + + # --- Setup the Event Logs area --- + event_group = QGroupBox("Event Logs (MQTT Server -> Dashboard)") + event_layout = QVBoxLayout(event_group) + self.event_log_area = QPlainTextEdit() + self.event_log_area.setReadOnly(True) + self.event_log_area.setObjectName("LogPanel") + event_layout.addWidget(self.event_log_area) + + layout.addWidget(request_group) + layout.addWidget(event_group) + + def _create_main_status_field(self): + field = QLineEdit() + field.setReadOnly(True) + return field + + def log_request(self, topic, payload_str, log_type="INFO"): + """Logs an event to the UI as a structured JSON object.""" + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + # Add the new "log_type" to the entry for clarity + log_entry = { + "timestamp": timestamp, + "log_type": log_type, # <-- NEW + "topic": topic + } + + try: + log_entry["payload"] = json.loads(payload_str) + except json.JSONDecodeError: + log_entry["message"] = payload_str + + final_log_string = json.dumps(log_entry, indent=2) + self.request_log_area.appendPlainText(final_log_string) + self.request_log_area.appendPlainText("-" * 50 + "\n") + + def log_event(self, topic, json_payload): + """Logs a received event as a clean JSON object to the UI ONLY.""" + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + log_entry = { + "timestamp": timestamp, + "topic": topic, + "payload": json.loads(json_payload) + } + final_log_string = json.dumps(log_entry, indent=2) + + self.event_log_area.appendPlainText(final_log_string) + self.event_log_area.appendPlainText("-" * 50 + "\n") + + # if self.save_logs_checkbox.isChecked(): + # self.log_data_signal.emit([timestamp, topic, json_payload.replace('\n', ' ')]) + + def confirm_station_reset(self): + reply = QMessageBox.question(self, 'Confirm Reset', + "Are you sure you want to reset the station?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reply == QMessageBox.StandardButton.Yes: + print("Requesting Stationm Reset...") + request = rpcRequest( + ts=int(time.time()), + jobId=f"job_{int(time.time())}", + jobType=jobType_e.JOBTYPE_REBOOT + ) + self._send_rpc_request(request) + else: + self.log_request("INFO", "Station Reset command cancelled by user.") + + # --- THIS IS THE CRITICAL METHOD TO UPDATE --- + def on_message_received(self, topic, payload): + now = datetime.datetime.now() + self.timestamp_label.setText(f"Last Update: {now.strftime('%Y-%m-%d %H:%M:%S')}") + + try: + msg_type = topic.split('/')[-1] + + if msg_type == 'PERIODIC': + decoded_payload = periodicData() + decoded_payload.ParseFromString(payload) + data_dict = MessageToDict(decoded_payload, preserving_proto_field_name=True) + self._log_periodic_to_terminal(decoded_payload, data_dict) + self.update_main_dashboard(data_dict) + if self.save_logs_checkbox.isChecked(): + log_payload_str = json.dumps(data_dict) + self.log_data_signal.emit([now, topic, data_dict, payload]) + + elif msg_type == 'EVENTS': + # This part handles the UI log, as before + json_payload = self._decode_event_payload(payload) + self.log_event(topic, json_payload) + + # This new block handles the CSV logging correctly + if self.save_logs_checkbox.isChecked(): + event_data_dict = json.loads(json_payload) + # Emit the signal with all 4 required items + self.log_data_signal.emit([now, topic, event_data_dict, payload]) + + # This part updates the instance log, as before + try: + data = json.loads(json_payload) + event_type = data.get("eventType", "Unknown Event") + self.log_to_instance_view(f"Event Received: {event_type}") + except json.JSONDecodeError: + self.log_to_instance_view("Received unparseable event data") + + elif msg_type == 'REQUEST': + try: + # 1. Use the correct rpcRequest protobuf object for parsing + decoded_payload = rpcRequest() + decoded_payload.ParseFromString(payload) + + # 2. Convert the parsed message to a dictionary and then to a JSON string + data_dict = MessageToDict(decoded_payload, preserving_proto_field_name=True) + json_payload = json.dumps(data_dict, indent=2) + + # This new block handles the CSV logging correctly + if self.save_logs_checkbox.isChecked(): + request_data_dict = json.loads(json_payload) + # Emit the signal with all 4 required items + self.log_data_signal.emit([now, topic, request_data_dict, payload]) + + # 3. Log the INCOMING request to the correct panel + self.log_request(topic, json_payload, log_type="INCOMING_RPC") + + # 4. (Optional) Log to the instance view on the main tab + job_type = data_dict.get("jobType", "Unknown Job") + self.log_to_instance_view(f"RPC Request Received: {job_type}") + + except Exception as e: + # Handle potential decoding errors for this specific topic + print(f"Error decoding RPC Request from topic '{topic}': {e}") + error_msg = f'{{"error": "RPC DECODING FAILED: {e}", "raw_hex": "{payload.hex()}"}}' + self.log_request(topic, error_msg, log_type="DECODE_ERROR") + + else: + print(f"Received message on unhandled topic: {topic}") + + except Exception as e: + print(f"Error processing message from topic '{topic}': {e}") + # Log the failure to the UI for better debugging + self.log_event(topic, f'{{"error": "DECODING FAILED: {e}", "raw_hex": "{payload.hex()}"}}') + + # --- (The rest of your methods like `on_swap_button_clicked`, etc., remain here) --- + def on_swap_button_clicked(self): + sender = self.sender() + chamber_num = int(sender.text().split('\n')[0]) + self.swap_button_clicks[chamber_num] += 1 + click_count = self.swap_button_clicks[chamber_num] + if click_count == 1: + self.swap_sequence.append(chamber_num) + sender.setStyleSheet("background-color: #27ae60;") + elif click_count == 2: + self.swap_sequence.append(chamber_num) + sender.setText(f"{chamber_num}") + sender.setStyleSheet("background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #27ae60, stop:0.5 #27ae60, stop:0.51 #2ecc71, stop:1 #2ecc71);") + elif click_count >= 3: + self.swap_sequence = [num for num in self.swap_sequence if num != chamber_num] + self.swap_button_clicks[chamber_num] = 0 + sender.setText(str(chamber_num)) + sender.setStyleSheet("") + self.update_swap_display() + self.update_swap_buttons_state() + + def update_swap_display(self): + self.swap_display.setText(str(self.swap_sequence) if self.swap_sequence else "") + + def update_swap_buttons_state(self): + is_sequence_present = bool(self.swap_sequence) + self.start_swap_btn.setEnabled(is_sequence_present) + + def start_swap(self): + if not self.swap_sequence: + QMessageBox.warning(self, "Empty Sequence", "Cannot start an empty swap sequence.") + return + print(f"Starting swap with sequence: {self.swap_sequence}") + request = rpcRequest( + ts=int(time.time()), + jobId=f"job_{int(time.time())}", + jobType=jobType_e.JOBTYPE_SWAP_START + ) + request.rpcData.slotsData.extend(self.swap_sequence) + self._send_rpc_request(request) + self.swap_sequence.clear() + self.update_swap_display() + + def abort_swap(self): + print("Requesting to abort swap...") + request = rpcRequest( + ts=int(time.time()), + jobId=f"job_{int(time.time())}", + jobType=jobType_e.JOBTYPE_TRANSACTION_ABORT + ) + self._send_rpc_request(request) + self.swap_sequence.clear() + self.update_swap_display() + self.swap_button_clicks = {i: 0 for i in range(1, 10)} + for i, btn in self.swap_buttons.items(): + btn.setText(str(i)) + btn.setStyleSheet("") + self.update_swap_buttons_state() + + def send_audio_command(self): + """ + Constructs and sends an RPC request to change the station's language. + """ + language_name = self.audio_combo.currentText() + language_code = self.AUDIO_LANGUAGES.get(language_name) + + if not language_code: + self.log_to_instance_view(f"Error: Could not find code for language '{language_name}'.") + return + + # Dynamically find the correct languageType enum value from the .proto file + # (e.g., "English" becomes "LANGUAGE_TYPE_ENGLISH") + enum_name = f"LANGUAGE_TYPE_{language_name.upper()}" + + try: + language_enum = languageType_e.Value(enum_name) + except ValueError: + self.log_to_instance_view(f"Error: Invalid language enum '{enum_name}' not found in .proto file.") + return + + # Create the rpcRequest object, similar to your abort_swap function + request = rpcRequest( + ts=int(time.time()), + jobId=f"lang_{int(time.time())}", + jobType=jobType_e.JOBTYPE_LANGUAGE_UPDATE, + languageType=language_enum # Add the specific language payload + ) + + # Use your existing helper method to send the request + self._send_rpc_request(request) + + # Log to the UI and show a confirmation pop-up + self.log_to_instance_view(f"Sent RPC to set language to {language_name} (Job ID: {request.jobId})") + QMessageBox.information(self, "RPC Sent", f"Request to change language to {language_name} has been sent.") + + + def _send_rpc_request(self, request_payload): + if not self.mqtt_client or not self.mqtt_client.client.is_connected(): + QMessageBox.warning(self, "Not Connected", "Cannot send command. MQTT client is not connected.") + return + device_id = self.device_id_input.text() + version = self.version_input.text() + topic = f"VEC/{self.client_id_input.text()}/{version}/{device_id}/RPC/REQUEST" + serialized_payload = request_payload.SerializeToString() + data_dict = MessageToDict(request_payload, preserving_proto_field_name=True) + json_payload = json.dumps(data_dict) + self.log_request(topic, json_payload, log_type="OUTGOING_RPC") + + job_type = data_dict.get("jobType", "Unknown Job") + self.log_to_instance_view(f"Command Sent: {job_type}") + + self.mqtt_client.publish_message(topic, serialized_payload) + + def handle_open_door(self, chamber_num): + print(f"Requesting to open door for chamber {chamber_num}...") + request = rpcRequest( + ts=int(time.time()), + jobId=f"job_{int(time.time())}", + jobType=jobType_e.JOBTYPE_GATE_OPEN_CLOSE + ) + request.slotInfo.slotId = chamber_num + request.slotInfo.state = 1 + self._send_rpc_request(request) + + def handle_charger_control(self, chamber_num, state): + action = "ON" if state else "OFF" + print(f"Requesting to turn charger {action} for chamber {chamber_num}...") + request = rpcRequest( + ts=int(time.time()), + jobId=f"job_{int(time.time())}", + jobType=jobType_e.JOBTYPE_CHARGER_ENABLE_DISABLE + ) + request.slotInfo.slotId = chamber_num + request.slotInfo.state = 1 if state else 0 + self._send_rpc_request(request) + + def reset_dashboard_ui(self): + self.log_request("INFO", "Dashboard UI cleared by user.") + self.last_recv_ts_field.setText("No Data") + self.sdc_field.setText("") + for chamber in self.chamber_widgets: + chamber.reset_to_default() + self.update_diagnostic_alarms(0) + self.swap_sequence.clear() + self.update_swap_display() + self.swap_button_clicks = {i: 0 for i in range(1, 10)} + for i, btn in self.swap_buttons.items(): + btn.setText(str(i)) + btn.setStyleSheet("") + self.update_swap_buttons_state() + + def connect_to_mqtt(self): + if self.mqtt_thread and self.mqtt_thread.isRunning(): + print("Cleaning up previous MQTT thread...") + if self.mqtt_client: + self.mqtt_client.disconnect_from_broker() + self.mqtt_client.cleanup() + self.mqtt_thread.quit() + self.mqtt_thread.wait() + if self.save_logs_checkbox.isChecked(): + self.start_csv_logger() + + broker = self.broker_input.text() + user = self.username_input.text() + password = self.password_input.text() + client_id = self.client_id_input.text() + version = self.version_input.text() + device_id = self.device_id_input.text() + + try: + port = int(self.port_input.text()) + except ValueError: + self.timestamp_label.setText("Error: Port must be a number.") + return + + # ========================================================== + # ===== ADD THIS BLOCK TO SAVE YOUR SETTINGS =============== + # ========================================================== + self.settings.setValue("broker_address", broker) + self.settings.setValue("port", str(port)) # Save port as a string + self.settings.setValue("username", user) + self.settings.setValue("password", password) + self.settings.setValue("client_id", client_id) + self.settings.setValue("version",version) + self.settings.setValue("device_id", device_id) + # Add any other settings you want to save + + self.settings.sync() # Force a write to disk immediately + print("✅ Configuration saved.") + # ========================================================== + + self.mqtt_thread = QThread() + self.mqtt_client = MqttClient(broker, port, user, password, client_id) + self.mqtt_client.moveToThread(self.mqtt_thread) + self.mqtt_client.stop_logging_signal.connect(self.csv_logger.stop_logging) + self.mqtt_client.connection_status_changed.connect(self.on_connection_status_changed) + self.mqtt_client.message_received.connect(self.on_message_received) + self.mqtt_thread.started.connect(self.mqtt_client.connect_to_broker) + self.mqtt_thread.start() + + def disconnect_from_mqtt(self): + if self.csv_logger: self.stop_csv_logger() + if self.mqtt_client: self.mqtt_client.disconnect_from_broker() + + def start_csv_logger(self): + base_log_dir = self.log_dir_input.text() + session_folder_name = f"session_{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" + + self.logger_thread = QThread() + # Pass BOTH the base directory and the session name to the logger + self.csv_logger = CsvLogger(base_log_dir, session_folder_name) + + self.csv_logger.moveToThread(self.logger_thread) + self.log_data_signal.connect(self.csv_logger.log_data) + self.logger_thread.started.connect(self.csv_logger.start_logging) + self.logger_thread.start() + + def stop_csv_logger(self): + if self.logger_thread and self.logger_thread.isRunning(): + self.csv_logger.stop_logging() + self.logger_thread.quit() + self.logger_thread.wait() + self.csv_logger = None + self.logger_thread = None + + def on_connection_status_changed(self, is_connected, message): + """Handles connection status updates from the MQTT client.""" + self.connect_button.setEnabled(not is_connected) + self.disconnect_button.setEnabled(is_connected) + self.set_config_inputs_enabled(not is_connected) + + # Set the text of the label to the message from the client + self.timestamp_label.setText(message) + + self.log_to_instance_view(message.strip("✅❌🔌🔴 ")) + + if is_connected: + client_id = self.client_id_input.text() + version = self.version_input.text() + device_id = self.device_id_input.text() + + periodic_topic = f"VEC/{client_id}/{version}/{device_id}/PERIODIC" + events_topic = f"VEC/{client_id}/{version}/{device_id}/EVENTS" + rpc_request_topic = f"VEC/{client_id}/{version}/{device_id}/RPC/REQUEST" + + # self.log_request(periodic_topic, "Subscribing to topic") + self.mqtt_client.subscribe_to_topic(periodic_topic) + + # self.log_request(events_topic, "Subscribing to topic") + self.mqtt_client.subscribe_to_topic(events_topic) + + # self.log_request(rpc_request_topic, "Subscribing to topic") + self.mqtt_client.subscribe_to_topic(rpc_request_topic) + + self.tabs.setCurrentWidget(self.main_tab) + + def update_main_dashboard(self, data): + # try: + # ts = datetime.datetime.fromtimestamp(data.get('ts', datetime.datetime.now().timestamp())).strftime('%Y-%m-%d %H:%M:%S') + # self.last_recv_ts_field.setText(ts) + # slot_payloads = data.get("slotLevelPayload", []) + # for i, slot_data in enumerate(slot_payloads): + # if i < len(self.chamber_widgets): + # self.chamber_widgets[i].update_data(slot_data) + # if (i+1) in self.swap_buttons: + # is_present = slot_data.get("batteryPresent") == 1 + # self.swap_buttons[i+1].setStyleSheet("background-color: #2ecc71;" if is_present else "") + # sdc_value = data.get("stationDiagnosticCode", 0) + # self.sdc_field.setText(str(sdc_value)) + + # backup_status = data.get("backupSupplyStatus", 0) # Default to 0 if not present + + # if backup_status == 1: + # self.backup_supply_indicator.setText("BackupON") + # self.backup_supply_indicator.setStyleSheet( + # "background-color: #28a745; color: white; border-radius: 5px; font-weight: bold;" # Green + # ) + # self.top_bar_frame.setStyleSheet("#topBarFrame { border: 1px solid #28a745; }") + # else: + # self.backup_supply_indicator.setText("Backup OFF") + # self.backup_supply_indicator.setStyleSheet( + # "background-color: #dc3545; color: white; border-radius: 5px; font-weight: bold;" # Red + # ) + # self.top_bar_frame.setStyleSheet("#topBarFrame { border: 1px solid #dc3545; }") + # self.update_diagnostic_alarms(sdc_value) + # except Exception as e: + # print(f"Error updating dashboard: {e}") + + try: + ts = datetime.datetime.fromtimestamp(data.get('ts', datetime.datetime.now().timestamp())).strftime('%Y-%m-%d %H:%M:%S') + self.last_recv_ts_field.setText(ts) + slot_payloads = data.get("slotLevelPayload", []) + for i, slot_data in enumerate(slot_payloads): + if i < len(self.chamber_widgets): + self.chamber_widgets[i].update_data(slot_data) + if (i+1) in self.swap_buttons: + is_present = slot_data.get("batteryPresent") == 1 + self.swap_buttons[i+1].setStyleSheet("background-color: #2ecc71;" if is_present else "") + sdc_value = data.get("stationDiagnosticCode", 0) + self.sdc_field.setText(str(sdc_value)) + self.update_diagnostic_alarms(sdc_value) + + except Exception as e: + print(f"Error updating dashboard: {e}") + + def _log_periodic_to_terminal(self, decoded_payload, data_dict): + """Formats and prints the periodic data to the terminal as a clean table.""" + try: + current_time = datetime.datetime.fromtimestamp(decoded_payload.ts).strftime('%Y-%m-%d %H:%M:%S') + device_id = data_dict.get("deviceId", "N/A") + + # --- Main Information --- + print("\n\033[1m" + "="*50 + " PERIODIC DATA " + "="*50 + "\033[0m") + print(f"\033[1m Timestamp:\033[0m {current_time} | \033[1mDevice ID:\033[0m {device_id}") + print(f"\033[1m Backup Supply:\033[0m {decoded_payload.backupSupplyStatus} | \033[1mStation SDC:\033[0m {decoded_payload.stationDiagnosticCode}") + print("-" * 120) + + # --- Table Header --- + header = "| {:^7} | {:^18} | {:^8} | {:^8} | {:^7} | {:^10} | {:^10} | {:^12} | {:^10} |" + print(header.format("Chamber", "Battery ID", "Present", "Charging", "SOC", "Voltage", "Current", "Temp (°C)", "Door")) + print("-" * 120) + + # --- Table Rows --- + row_format = "| {:^7} | {:<18} | {:^8} | {:^8} | {:>5}% | {:>8} V | {:>8} A | {:>10}°C | {:^10} |" + for i, chamber in enumerate(data_dict.get("slotLevelPayload", []), start=1): + print(row_format.format( + i, + chamber.get('batteryIdentification', 'N/A'), + "✅" if chamber.get("batteryPresent") == 1 else "❌", + "✅" if chamber.get("chargingStatus") == 1 else "❌", + chamber.get('soc', 'N/A'), + chamber.get('batVoltage', 'N/A'), + chamber.get('current', 'N/A'), + chamber.get('batteryTemp', 'N/A'), + "OPEN" if chamber.get("doorStatus") == 1 else "CLOSED" + )) + + print("=" * 120 + "\n") + + except Exception as e: + print(f"Error printing periodic log to terminal: {e}") + + def update_diagnostic_alarms(self, sdc_value): + for i, error_text in enumerate(self.DIAGNOSTIC_ERRORS): + is_alarm_active = (sdc_value >> i) & 1 + label = self.diag_labels[error_text] + if is_alarm_active: label.setProperty("alarm", "active") + else: label.setProperty("alarm", "inactive") + label.style().unpolish(label) + label.style().polish(label) + + def set_config_inputs_enabled(self, enabled): + for w in self.config_tab.findChildren(QWidget): + if isinstance(w, (QLineEdit, QPushButton, QCheckBox, QComboBox)): + w.setEnabled(enabled) + + @pyqtSlot() # This new slot handles the disconnected signal + def handle_disconnection(self): + print("Main window sees disconnection, stopping logger if active.") + if self.csv_logger and self.csv_logger.timer.isActive(): + self.csv_logger.stop_logging() + + # You might also want to update UI elements here + self.connect_button.setText("Connect") + self.connection_status_label.setText("Disconnected") + + def closeEvent(self, event: QCloseEvent): + """ + Handles the window's close event to ensure a clean shutdown. + """ + print("--- Close event triggered. Shutting down gracefully... ---") + + if self.mqtt_thread and self.mqtt_thread.isRunning(): + print(" > Stopping MQTT client...") + # Tell the client to disconnect (which will stop its loop) + if self.mqtt_client: + self.mqtt_client.disconnect_from_broker() + + print(" > Quitting and waiting for MQTT thread...") + self.mqtt_thread.quit() + if not self.mqtt_thread.wait(5000): + print(" > Warning: Thread did not terminate in time.") + self.mqtt_thread.terminate() + + # The handle_disconnection slot will have already stopped the logger + # if it was running. No need to call it again here. + + print("--- Shutdown complete. ---") + event.accept() \ No newline at end of file diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..7265d5d --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,218 @@ +# --- Dynamic Theme Stylesheets --- + +def get_light_theme_styles(scale=1.0): + + log_font_size = max(10, int(11 * scale)) + button_font_size = max(7, int(10 * scale)) + + return f""" + QMainWindow, QWidget {{ + background-color: #f0f0f0; + color: #000; + }} + #LogPanel {{ + font-family: "Courier New", Consolas, monospace; + font-size: {log_font_size}pt; + background-color: #212121; + color: #eceff1; + border: 1px solid #455a64; + }} + QGroupBox {{ + font-family: Arial; + border: 1px solid #4a4a4a; + border-radius: {int(8 * scale)}px; + margin-top: {int(6 * scale)}px; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top center; + padding: 0 {int(10 * scale)}px; + color: #000; + }} + QTabWidget::pane {{ border-top: 2px solid #c8c8c8; }} + QTabBar::tab {{ + background: #e1e1e1; border: 1px solid #c8c8c8; + padding: {int(6 * scale)}px {int(15 * scale)}px; + border-top-left-radius: {int(4 * scale)}px; border-top-right-radius: {int(4 * scale)}px; + }} + QTabBar::tab:selected {{ background: #f0f0f0; border-bottom-color: #f0f0f0; }} + QFormLayout::label {{ color: #000; padding-top: {int(3 * scale)}px; }} + QLineEdit, QPlainTextEdit, QComboBox {{ + background-color: #fff; border: 1px solid #c8c8c8; + border-radius: {int(4 * scale)}px; padding: {int(4 * scale)}px; + font-size: {max(7, int(9 * scale))}pt; + }} + QLineEdit:read-only {{ background-color: #e9e9e9; }} + QPushButton {{ + background-color: #e1e1e1; border: 1px solid #c8c8c8; + padding: {int(5 * scale)}px {int(10 * scale)}px; + border-radius: {int(4 * scale)}px; + }} + QPushButton:hover {{ background-color: #dcdcdc; }} + QPushButton:pressed {{ background-color: #c8c8c8; }} + #RefreshButton, #ResetButton {{ + padding: {int(6 * scale)}px {int(16 * scale)}px; + font-size: {button_font_size * 1.3}pt; + font-weight: bold; + border-radius: {int(4*scale)}px; + }} + #RefreshButton {{ + background-color: #2e7d32; /* A slightly darker green */ + }} + #ResetButton {{ + background-color: #c62828; /* A slightly darker red */ + }} + #ChamberOpenDoorButton, #ChamberChgOnButton, #ChamberChgOffButton {{ + padding: {int(8 * scale)}px; + font-size: {button_font_size}pt; + font-weight: bold; + border-radius: {int(4*scale)}px; + }} + + #ChamberOpenDoorButton {{ background-color: #E1E1E1; }} + #ChamberChgOnButton {{ background-color: #E1E1E1; }} + #ChamberChgOffButton {{ background-color: #E1E1E1; }} + + #ChamberOpenDoorButton:hover {{ background-color: #3498DB; }} + #ChamberChgOnButton:hover {{ background-color: #229954; }} + #ChamberChgOffButton:hover {{ background-color: #c0392b; }} + + QPushButton:disabled {{ background-color: #d3d3d3; color: #a0a0a0; }} + + #ConnectButton, #DisconnectButton {{ + padding: {int(6 * scale)}px {int(16 * scale)}px; + font-size: {button_font_size}pt; + font-weight: bold; + border-radius: {int(4 * scale)}px; + color: white; + }} + + #ConnectButton {{ background-color: #27ae60; }} /* Green */ + #DisconnectButton {{ background-color: #c0392b; }} /* Red */ + + #ConnectButton:hover {{ background-color: #52be80; }} + #DisconnectButton:hover {{ background-color: #cd6155; }} + + #ConnectButton:pressed {{ background-color: #52be80; }} + #DisconnectButton:pressed {{ background-color: #cd6155; }} + + #ConnectButton:disabled, #DisconnectButton:disabled {{ + background-color: #546e7a; + color: #90a4ae; + }} + + #RefreshButton, #StartSwapButton {{ background-color: #27ae60; color: white; border: none; }} + #RefreshButton:hover, #StartSwapButton:hover {{ background-color: #229954; }} + #ResetButton, #AbortSwapButton {{ background-color: #c0392b; color: white; border: none; }} + #ResetButton:hover, #AbortSwapButton:hover {{ background-color: #c0392b; }} + #SendAudioButton {{ background-color: #3498db; color: white; border: none; font-size: {max(10, int(14 * scale))}px; }} + #SendAudioButton:hover {{ background-color: #2980b9; }} + QLabel[status="present"] {{ background-color: #2ecc71; color: white; border-radius: {int(4*scale)}px; padding: {int(3*scale)}px; }} + QLabel[status="absent"] {{ background-color: #e74c3c; color: white; border-radius: {int(4*scale)}px; padding: {int(3*scale)}px; }} + QLabel[alarm="active"] {{ background-color: #e74c3c; color: white; font-weight: bold; border-radius: {int(4*scale)}px; padding: {int(2*scale)}px; }} + QLabel[alarm="inactive"] {{ background-color: transparent; color: black; }} + QGroupBox#ChamberWidget {{ border: 2px solid #3498db; }} + """ + +def get_dark_theme_styles(scale=1.0): + + log_font_size = max(10, int(11 * scale)) + button_font_size = max(7, int(10 * scale)) + + return f""" + QMainWindow, QWidget {{ background-color: #2b2b2b; color: #f0f0f0; }} + #LogPanel {{ + font-family: "Courier New", Consolas, monospace; + font-size: {log_font_size}pt; + background-color: #212121; + color: #eceff1; + border: 1px solid #455a64; + }} + QGroupBox {{ + font-family: Arial; border: 1px solid #4a4a4a; + border-radius: {int(8 * scale)}px; margin-top: {int(6 * scale)}px; + }} + QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top center; padding: 0 {int(10 * scale)}px; color: #f0f0f0; }} + QTabWidget::pane {{ border-top: 2px solid #4a4a4a; }} + QTabBar::tab {{ + background: #3c3c3c; border: 1px solid #4a4a4a; color: #f0f0f0; + padding: {int(6 * scale)}px {int(15 * scale)}px; + border-top-left-radius: {int(4 * scale)}px; border-top-right-radius: {int(4 * scale)}px; + }} + QTabBar::tab:selected {{ background: #2b2b2b; border-bottom-color: #2b2b2b; }} + QFormLayout::label {{ color: #f0f0f0; padding-top: {int(3 * scale)}px; }} + QLineEdit, QPlainTextEdit, QComboBox {{ + background-color: #3c3c3c; border: 1px solid #4a4a4a; + border-radius: {int(4 * scale)}px; padding: {int(4 * scale)}px; color: #f0f0f0; + font-size: {max(7, int(9 * scale))}pt; + }} + QLineEdit:read-only {{ background-color: #333333; }} + QPushButton {{ + background-color: #555555; border: 1px solid #4a4a4a; + padding: {int(5 * scale)}px {int(10 * scale)}px; + border-radius: {int(4 * scale)}px; color: #f0f0f0; + }} + QPushButton:hover {{ background-color: #6a6a6a; }} + QPushButton:pressed {{ background-color: #4a4a4a; }} + QPushButton:disabled {{ background-color: #404040; color: #888888; }} + #RefreshButton, #ResetButton {{ + padding: {int(6 * scale)}px {int(16 * scale)}px; + font-size: {button_font_size * 1.3}pt; + font-weight: bold; + border-radius: {int(4*scale)}px; + }} + #RefreshButton {{ + background-color: #2e7d32; /* A slightly darker green */ + }} + #ResetButton {{ + background-color: #c62828; /* A slightly darker red */ + }} + #ChamberOpenDoorButton, #ChamberChgOnButton, #ChamberChgOffButton {{ + padding: {int(8 * scale)}px; + font-size: {button_font_size}pt; + font-weight: bold; + border-radius: {int(4*scale)}px; + }} + + #ChamberOpenDoorButton {{ background-color: #3C3C3C; }} + #ChamberChgOnButton {{ background-color: #3C3C3C; }} + #ChamberChgOffButton {{ background-color: #3C3C3C; }} + + #ChamberOpenDoorButton:hover {{ background-color: #607d8b; }} + #ChamberChgOnButton:hover {{ background-color: #52be80; }} + #ChamberChgOffButton:hover {{ background-color: #cd6155; }} + + #ConnectButton, #DisconnectButton {{ + padding: {int(6 * scale)}px {int(16 * scale)}px; + font-size: {button_font_size}pt; + font-weight: bold; + border-radius: {int(4*scale)}px; + color: white; + }} + + #ConnectButton {{ background-color: #27ae60; }} /* Green */ + #DisconnectButton {{ background-color: #c0392b; }} /* Red */ + + #ConnectButton:hover {{ background-color: #52be80; }} + #DisconnectButton:hover {{ background-color: #cd6155; }} + + #ConnectButton:pressed {{ background-color: #52be80; }} + #DisconnectButton:pressed {{ background-color: #cd6155; }} + + #ConnectButton:disabled, #DisconnectButton:disabled {{ + background-color: #546e7a; + color: #90a4ae; + }} + + #RefreshButton, #StartSwapButton {{ background-color: #27ae60; color: white; border: none; }} + #RefreshButton:hover, #StartSwapButton:hover {{ background-color: #52be80; }} + #ResetButton, #AbortSwapButton {{ background-color: #c0392b; color: white; border: none; }} + #ResetButton:hover, #AbortSwapButton:hover {{ background-color: #cd6155; }} + #SendAudioButton {{ background-color: #3498db; color: white; border: none; font-size: {max(10, int(14 * scale))}px; }} + #SendAudioButton:hover {{ background-color: #5dade2; }} + QLabel[status="present"] {{ background-color: #2ecc71; color: white; border-radius: {int(4*scale)}px; padding: {int(3*scale)}px; }} + QLabel[status="absent"] {{ background-color: #e74c3c; color: white; border-radius: {int(4*scale)}px; padding: {int(3*scale)}px; }} + QLabel[alarm="active"] {{ background-color: #e74c3c; color: white; font-weight: bold; border-radius: {int(4*scale)}px; padding: {int(2*scale)}px; }} + QLabel[alarm="inactive"] {{ background-color: transparent; color: #f0f0f0; }} + QGroupBox#ChamberWidget {{ border: 2px solid #3498db; }} + """ \ No newline at end of file diff --git a/ui/widgets.py b/ui/widgets.py new file mode 100644 index 0000000..0dd1236 --- /dev/null +++ b/ui/widgets.py @@ -0,0 +1,153 @@ +# --- REPLACE the entire content of ui/widgets.py with this --- + +from PyQt6.QtWidgets import ( + QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QFrame, QPushButton, QFormLayout +) +from PyQt6.QtCore import Qt, pyqtSignal # <-- IMPORT pyqtSignal +from PyQt6.QtGui import QFont + +class ChamberWidget(QGroupBox): + # --- ADD SIGNALS HERE --- + open_door_requested = pyqtSignal() + chg_on_requested = pyqtSignal() + chg_off_requested = pyqtSignal() + + def __init__(self, title="CHAMBER - X", scale=1.0): + super().__init__(title) + self.setObjectName("ChamberWidget") + self.setFont(QFont("Arial", max(8, int(9 * scale)), QFont.Weight.Bold)) + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(max(2, int(4 * scale))) + + # This section creates the 'id_field' that was missing + id_layout = QHBoxLayout() + id_layout.addWidget(QLabel("BAT ID: ")) + self.id_field = self._create_data_field(scale) # This line ensures self.id_field exists + id_layout.addWidget(self.id_field) + main_layout.addLayout(id_layout) + + columns_layout = QHBoxLayout() + battery_form_layout = QFormLayout() + battery_form_layout.setVerticalSpacing(max(2, int(4 * scale))) + self.battery_status_label = self._create_status_label("ABSENT", scale) + self.battery_status_label.setProperty("status", "absent") + self.soc_field = self._create_data_field(scale) + self.voltage_field = self._create_data_field(scale) + self.temp_field = self._create_data_field(scale) + self.battery_fault_field = self._create_data_field(scale) + + battery_form_layout.addRow("Status:", self.battery_status_label) + battery_form_layout.addRow("SOC:", self.soc_field) + battery_form_layout.addRow("Voltage:", self.voltage_field) + battery_form_layout.addRow("Temp:", self.temp_field) + battery_form_layout.addRow("Fault:", self.battery_fault_field) + + separator_line = QFrame() + separator_line.setFrameShape(QFrame.Shape.VLine) + separator_line.setFrameShadow(QFrame.Shadow.Sunken) + + charger_form_layout = QFormLayout() + charger_form_layout.setVerticalSpacing(max(2, int(4 * scale))) + self.charger_status_label = self._create_status_label("OFF", scale) + self.charger_status_label.setProperty("status", "absent") + self.slot_temp_field = self._create_data_field(scale) + self.chg_temp_field = self._create_data_field(scale) + self.door_status_field = self._create_data_field(scale) + self.charger_fault_field = self._create_data_field(scale) + + charger_form_layout.addRow("Chg Status:", self.charger_status_label) + charger_form_layout.addRow("Chg Temp:", self.chg_temp_field) + charger_form_layout.addRow("Slot Temp:", self.slot_temp_field) + charger_form_layout.addRow("Door Status:", self.door_status_field) + charger_form_layout.addRow("Fault:", self.charger_fault_field) + + columns_layout.addLayout(battery_form_layout) + columns_layout.addWidget(separator_line) + columns_layout.addLayout(charger_form_layout) + main_layout.addLayout(columns_layout) + + main_layout.addStretch() + + button_layout = QHBoxLayout() + self.open_door_btn = QPushButton("OPEN DOOR") + self.chg_on_btn = QPushButton("CHG ON") + self.chg_off_btn = QPushButton("CHG OFF") + + self.open_door_btn.setObjectName("ChamberOpenDoorButton") + self.chg_on_btn.setObjectName("ChamberChgOnButton") + self.chg_off_btn.setObjectName("ChamberChgOffButton") + + self.open_door_btn.clicked.connect(self.open_door_requested.emit) + self.chg_on_btn.clicked.connect(self.chg_on_requested.emit) + self.chg_off_btn.clicked.connect(self.chg_off_requested.emit) + + button_layout.addWidget(self.open_door_btn) + button_layout.addWidget(self.chg_on_btn) + button_layout.addWidget(self.chg_off_btn) + main_layout.addLayout(button_layout) + + # ... (the rest of the class is unchanged) ... + def _create_status_label(self, text, scale): + label = QLabel(text) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setFont(QFont("Arial", max(7, int(8 * scale)), QFont.Weight.Bold)) + return label + + def _create_data_field(self, scale): + field = QLineEdit("N/A") + field.setReadOnly(True) + field.setFont(QFont("Arial", max(7, int(8 * scale)))) + return field + + def update_data(self, data): + if data.get("batteryPresent") == 1: + self.battery_status_label.setText("PRESENT") + self.battery_status_label.setProperty("status", "present") + else: + self.battery_status_label.setText("ABSENT") + self.battery_status_label.setProperty("status", "absent") + + if data.get("chargerPresent") == 1: + self.charger_status_label.setText("CHARGER ON") + self.charger_status_label.setProperty("status", "present") + else: + self.charger_status_label.setText("CHARGER OFF") + self.charger_status_label.setProperty("status", "absent") + + for widget in [self.battery_status_label, self.charger_status_label]: + widget.style().unpolish(widget) + widget.style().polish(widget) + + self.id_field.setText(data.get("batteryIdentification", "N/A")) + self.soc_field.setText(f'{data.get("soc", 0)}%') + self.voltage_field.setText(f'{data.get("voltage", 0) / 1000.0:.2f} V') + self.temp_field.setText(f'{data.get("batteryMaxTemp", 0) / 10.0:.1f} °C') + self.battery_fault_field.setText(str(data.get("batteryFaultCode", 0))) + self.slot_temp_field.setText(f'{data.get("slotTemperature", 0) / 10.0:.1f} °C') + self.chg_temp_field.setText(f'{data.get("chargerTemp", 0) / 10.0:.1f} °C') + self.charger_fault_field.setText(str(data.get("chargerFaultCode", 0))) + door_status = "CLOSED" if data.get("doorStatus") == 1 else "OPEN" + self.door_status_field.setText(door_status) + + def reset_to_default(self): + """Resets all fields in this chamber widget to their default state.""" + self.battery_status_label.setText("ABSENT") + self.battery_status_label.setProperty("status", "absent") + self.charger_status_label.setText("CHARGER OFF") + self.charger_status_label.setProperty("status", "absent") + + # Re-apply the stylesheet for the status labels + for widget in [self.battery_status_label, self.charger_status_label]: + widget.style().unpolish(widget) + widget.style().polish(widget) + + self.id_field.setText("N/A") + self.soc_field.setText("N/A") + self.voltage_field.setText("N/A") + self.temp_field.setText("N/A") + self.battery_fault_field.setText("N/A") + self.slot_temp_field.setText("N/A") + self.chg_temp_field.setText("N/A") + self.door_status_field.setText("N/A") + self.charger_fault_field.setText("N/A") \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..5af210c --- /dev/null +++ b/utils.py @@ -0,0 +1,13 @@ +import sys +import os + +def resource_path(relative_path): + """ Get absolute path to resource, works for dev and for PyInstaller """ + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + # If not running as a bundled exe, use the normal script path + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) \ No newline at end of file