diff --git a/.gitignore b/.gitignore index a8e81fc..cda907d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ env/ venv/ .idea/ logs.txt -.DS_Store \ No newline at end of file +.DS_Store +*pb2.py +.venv diff --git a/readme.md b/readme.md index 7825ce1..5868a63 100644 --- a/readme.md +++ b/readme.md @@ -112,6 +112,35 @@ You can always create ```config.json``` file yourself if you think writing it wi ``` > result - app publishes random int value on 'topic1' every second and random uint value on 'test/topic2' +# Protobuf +To use protobuf place compiled message files in src/protofiles. + +## Custom message content + +To send a message with custom contents use *.csv file. Program will look for specified file in root directory. + +### Example: + +Given these protobuf messages: +```protobuf +message Message1 { + int32 my_int = 1; + Message2 nested_message = 2; +} +message Message2 { + int32 my_int = 1; +} +``` + +example csv file for Message1 would look like this: + +```csv +my_int,nested_message.my_int +123,456 +456,1312 +123123,45123 +``` + ## Screenshots Keep in mind that the look of the app is dependent on user's system - QT uses native components.

@@ -125,3 +154,4 @@ Main window
Add topic window

+ diff --git a/src/abstractdatagenerator.py b/src/abstractdatagenerator.py new file mode 100644 index 0000000..87a7e87 --- /dev/null +++ b/src/abstractdatagenerator.py @@ -0,0 +1,6 @@ +class DataGenerator(): + def __init__(self, config): + pass + + def next_message(self) -> str: + pass diff --git a/src/arbiter.py b/src/arbiter.py new file mode 100644 index 0000000..1e0b0c2 --- /dev/null +++ b/src/arbiter.py @@ -0,0 +1,11 @@ +from abstractdatagenerator import DataGenerator +from jsondatagenerator import JsonDataGenerator +from protogenerator import ProtoDataGenerator + +def get_data_generator(config) -> DataGenerator: + if "data_format" in config: + return JsonDataGenerator(config) + elif "message" in config: + return ProtoDataGenerator(config) + + diff --git a/src/gui/addprototopicdialog.ui b/src/gui/addprototopicdialog.ui new file mode 100644 index 0000000..a94adc2 --- /dev/null +++ b/src/gui/addprototopicdialog.ui @@ -0,0 +1,169 @@ + + + AddProtoTopicDialog + + + + 0 + 0 + 457 + 337 + + + + + 12 + + + + Add topic + + + + + + + + + 14 + + + + Add topic + + + + + + + + + Name + + + + + + + Name of topic + + + + + + + Message + + + + + + + + + + Interval (seconds) + + + + + + + Interval of incoming data + + + 0.100000000000000 + + + 60.000000000000000 + + + 0.500000000000000 + + + 1.500000000000000 + + + + + + + Manual + + + + + + + If checked, the data will be automatically send every <interval> seconds + + + + + + false + + + + + + + + + + File (optional) + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + AddProtoTopicDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + AddProtoTopicDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/gui/choosetopicdialog.ui b/src/gui/choosetopicdialog.ui new file mode 100644 index 0000000..3e49633 --- /dev/null +++ b/src/gui/choosetopicdialog.ui @@ -0,0 +1,45 @@ + + + ChooseTopicDialog + + + + 0 + 0 + 319 + 153 + + + + Choose message type + + + + + 9 + 19 + 291 + 121 + + + + + + + Protobuf + + + + + + + JSON + + + + + + + + + diff --git a/src/gui/generated/addprototopicdialog.py b/src/gui/generated/addprototopicdialog.py new file mode 100644 index 0000000..ccc58cd --- /dev/null +++ b/src/gui/generated/addprototopicdialog.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'addprototopicdialog.ui' +## +## Created by: Qt User Interface Compiler version 6.7.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QDialog, + QDialogButtonBox, QDoubleSpinBox, QFormLayout, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QSizePolicy, + QVBoxLayout, QWidget) + +class Ui_AddProtoTopicDialog(object): + def setupUi(self, AddProtoTopicDialog): + if not AddProtoTopicDialog.objectName(): + AddProtoTopicDialog.setObjectName(u"AddProtoTopicDialog") + AddProtoTopicDialog.resize(457, 337) + font = QFont() + font.setPointSize(12) + AddProtoTopicDialog.setFont(font) + self.verticalLayout_2 = QVBoxLayout(AddProtoTopicDialog) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName(u"verticalLayout") + self.label = QLabel(AddProtoTopicDialog) + self.label.setObjectName(u"label") + font1 = QFont() + font1.setPointSize(14) + self.label.setFont(font1) + + self.verticalLayout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignTop) + + self.formLayout = QFormLayout() + self.formLayout.setObjectName(u"formLayout") + self.name_lbl = QLabel(AddProtoTopicDialog) + self.name_lbl.setObjectName(u"name_lbl") + + self.formLayout.setWidget(0, QFormLayout.LabelRole, self.name_lbl) + + self.name_line_edit = QLineEdit(AddProtoTopicDialog) + self.name_line_edit.setObjectName(u"name_line_edit") + + self.formLayout.setWidget(0, QFormLayout.FieldRole, self.name_line_edit) + + self.format_lbl = QLabel(AddProtoTopicDialog) + self.format_lbl.setObjectName(u"format_lbl") + + self.formLayout.setWidget(1, QFormLayout.LabelRole, self.format_lbl) + + self.message_list = QListWidget(AddProtoTopicDialog) + self.message_list.setObjectName(u"message_list") + + self.formLayout.setWidget(1, QFormLayout.FieldRole, self.message_list) + + self.interval_lbl = QLabel(AddProtoTopicDialog) + self.interval_lbl.setObjectName(u"interval_lbl") + + self.formLayout.setWidget(3, QFormLayout.LabelRole, self.interval_lbl) + + self.interval_spin_box = QDoubleSpinBox(AddProtoTopicDialog) + self.interval_spin_box.setObjectName(u"interval_spin_box") + self.interval_spin_box.setMinimum(0.100000000000000) + self.interval_spin_box.setMaximum(60.000000000000000) + self.interval_spin_box.setSingleStep(0.500000000000000) + self.interval_spin_box.setValue(1.500000000000000) + + self.formLayout.setWidget(3, QFormLayout.FieldRole, self.interval_spin_box) + + self.manual_lbl = QLabel(AddProtoTopicDialog) + self.manual_lbl.setObjectName(u"manual_lbl") + + self.formLayout.setWidget(4, QFormLayout.LabelRole, self.manual_lbl) + + self.manual_check_box = QCheckBox(AddProtoTopicDialog) + self.manual_check_box.setObjectName(u"manual_check_box") + self.manual_check_box.setChecked(False) + + self.formLayout.setWidget(4, QFormLayout.FieldRole, self.manual_check_box) + + self.file_line_edit = QLineEdit(AddProtoTopicDialog) + self.file_line_edit.setObjectName(u"file_line_edit") + + self.formLayout.setWidget(2, QFormLayout.FieldRole, self.file_line_edit) + + self.path_label = QLabel(AddProtoTopicDialog) + self.path_label.setObjectName(u"path_label") + + self.formLayout.setWidget(2, QFormLayout.LabelRole, self.path_label) + + + self.verticalLayout.addLayout(self.formLayout) + + + self.verticalLayout_2.addLayout(self.verticalLayout) + + self.buttonBox = QDialogButtonBox(AddProtoTopicDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) + + self.verticalLayout_2.addWidget(self.buttonBox) + + + self.retranslateUi(AddProtoTopicDialog) + self.buttonBox.accepted.connect(AddProtoTopicDialog.accept) + self.buttonBox.rejected.connect(AddProtoTopicDialog.reject) + + QMetaObject.connectSlotsByName(AddProtoTopicDialog) + # setupUi + + def retranslateUi(self, AddProtoTopicDialog): + AddProtoTopicDialog.setWindowTitle(QCoreApplication.translate("AddProtoTopicDialog", u"Add topic", None)) + self.label.setText(QCoreApplication.translate("AddProtoTopicDialog", u"Add topic", None)) + self.name_lbl.setText(QCoreApplication.translate("AddProtoTopicDialog", u"Name", None)) +#if QT_CONFIG(tooltip) + self.name_line_edit.setToolTip(QCoreApplication.translate("AddProtoTopicDialog", u"Name of topic", None)) +#endif // QT_CONFIG(tooltip) + self.format_lbl.setText(QCoreApplication.translate("AddProtoTopicDialog", u"Message", None)) + self.interval_lbl.setText(QCoreApplication.translate("AddProtoTopicDialog", u"Interval (seconds)", None)) +#if QT_CONFIG(tooltip) + self.interval_spin_box.setToolTip(QCoreApplication.translate("AddProtoTopicDialog", u"Interval of incoming data", None)) +#endif // QT_CONFIG(tooltip) + self.manual_lbl.setText(QCoreApplication.translate("AddProtoTopicDialog", u"Manual", None)) +#if QT_CONFIG(tooltip) + self.manual_check_box.setToolTip(QCoreApplication.translate("AddProtoTopicDialog", u"If checked, the data will be automatically send every seconds", None)) +#endif // QT_CONFIG(tooltip) + self.manual_check_box.setText("") + self.path_label.setText(QCoreApplication.translate("AddProtoTopicDialog", u"File (optional)", None)) + # retranslateUi + diff --git a/src/gui/generated/choosetopicdialog.py b/src/gui/generated/choosetopicdialog.py new file mode 100644 index 0000000..038e7a7 --- /dev/null +++ b/src/gui/generated/choosetopicdialog.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'choosetopicdialog.ui' +## +## Created by: Qt User Interface Compiler version 6.5.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QDialog, QHBoxLayout, QPushButton, + QSizePolicy, QWidget) + +class Ui_ChooseTopicDialog(object): + def setupUi(self, ChooseTopicDialog): + if not ChooseTopicDialog.objectName(): + ChooseTopicDialog.setObjectName(u"ChooseTopicDialog") + ChooseTopicDialog.resize(319, 153) + self.horizontalLayoutWidget = QWidget(ChooseTopicDialog) + self.horizontalLayoutWidget.setObjectName(u"horizontalLayoutWidget") + self.horizontalLayoutWidget.setGeometry(QRect(9, 19, 291, 121)) + self.horizontalLayout = QHBoxLayout(self.horizontalLayoutWidget) + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.choose_proto_button = QPushButton(self.horizontalLayoutWidget) + self.choose_proto_button.setObjectName(u"choose_proto_button") + + self.horizontalLayout.addWidget(self.choose_proto_button) + + self.choose_json_button = QPushButton(self.horizontalLayoutWidget) + self.choose_json_button.setObjectName(u"choose_json_button") + + self.horizontalLayout.addWidget(self.choose_json_button) + + + self.retranslateUi(ChooseTopicDialog) + + QMetaObject.connectSlotsByName(ChooseTopicDialog) + # setupUi + + def retranslateUi(self, ChooseTopicDialog): + ChooseTopicDialog.setWindowTitle(QCoreApplication.translate("ChooseTopicDialog", u"Choose message type", None)) + self.choose_proto_button.setText(QCoreApplication.translate("ChooseTopicDialog", u"Protobuf", None)) + self.choose_json_button.setText(QCoreApplication.translate("ChooseTopicDialog", u"JSON", None)) + # retranslateUi + diff --git a/src/gui/ui.py b/src/gui/ui.py index 7ee8577..084c4ed 100644 --- a/src/gui/ui.py +++ b/src/gui/ui.py @@ -19,6 +19,9 @@ from logger import QListWidgetLogHandler from os import listdir, path, getcwd import icons.generated.icons +from gui.generated.addprototopicdialog import Ui_AddProtoTopicDialog +from gui.generated.choosetopicdialog import Ui_ChooseTopicDialog +from protogenerator import ProtoDataGenerator class MqttSimTopicToolButton(QToolButton): @@ -39,7 +42,8 @@ def __init__(self): self.predefined_pattern_combo_box.currentIndexChanged.connect( self.__on_pattern_selected ) - self.save_as_pattern_btn.clicked.connect(self.__on_save_as_pattern_btn_clicked) + self.save_as_pattern_btn.clicked.connect( + self.__on_save_as_pattern_btn_clicked) def __on_load_from_file_btn_clicked(self) -> None: filename, _ = QFileDialog.getOpenFileName( @@ -180,6 +184,43 @@ def set_topic_name(self, new_topic_name: str) -> None: self.topic = new_topic_name self.topic_lbl.setText(new_topic_name) + +class MqttSimAddProtoTopicWindow(Ui_AddProtoTopicDialog, QDialog): + def __init__(self): + super(MqttSimAddProtoTopicWindow, self).__init__() + self.setupUi(self) + self.setWindowIcon(QIcon(":/icons/mqtt.svg")) + + +class MqttSimEditProtoTopicWindow(MqttSimAddProtoTopicWindow, QDialog): + def __init__(self, topic_name: str, topic_data: dict): + super(MqttSimEditProtoTopicWindow, self).__init__() + self.__set_topic_values(topic_name, topic_data) + self.setWindowIcon(QIcon(":/icons/mqtt.svg")) + + def __set_topic_values(self, topic_name, topic_data) -> None: + messages = ProtoDataGenerator.get_message_constructors() + current_item = None + message_names = sorted(list(messages.keys()), key=lambda s: (not 'Msg' in s, s)) + for message_name in message_names: + new_item = QListWidgetItem(message_name) + self.message_list.addItem(new_item) + if message_name == topic_data["message"]: + current_item = new_item + self.message_list.setCurrentItem(current_item) + self.name_line_edit.setText(topic_name) + self.file_line_edit.setText(topic_data["file"]) + self.interval_spin_box.setValue(topic_data.get("interval")) + self.manual_check_box.setChecked(topic_data.get("manual")) + + +class MqttSimChooseTopicWindow(Ui_ChooseTopicDialog, QDialog): + def __init__(self): + super(MqttSimChooseTopicWindow, self).__init__() + self.setupUi(self) + self.setWindowIcon(QIcon(":/icons/mqtt.svg")) + + class MqttSimMainWindow(Ui_MainWindow, QMainWindow): def __init__(self, sim: MqttSim): super(MqttSimMainWindow, self).__init__() @@ -212,12 +253,12 @@ def on_broker_connect_btn_clicked() -> None: else: if self.__sim.connect_to_broker(): self.broker_connect_btn.setText( - QCoreApplication.translate("MainWindow", "Disconnect", None) + QCoreApplication.translate( + "MainWindow", "Disconnect", None) ) self.broker_connect_btn.setToolTip( QCoreApplication.translate( - "MainWindow", "Disconnect from broker", None - ) + "MainWindow", "Disconnect from broker", None) ) def on_clear_logs_btn_clicked() -> None: @@ -225,9 +266,21 @@ def on_clear_logs_btn_clicked() -> None: self.__logger.info("Cleared logs.") def on_add_topic_btn_clicked() -> None: + choose_topic_window = MqttSimChooseTopicWindow() + choose_topic_window.choose_json_button.clicked.connect( + choose_topic_window.close) + choose_topic_window.choose_json_button.clicked.connect( + on_add_json_btn_clicked) + choose_topic_window.choose_proto_button.clicked.connect( + choose_topic_window.close) + choose_topic_window.choose_proto_button.clicked.connect( + on_add_proto_btn_clicked) + choose_topic_window.exec() + + def on_add_json_btn_clicked() -> None: def validate_input(topic_config) -> bool: return len(topic_config.get("topic")) > 0 - + add_topic_window = MqttSimAddTopicWindow() while True: if add_topic_window.exec() == QDialog.Accepted: @@ -246,6 +299,34 @@ def validate_input(topic_config) -> bool: else: break # Break out of the loop if dialog is cancelled + def on_add_proto_btn_clicked() -> None: + def validate_input(topic_name, topic_config) -> bool: + return (len(topic_name) > 0 and topic_name not in self.__config.get_topics().keys()) + + add_topic_window = MqttSimAddProtoTopicWindow() + messages = ProtoDataGenerator.get_message_constructors() + + message_names = sorted(list(messages.keys()), key=lambda s: (not 'Msg' in s, s)) + for message_name in message_names: + add_topic_window.message_list.addItem(message_name) + if add_topic_window.exec(): + if add_topic_window.message_list.currentItem() is None: + QMessageBox().critical(self, "Error!", "Invalid topic input.") + return + topic_name = add_topic_window.name_line_edit.text() + topic_config = { + "topic": add_topic_window.name_line_edit.text(), + "message": add_topic_window.message_list.currentItem().text(), + "interval": add_topic_window.interval_spin_box.value(), + "manual": add_topic_window.manual_check_box.isChecked(), + "file": add_topic_window.file_line_edit.text() + } + if validate_input(topic_name, topic_config): + uuid = self.__sim.add_topic(topic_config) + self.__add_topic_to_item_list(uuid) + else: + QMessageBox().critical(self, "Error!", "Invalid topic input.") + def on_broker_info_changed() -> None: self.__sim.set_broker(self.broker_hostname.text(), self.broker_port.value()) @@ -271,7 +352,7 @@ def on_remove_btn_clicked() -> None: result = QMessageBox.question( self, "Remove topic?", - f"Are you sure you want to remove topic {self.__config.get_topic_data(topic_uuid).get("topic")} [uuid={topic_uuid}]?", + f'Are you sure you want to remove topic {self.__config.get_topic_data(topic_uuid).get("topic")} [uuid={topic_uuid}]?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if result == QMessageBox.StandardButton.Yes: self.__sim.remove_topic(topic_uuid) @@ -279,20 +360,37 @@ def on_remove_btn_clicked() -> None: def on_edit_btn_clicked() -> None: data = self.__config.get_topic_data(topic_uuid) - edit_topic_window = MqttSimEditTopicWindow(data) - if edit_topic_window.exec(): - edited_data = { - "topic": edit_topic_window.name_line_edit.text(), - "data_format": edit_topic_window.format_text_edit.toPlainText(), - "interval": edit_topic_window.interval_spin_box.value(), - "manual": edit_topic_window.manual_check_box.isChecked(), - } - if edited_data != data: - topic_widget.set_topic_name(edited_data.get("topic")) - self.__sim.edit(topic_uuid, edited_data) - self.__logger.info( - f"Edited topic {data.get("topic")} [uuid={topic_uuid}] ({data} -> {edited_data})." - ) + if "data_format" in data.keys(): + edit_topic_window = MqttSimEditTopicWindow(data) + if edit_topic_window.exec(): + edited_data = { + "topic": edit_topic_window.name_line_edit.text(), + "data_format": edit_topic_window.format_text_edit.toPlainText(), + "interval": edit_topic_window.interval_spin_box.value(), + "manual": edit_topic_window.manual_check_box.isChecked(), + } + if edited_data != data: + topic_widget.set_topic_name(edited_data.get("topic")) + self.__sim.edit(topic_uuid, edited_data) + self.__logger.info( + f'Edited topic {data.get("topic")} [uuid={topic_uuid}] ({data} -> {edited_data}).' + ) + else: + edit_topic_window = MqttSimEditProtoTopicWindow( + topic_name, data) + if edit_topic_window.exec(): + edited_topic_data = { + "topic": edit_topic_window.name_line_edit.text(), + "message": edit_topic_window.message_list.currentItem().text(), + "interval": edit_topic_window.interval_spin_box.value(), + "manual": edit_topic_window.manual_check_box.isChecked(), + "file": edit_topic_window.file_line_edit.text() + } + if edited_topic_data != data: + self.__sim.edit(topic_uuid, edited_topic_data) + self.__logger.info( + f'Edited topic {data.get("topic")} [uuid={topic_uuid}] ({data} -> {edited_topic_data}).' + ) topic_widget.remove_btn.clicked.connect(on_remove_btn_clicked) topic_widget.edit_btn.clicked.connect(on_edit_btn_clicked) diff --git a/src/mqttsimdatagenerator.py b/src/jsondatagenerator.py similarity index 98% rename from src/mqttsimdatagenerator.py rename to src/jsondatagenerator.py index 590619b..524ef68 100644 --- a/src/mqttsimdatagenerator.py +++ b/src/jsondatagenerator.py @@ -5,10 +5,12 @@ from functools import partial from datetime import datetime from copy import copy +from abstractdatagenerator import DataGenerator -class MqttSimDataGenerator: - def __init__(self, data_format: str): - self.reinitalize(data_format) + +class JsonDataGenerator(DataGenerator): + def __init__(self, config: dict): + self.reinitalize(config.get("data_format")) def next_message(self): message = self.__format_str diff --git a/src/mqttsim.py b/src/mqttsim.py index 5235cbf..fea2620 100644 --- a/src/mqttsim.py +++ b/src/mqttsim.py @@ -3,8 +3,10 @@ from threading import Thread from datetime import datetime from time import sleep -from mqttsimdatagenerator import MqttSimDataGenerator +from jsondatagenerator import JsonDataGenerator +from protogenerator import ProtoDataGenerator from uuid import uuid4 +from arbiter import get_data_generator class MqttSimConfig: def __init__(self, path: str): @@ -28,7 +30,7 @@ def put_broker(self, host: str, port: int, username: str | None = None, password def get_broker(self) -> tuple[str, int, str, str]: broker_info = self.__config.get("broker") if broker_info is None: - self.__config.put("broker", { "host": "localhost", "port": 1883 }) + self.__config.put("broker", {"host": "localhost", "port": 1883}) return self.get_broker() return ( broker_info.get("host", "localhost"), @@ -52,10 +54,10 @@ class MqttSim: def __init__(self, config: MqttSimConfig, logger: any): self.__logger = logger self.__config = config - self.__topic_data_generators = { - topic_uuid: MqttSimDataGenerator(topic_config.get("data_format")) - for topic_uuid, topic_config in self.__config.get_topics().items() - } + self.__topic_data_generators = dict() + ProtoDataGenerator.logger = logger + for topic_uuid, topic_config in self.__config.get_topics().items(): + self.__topic_data_generators[topic_uuid] = get_data_generator(topic_config) self.__setup_client() self.__setup_publishing_thread() @@ -89,9 +91,15 @@ def time_diff_in_seconds(time1, time2) -> int: def __setup_client(self) -> None: def on_message(client, userdata, message) -> None: - self.__logger.info(f"Received message from broker: '{message}'.") + message_contents = str(message.payload) + message_contents = message_contents.replace('\n', ' ') + message_contents = message_contents[:3] + '[...]' + message_contents[-3:] + self.__logger.info(f"Received message from broker {message.topic}: {message_contents}.") def on_connect(client, userdata, flags, rc) -> None: + topics = self.__config.get_topics() + for topic_uuid in topics: + self.__client.subscribe(topics[topic_uuid]["topic"]) if rc == CONNACK_ACCEPTED: self.__logger.info("Connected to broker.") else: @@ -134,7 +142,7 @@ def connect_to_broker(self) -> bool: return False except Exception: self.__logger.error( - f"Unknown error occured when trying to connect to broker." + "Unknown error occured when trying to connect to broker." ) return False return True @@ -154,17 +162,16 @@ def remove_topic(self, topic_uuid: str) -> None: topic_data = self.__config.get_topic_data(topic_uuid) self.__client.unsubscribe(topic_data.get("topic")) self.__config.remove_topic(topic_uuid) - self.__logger.info(f"Removed topic: {topic_data.get("topic")}) [uuid={topic_uuid}].") - del self.__topic_data_generators[topic_uuid] + if topic_uuid in self.__topic_data_generators: + self.__logger.info(f'Removed topic: {topic_data.get("topic")} [uuid={topic_uuid}].') + del self.__topic_data_generators[topic_uuid] # Adds topic to config (and saves it into config file). # If publishing thread was already started, it will take the topic into account. def add_topic(self, topic_config: dict) -> str: uuid = self.__config.put_topic(topic_config) - self.__logger.info(f"Added topic: {topic_config.get("topic")} [uuid={uuid}].") - self.__topic_data_generators[uuid] = MqttSimDataGenerator( - topic_config.get("data_format") - ) + self.__logger.info(f'Added topic: {topic_config.get("topic")} [uuid={uuid}].') + self.__topic_data_generators[uuid] = get_data_generator(topic_config) return uuid def get_logger(self) -> any: @@ -175,15 +182,14 @@ def get_config(self) -> MqttSimConfig: def edit(self, topic_uuid, new_data) -> None: self.__config.put_topic(new_data, uuid=topic_uuid) - self.__topic_data_generators[topic_uuid].reinitalize( - new_data.get("data_format") - ) - + if topic_uuid in self.__topic_data_generators: + self.__topic_data_generators[topic_uuid] = get_data_generator(new_data) def send_single_message(self, topic_uuid) -> None: if not self.is_connected_to_broker(): self.__logger.error("Trying to send message when not connected to broker.") return topic_data = self.__config.get_topic_data(topic_uuid) - self.__logger.info(f"Publishing data on {topic_data.get("topic")} [uuid={topic_uuid}]...") - message = self.__topic_data_generators.get(topic_uuid).next_message() + self.__logger.info(f'Publishing data on {topic_data.get("topic")} [uuid={topic_uuid}]...') + message = self.__topic_data_generators.get( + topic_uuid).next_message() self.__client.publish(topic_data.get("topic"), message) diff --git a/src/protofiles/__init__.py b/src/protofiles/__init__.py new file mode 100644 index 0000000..2def8af --- /dev/null +++ b/src/protofiles/__init__.py @@ -0,0 +1,7 @@ +from os.path import dirname, basename, isfile, join, abspath, dirname +from sys import path +from glob import glob +modules = glob(join(dirname(__file__), "*.py")) +__all__ = [basename(f)[:-3] for f in modules if isfile(f) + and not f.endswith('__init__.py')] +path.insert(0, abspath(dirname(__file__))) diff --git a/src/protogenerator.py b/src/protogenerator.py new file mode 100644 index 0000000..5f67757 --- /dev/null +++ b/src/protogenerator.py @@ -0,0 +1,166 @@ +from pkgutil import iter_modules +import protofiles +import importlib +from google.protobuf.message import Message +from random import getrandbits, randint, random, choice, randbytes +from string import ascii_lowercase +import pandas as pd +from os import stat +from abstractdatagenerator import DataGenerator + + +class ProtoDataGenerator(DataGenerator): + logger = None + + def __init__(self, config): + self.reinitialize(config.get("message"), config.get("file")) + + def reinitialize(self, message_name: str, message_file_path=''): + self.message_constructors = ProtoDataGenerator.get_message_constructors() + self.message_constructor = self.message_constructors[message_name] + self.constructed_messages = None + self.message_name = message_name + if message_file_path != '': + try: + self.file_time_stamp = stat(message_file_path).st_mtime + except FileNotFoundError: + ProtoDataGenerator.logger.error(f"ERROR: File {message_file_path} not found. Defaulting to sending random messages") + self.message_file_path = '' + return + + self.message_file_path = message_file_path + self.constructed_messages = self.read_message_csv( + message_name, message_file_path) + self.curr_message = 0 + + def get_message_constructors(): + message_constructors = {} + for submodule in iter_modules(protofiles.__path__): + submodule_name = submodule.name + submodule_fullname = f"protofiles.{submodule_name}" + imported_module = importlib.import_module(submodule_fullname) + for v in vars(imported_module).values(): + if not (isinstance(v, type) and issubclass(v, Message)): + continue + message_constructors[v.__name__] = v + return message_constructors + + def get_random_message(self): + message = self.message_constructor() + for field_descriptor in self.message_constructor.DESCRIPTOR.fields: + + message_type = field_descriptor.message_type + if field_descriptor.message_type is not None: + gen = ProtoDataGenerator(message_type.name) + m = gen.get_random_message() + attr = getattr(message, field_descriptor.name) + attr.CopyFrom(m) + continue + field_type = field_descriptor.type + # boolean + if field_type == 8: + setattr(message, field_descriptor.name, getrandbits(1)) + # string + elif field_type == 9: + setattr(message, field_descriptor.name, ''.join( + choice(ascii_lowercase) for i in range(10))) + # float or double + elif field_type == 2 or field_type == 1: + setattr(message, field_descriptor.name, random()) + # bytes + elif field_type == 12: + setattr(message, field_descriptor.name, randbytes(10)) + # int + else: + setattr(message, field_descriptor.name, + randint(0, 1000)) + return message + + def next_message(self): + ret = None + if self.constructed_messages is not None: + time_stamp = stat(self.message_file_path).st_mtime + if time_stamp != self.file_time_stamp: + ProtoDataGenerator.logger.info( + f"INFO: file {self.message_file_path} changed. Reconstructing messages") + self.constructed_messages = self.read_message_csv( + self.message_name, message_file=self.message_file_path) + self.file_time_stamp = time_stamp + self.curr_message = 0 + + ret = self.constructed_messages[self.curr_message] + self.curr_message = (self.curr_message + + 1) % len(self.constructed_messages) + else: + ret = self.get_random_message() + return ret.SerializeToString() + + def read_message_csv(self, message_name: str, message_file: str) -> list: + df = None + try: + df = pd.read_csv(message_file) + except FileNotFoundError: + ProtoDataGenerator.logger.error(f"ERROR: File {message_file} not found. Defaulting to sending random messages") + return + + messages = [] + for i in range(df.shape[0]): + fields = dict() + for field in df.columns: + fields[field] = df[field][i] + messages.append(self.construct_message(message_name, fields)) + return messages + + def construct_message(self, message_name: str, fields: dict) -> Message: + message_constructor = self.message_constructors[message_name] + new_message = message_constructor() + for field_descriptor in message_constructor.DESCRIPTOR.fields: + message_type = field_descriptor.message_type + if field_descriptor.message_type is not None: + passed_on_fields = dict() + for field in fields.keys(): + if field_descriptor.name not in field: + continue + sub_fields = field.split('.') + try: + new_field_name = sub_fields[sub_fields.index( + field_descriptor.name)+1:] + except ValueError: + if ' ' in field: + ProtoDataGenerator.logger.error(f"ERROR: Field {field} contains spaces!!! You should not do that!") + else: + ProtoDataGenerator.logger.error( + f"ERROR: Field with key \'{field}\' not found \'{field_descriptor.name}\'.") + return new_message + passed_on_fields['.'.join( + new_field_name)] = fields[field] + m = self.construct_message(message_type.name, passed_on_fields) + attr = getattr(new_message, field_descriptor.name) + attr.CopyFrom(m) + continue + field_type = field_descriptor.type + if field_descriptor.name not in fields.keys(): + ProtoDataGenerator.logger.info(f"INFO: data for field \'{field_descriptor.name}\' not found when constructing message \'{message_name}\'.") + continue + # bolean + if field_type == 8: + setattr(new_message, field_descriptor.name, + bool(fields[field_descriptor.name])) + # string + elif field_type == 9: + setattr(new_message, field_descriptor.name, + fields[field_descriptor.name]) + # float or double + elif field_type == 2 or field_type == 1: + setattr(new_message, field_descriptor.name, + float(fields[field_descriptor.name])) + # bytes + elif field_type == 12: + setattr(new_message, field_descriptor.name, + fields[field_descriptor.name]) + + # int + else: + setattr(new_message, field_descriptor.name, + int(fields[field_descriptor.name])) + return new_message diff --git a/src/requirements.txt b/src/requirements.txt index 2108776..bc00439 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,5 +1,12 @@ +numpy==1.26.4 paho-mqtt==2.1.0 +pandas==2.2.2 +protobuf==5.27.0 PySide6==6.7.0 PySide6_Addons==6.7.0 PySide6_Essentials==6.7.0 +python-dateutil==2.9.0.post0 +pytz==2024.1 shiboken6==6.7.0 +six==1.16.0 +tzdata==2024.1