Banner publicitario de PCBWay

Creando una aplicación para programar Arduino en lenguaje LADDER

torres.electronico

Well-known-Alfil
Bueno, acá de nuevo con un nuevo proyecto que de seguro, cuando este terminado a muchos de la vieja escuela que nunca aprendieron a programar microcontroladores pero si saben sobre automatización con lógica de contactos, o bien entienden el lenguaje de escalera (ladder), les va a servir mucho para hacer sus propios automatismos de bajo coste...
Estoy haciendo un curso de Python online enfocado a todo lo referido al diseño de GUI profesionales. Como es libre, ya prácticamente me comí todos los apuntes y algunos videos de referencia, mas otros tantos que busque para satisfacer mi sed. Estoy entrando en el punto de hacer un ejercicio, y se me ocurrio hacer una mini aplicacion que nos permita programar un Arduino Nano en lenguaje LADDER... Como ya tengo un hardware armado hace ya un tiempo, estoy orientando este software a una especie de microPLC que tiene las siguientes caracteristicas:

7 ENTRADAS DIGITALESD2 (Con opción de activar interrupción)-D4-D5-D6-D7-D8-D9
5 SALIDAS DIGITALESD3 (Con opción de activar la función PWM)-D14-D15-D16-D17
2 ENTRADAS ANALOGICASD20(A6)-D21(A7)
COMUNICACIONI2c/SPI/UART

El software si bien en esta primera etapa beta tiene un set pequeño de funciones, creo que son las mas básicas e indispensables para arrancar:

Contacto NA - Contacto NC - Bobina NC - Bobina NA - PWM - ADC - Comparadores "Igual que"/"Mayor que"/Menor que" - Bobina de salida - Bobina de memoria - Memoria 0 al 9 - Variable 0 al 9 - Timer Ton - Timer Toff - Funciones lógicas "AND"/"OR"/"NOT"

Cuando arranque con la idea de hacer este ejemplo a modo practica, sinceramente no tenia ni la mas pálida idea en que me estaba por meter :facepalm: ... una cosa es expectativa, y otra realidad :silbando: ... Pero bueno, asi como me meti en esto, estoy seguro que muchos de ustedes me van a dar una mano y me van a ir corrigiendo / enseñando como salir de algunas trabas que me tildan de a ratos por un largo tiempito... En definitiva, esto es algo para ustedes, para la comunidad en general...
Bien; les comento a modo resumen rápido el flujo de dependencias, asi nos metemos de lleno en el bolonqui que tengo en la cabeza para poder cocinar esto:

1_Python / PyQt5: Nos permite arrastrar bloques y vea la GUI en gris oscuro con líneas guía.
2_Bloques PNG: Se arrastran, se colocan en la escena, y Python sabe la posición X/Y.
3_Python generate_sketch(): Recorre los bloques, detecta filas/columnas, traduce a código Arduino.
4_ os y open(): Guardan el .ino en la carpeta build.
5_ arduino-cli: Si queremos automatizar la compilación y carga al Arduino, se integra acá.

Como no encontre un banco de imágenes PNG que tuviera las imágenes de las lógicas de contacto y funciones, tuve que dibujar las 38 funciones :rolleyes:

funcionesLADDER.png

las primeras GUI no daba pie con bola


uPLC_3.png

Hasta que al fin, luego de varias horas de lectura, interpretación a los golpes con pruebas y error, logre llegar a dar el primer paso... Tener la GUI funcional

uPLC_7.png

Cómo podemos detectar la lógica?
Para generar Ladder real, necesitamos un paso intermedio de análisis, o por lo menos, yo lo creo así:

A_Leer todas las posiciones X/Y de los bloques para determinar:
Bloques que están en la misma línea → se consideran “en serie” (AND implícito).
Bloques que están alineados verticalmente con otra entrada OR → se consideran “en paralelo” (OR implícito).

B_Clasificar cada bloque por tipo (Contacto, Bobina, Timer, Comparación, etc.).

C_Construir un árbol o grafo de conexiones:

Nodo = bloque.
Aristas = flujo de energía / señal.

D_Recorrer el árbol para generar código Arduino respetando las conexiones.

Cómo puedo traducir el diagrama de contactos al lenguaje de Arduino?


Para un OR simple como el ejemplo del video:

Código:
Entrada1 ----┐
             ├-- OR ----> Bobina
Entrada2 ----┘

tendría que generar algo así:

CSS:
int in1 = digitalRead(PIN1);
int in2 = digitalRead(PIN2);
int salida = in1 || in2; // OR lógico
digitalWrite(PIN_OUT, salida);

Si tuviera un AND, sería:

CSS:
int salida = in1 && in2; // AND lógico

Para poder iniciar con este pensamiento, necesito estructurarme algunas reglas basicas:
-Los bloques no tienen que estar solo en la misma fila; necesitamos mirar la posición vertical y horizontal para detectar paralelos (OR) y series (AND).
-La bobina siempre es la salida final de la línea o rama lógica.
-Para Timer, PWM, Comparaciones, etc., se usa la misma idea: reconocer entradas, aplicar función, escribir salida...


Con estas reglas básicas, para poder generar Ladder/Arduino, lo que haemos entonces en el programa es recorrer todos los bloques y agruparlos por fila (cada línea del Ladder). Detectadas las series y paralelos, pasamos a agruparlos por:

-Bloques alineados horizontalmente = serie (AND).
-Bloques alineados verticalmente = paralelo (OR).
-Asignar variables Arduino a entradas, salidas y memorias.


Esto permitirá que si pones por ejemplo 2 contactos normal abierto en paralelo y conectas a una bobina, el .ino contenga un verdadero digitalRead(PIN) || digitalRead(PIN) y luego el digitalWrite a la bobina. Suena tonto la idea que tengo en mente y que estoy tratando de explicar, asi que ténganme paciencia...

Python:
# main.py
# uPLC - ETI Patagonia - prof.martintorres@educ.ar

import sys, os
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QListWidget, QListWidgetItem,
    QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QAction
)
from PyQt5.QtCore import Qt, QMimeData, QPointF
from PyQt5.QtGui import QPixmap, QDrag, QPen, QColor

MAX_COLS = 8
BLOCK_WIDTH = 100
BLOCK_HEIGHT = 52
BLOCK_SPACING = 10

# ---------------- Clase GraphicsView ----------------
class GraphicsView(QGraphicsView):
    def __init__(self, scene):
        super().__init__(scene)
        self.setAcceptDrops(True)
        self.row_counter = 0
        self.col_counter = 0

    def dragEnterEvent(self, event):
        if event.mimeData().hasFormat("application/x-item"):
            event.acceptProposedAction()

    def dragMoveEvent(self, event):
        if event.mimeData().hasFormat("application/x-item"):
            event.acceptProposedAction()

    def dropEvent(self, event):
        mime = event.mimeData()
        data = str(mime.data("application/x-item"), "utf-8")
        file_name = data
        pixmap = QPixmap(f"icons/{file_name}")
        if pixmap.isNull():
            print(f"No se encontró {file_name}")
            return

        col_index = self.col_counter
        row_index = self.row_counter
        x = col_index * (BLOCK_WIDTH + BLOCK_SPACING)
        y = row_index * (BLOCK_HEIGHT + BLOCK_SPACING)

        pixmap_item = self.scene().addPixmap(pixmap)
        pixmap_item.setFlags(pixmap_item.ItemIsMovable | pixmap_item.ItemIsSelectable)
        pixmap_item.setPos(x, y)
        pixmap_item.block_file = file_name
        pixmap_item.row_index = row_index
        pixmap_item.col_index = col_index
        pixmap_item.mouseReleaseEvent = lambda e, item=pixmap_item: self.snap_item(item, e)

        # OR automático
        if file_name == "Fun_OR_A.png":
            pixmap_b = QPixmap("icons/Fun_OR_B.png")
            pixmap_item_b = self.scene().addPixmap(pixmap_b)
            pixmap_item_b.setFlags(pixmap_item_b.ItemIsMovable | pixmap_item_b.ItemIsSelectable)
            x_b = x
            y_b = y + BLOCK_HEIGHT // 2
            pixmap_item_b.setPos(x_b, y_b)
            pixmap_item_b.block_file = "Fun_OR_B.png"
            pixmap_item_b.row_index = row_index
            pixmap_item_b.col_index = col_index
            pixmap_item_b.mouseReleaseEvent = lambda e, item=pixmap_item_b: self.snap_item(item, e)

        self.col_counter += 1
        if self.col_counter >= MAX_COLS:
            self.col_counter = 0
            self.row_counter += 1

        event.acceptProposedAction()

    def snap_item(self, item, event):
        pos = item.pos()
        x = round(pos.x() / (BLOCK_WIDTH + BLOCK_SPACING)) * (BLOCK_WIDTH + BLOCK_SPACING)
        y = round(pos.y() / (BLOCK_HEIGHT + BLOCK_SPACING)) * (BLOCK_HEIGHT + BLOCK_SPACING)
        item.setPos(QPointF(x, y))
        item.row_index = y // (BLOCK_HEIGHT + BLOCK_SPACING)
        item.col_index = x // (BLOCK_WIDTH + BLOCK_SPACING)

# ---------------- Ventana principal ----------------
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("uPLC LADDER - ETI Patagonia")
        self.setGeometry(100, 100, 1000, 600)
        self.setStyleSheet("background-color: #2f2f2f;")

        # ---------------- Menú superior ----------------
        menubar = self.menuBar()
        menu_file = menubar.addMenu("Archivo")
        generate_action = QAction("Generar SKETCH", self)
        generate_action.triggered.connect(self.generate_sketch)
        menu_file.addAction(generate_action)

        # ---------------- Layout principal ----------------
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QHBoxLayout()
        central_widget.setLayout(main_layout)

        # ---------------- Panel izquierdo (1/6) ----------------
        self.function_list = QListWidget()
        self.function_list.setFixedWidth(160)
        self.function_list.setStyleSheet(
            "QListWidget {background-color: #3c3c3c; color: white; font-weight: bold;}"
            "QListWidget::item:selected {background-color: #5c5c5c;}"
        )
        self.populate_functions()
        self.function_list.setDragEnabled(True)
        self.function_list.startDrag = self.startDrag

        left_layout = QVBoxLayout()
        left_layout.addWidget(self.function_list)
        left_layout.addStretch()
        left_container = QWidget()
        left_container.setLayout(left_layout)

        # ---------------- Área de programación (5/6) ----------------
        self.scene = QGraphicsScene()
        self.scene.setBackgroundBrush(Qt.darkGray)
        self.view = GraphicsView(self.scene)
        self.view.setStyleSheet("border: 1px solid #555;")

        pen = QPen(QColor(200, 200, 200, 50))
        for i in range(0, 1000, BLOCK_HEIGHT):
            self.scene.addLine(0, i, 5000, i, pen)

        main_layout.addWidget(left_container)
        main_layout.addWidget(self.view)

    # ---------------- Funciones panel izquierdo ----------------
    def populate_functions(self):
        functions = [
            ("Contacto NO", "In_NA.png"),
            ("Contacto NC", "In_NC.png"),
            ("Lectura Analogica", "In_ADC.png"),
            ("Bobina NO", "Out_NA.png"),
            ("Bobina NC", "Out_NC.png"),
            ("Bobina Memoria", "Fun_GUARDARenMEMORIA.png"),
            ("PWM", "Fun_PWM.png"),
            ("Timer TON", "Fun_Ton.png"),
            ("Timer TOF", "Fun_Toff.png"),
            ("Comparación IGUAL QUE", "Fun_varIGUALvar.png"),
            ("Comparación MENOR QUE", "Fun_varMENORvar.png"),
            ("Comparación MAYOR QUE", "Fun_varMAYORvar.png"),
            ("Memoria0", "Fun_MEMORIA0.png"),
            ("Memoria1", "Fun_MEMORIA1.png"),
            ("Memoria2", "Fun_MEMORIA2.png"),
            ("Memoria3", "Fun_MEMORIA3.png"),
            ("Memoria4", "Fun_MEMORIA4.png"),
            ("Memoria5", "Fun_MEMORIA5.png"),
            ("Memoria6", "Fun_MEMORIA6.png"),
            ("Memoria7", "Fun_MEMORIA7.png"),
            ("Memoria8", "Fun_MEMORIA8.png"),
            ("Memoria9", "Fun_MEMORIA9.png"),
            ("Variable0", "Fun_VARIABLE0.png"),
            ("Variable1", "Fun_VARIABLE1.png"),
            ("Variable2", "Fun_VARIABLE2.png"),
            ("Variable3", "Fun_VARIABLE3.png"),
            ("Variable4", "Fun_VARIABLE4.png"),
            ("Variable5", "Fun_VARIABLE5.png"),
            ("Variable6", "Fun_VARIABLE6.png"),
            ("Variable7", "Fun_VARIABLE7.png"),
            ("Variable8", "Fun_VARIABLE8.png"),
            ("Variable9", "Fun_VARIABLE9.png"),
            ("AND", "Fun_AND.png"),
            ("NOT", "Fun_NOT.png"),
            ("OR", "Fun_OR_A.png"),
            ("Linea horizontal", "LINEA.png")
        ]
        for name, file in functions:
            item = QListWidgetItem(name)
            item.setData(Qt.UserRole, file)
            self.function_list.addItem(item)

    # ---------------- Drag correcto ----------------
    def startDrag(self, supportedActions):
        item = self.function_list.currentItem()
        if not item:
            return
        drag = QDrag(self.function_list)
        mime = QMimeData()
        mime.setData("application/x-item", bytes(item.data(Qt.UserRole), "utf-8"))
        drag.setMimeData(mime)
        drag.exec_(Qt.MoveAction)

    # ---------------- Generar Sketch real ----------------
    def generate_sketch(self):
        if not os.path.exists("build"):
            os.makedirs("build")
        file_path = os.path.join("build", "program.ino")
        # Ordenamos los bloques por fila y columna
        items = sorted(
            [i for i in self.scene.items() if hasattr(i, "block_file")],
            key=lambda b: (b.row_index, b.col_index)
        )
        with open(file_path, "w") as f:
            f.write("// ==== Programa Ladder generado automáticamente ====\n\n")
            f.write("void setup() {\n  // Configurar pines\n}\n\n")
            f.write("void loop() {\n")
            for item in items:
                code_line = self.block_to_code(item.block_file)
                f.write(f"  {code_line}\n")
            f.write("}\n")
        print(f"Sketch generado: {file_path}")

    # ---------------- Traducción bloque -> código Arduino ----------------
    def block_to_code(self, block_file):
        # Contactos
        if block_file == "In_NA.png":
            return "// Contacto NO (digitalRead entrada)"
        elif block_file == "In_NC.png":
            return "// Contacto NC (digitalRead entrada negada)"
        elif block_file == "In_ADC.png":
            return "// Lectura analogica (analogRead)"
        # Bobinas
        elif block_file == "Out_NA.png":
            return "// Bobina NO (digitalWrite salida)"
        elif block_file == "Out_NC.png":
            return "// Bobina NC (digitalWrite salida negada)"
        elif block_file == "Fun_GUARDARenMEMORIA.png":
            return "// Bobina memoria"
        elif block_file == "Fun_PWM.png":
            return "// PWM analogWrite"
        # Timers
        elif block_file == "Fun_Ton.png":
            return "// Timer TON"
        elif block_file == "Fun_Toff.png":
            return "// Timer TOF"
        # Comparaciones
        elif block_file == "Fun_varIGUALvar.png":
            return "// Comparacion IGUAL"
        elif block_file == "Fun_varMENORvar.png":
            return "// Comparacion MENOR"
        elif block_file == "Fun_varMAYORvar.png":
            return "// Comparacion MAYOR"
        # Memorias
        elif "Fun_MEMORIA" in block_file:
            return f"// Memoria {block_file[-5]}"
        # Variables
        elif "Fun_VARIABLE" in block_file:
            return f"// Variable {block_file[-5]}"
        # Logica
        elif block_file == "Fun_AND.png":
            return "// AND"
        elif block_file == "Fun_NOT.png":
            return "// NOT"
        elif block_file == "Fun_OR_A.png":
            return "// OR"
        elif block_file == "Fun_OR_B.png":
            return "// OR parte inferior"
        elif block_file == "LINEA.png":
            return "// Linea horizontal"
        return f"// Bloque desconocido: {block_file}"


# -------------------- Main --------------------
if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

En la versión que vemos arriba, la GUI simplemente recorre todos los bloques en orden de fila y columna, y genera un comentario por cada bloque:

CSS:
// ==== Programa Ladder generado automaticamente ====

void setup() {
  // Configurar pines
}

void loop() {
  // Contacto NO (digitalRead entrada)
  // Contacto NO (digitalRead entrada)
  // OR parte inferior
  // OR
  // Bobina NO (digitalWrite salida)
}

Si bien esto no tiene lógica de conexiones todavía, lo que estoy haciendo es tratar de hacer que la GUI entienda qué bloques de función están en qué fila y columna.
En las ultimas versiones que fui creando, pude ir sumando ya los bloques lógicos que tengo terminados:

Python:
# main.py
# uPLC - ETI Patagonia - prof.martintorres@educ.ar

import sys, os
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QListWidget, QListWidgetItem,
    QVBoxLayout, QHBoxLayout, QGraphicsView, QGraphicsScene, QAction
)
from PyQt5.QtCore import Qt, QMimeData, QPointF
from PyQt5.QtGui import QPixmap, QDrag, QPen, QColor

MAX_COLS = 8
BLOCK_WIDTH = 100
BLOCK_HEIGHT = 52
BLOCK_SPACING = 10

# ---------------- Clase GraphicsView ----------------
class GraphicsView(QGraphicsView):
    def __init__(self, scene):
        super().__init__(scene)
        self.setAcceptDrops(True)
        self.row_counter = 0
        self.col_counter = 0

    def dragEnterEvent(self, event):
        if event.mimeData().hasFormat("application/x-item"):
            event.acceptProposedAction()

    def dragMoveEvent(self, event):
        if event.mimeData().hasFormat("application/x-item"):
            event.acceptProposedAction()

    def dropEvent(self, event):
        mime = event.mimeData()
        file_name = str(mime.data("application/x-item"), "utf-8")
        pixmap = QPixmap(f"icons/{file_name}")
        if pixmap.isNull():
            print(f"No se encontró {file_name}")
            return

        col_index = self.col_counter
        row_index = self.row_counter
        x = col_index * (BLOCK_WIDTH + BLOCK_SPACING)
        y = row_index * (BLOCK_HEIGHT + BLOCK_SPACING)

        pixmap_item = self.scene().addPixmap(pixmap)
        pixmap_item.setFlags(pixmap_item.ItemIsMovable | pixmap_item.ItemIsSelectable)
        pixmap_item.setPos(x, y)
        pixmap_item.block_file = file_name
        pixmap_item.row_index = row_index
        pixmap_item.col_index = col_index
        pixmap_item.mouseReleaseEvent = lambda e, item=pixmap_item: self.snap_item(item, e)

        # OR automático
        if file_name == "Fun_OR_A.png":
            pixmap_b = QPixmap("icons/Fun_OR_B.png")
            pixmap_item_b = self.scene().addPixmap(pixmap_b)
            pixmap_item_b.setFlags(pixmap_item_b.ItemIsMovable | pixmap_item_b.ItemIsSelectable)
            x_b = x
            y_b = y + BLOCK_HEIGHT // 2
            pixmap_item_b.setPos(x_b, y_b)
            pixmap_item_b.block_file = "Fun_OR_B.png"
            pixmap_item_b.row_index = row_index
            pixmap_item_b.col_index = col_index
            pixmap_item_b.mouseReleaseEvent = lambda e, item=pixmap_item_b: self.snap_item(item, e)

        self.col_counter += 1
        if self.col_counter >= MAX_COLS:
            self.col_counter = 0
            self.row_counter += 1

        event.acceptProposedAction()

    def snap_item(self, item, event):
        pos = item.pos()
        x = round(pos.x() / (BLOCK_WIDTH + BLOCK_SPACING)) * (BLOCK_WIDTH + BLOCK_SPACING)
        y = round(pos.y() / (BLOCK_HEIGHT + BLOCK_SPACING)) * (BLOCK_HEIGHT + BLOCK_SPACING)
        item.setPos(QPointF(x, y))
        item.row_index = y // (BLOCK_HEIGHT + BLOCK_SPACING)
        item.col_index = x // (BLOCK_WIDTH + BLOCK_SPACING)

# ---------------- Ventana principal ----------------
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("uPLC LADDER - ETI Patagonia")
        self.setGeometry(100, 100, 1200, 700)
        self.setStyleSheet("background-color: #2f2f2f;")

        # Menú superior
        menubar = self.menuBar()
        menu_file = menubar.addMenu("Archivo")
        generate_action = QAction("Generar SKETCH", self)
        generate_action.triggered.connect(self.generate_sketch)
        menu_file.addAction(generate_action)

        # Layout principal
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QHBoxLayout()
        central_widget.setLayout(main_layout)

        # Panel izquierdo (1/6)
        self.function_list = QListWidget()
        self.function_list.setFixedWidth(160)
        self.function_list.setStyleSheet(
            "QListWidget {background-color: #3c3c3c; color: white; font-weight: bold;}"
            "QListWidget::item:selected {background-color: #5c5c5c;}"
        )
        self.populate_functions()
        self.function_list.setDragEnabled(True)
        self.function_list.startDrag = self.startDrag

        left_layout = QVBoxLayout()
        left_layout.addWidget(self.function_list)
        left_layout.addStretch()
        left_container = QWidget()
        left_container.setLayout(left_layout)

        # Área de programación (5/6)
        self.scene = QGraphicsScene()
        self.scene.setBackgroundBrush(Qt.darkGray)
        self.view = GraphicsView(self.scene)
        self.view.setStyleSheet("border: 1px solid #555;")

        # Líneas guía
        pen = QPen(QColor(200, 200, 200, 50))
        for i in range(0, 1000, BLOCK_HEIGHT):
            self.scene.addLine(0, i, 5000, i, pen)

        main_layout.addWidget(left_container)
        main_layout.addWidget(self.view)

    def populate_functions(self):
        functions = [
            ("Contacto NO", "In_NA.png"),
            ("Contacto NC", "In_NC.png"),
            ("Lectura Analogica", "In_ADC.png"),
            ("Bobina NO", "Out_NA.png"),
            ("Bobina NC", "Out_NC.png"),
            ("Bobina Memoria", "Fun_GUARDARenMEMORIA.png"),
            ("PWM", "Fun_PWM.png"),
            ("Timer TON", "Fun_Ton.png"),
            ("Timer TOF", "Fun_Toff.png"),
            ("Comparación IGUAL QUE", "Fun_varIGUALvar.png"),
            ("Comparación MENOR QUE", "Fun_varMENORvar.png"),
            ("Comparación MAYOR QUE", "Fun_varMAYORvar.png"),
            ("Memoria0", "Fun_MEMORIA0.png"),
            ("Memoria1", "Fun_MEMORIA1.png"),
            ("Memoria2", "Fun_MEMORIA2.png"),
            ("Memoria3", "Fun_MEMORIA3.png"),
            ("Memoria4", "Fun_MEMORIA4.png"),
            ("Memoria5", "Fun_MEMORIA5.png"),
            ("Memoria6", "Fun_MEMORIA6.png"),
            ("Memoria7", "Fun_MEMORIA7.png"),
            ("Memoria8", "Fun_MEMORIA8.png"),
            ("Memoria9", "Fun_MEMORIA9.png"),
            ("Variable0", "Fun_VARIABLE0.png"),
            ("Variable1", "Fun_VARIABLE1.png"),
            ("Variable2", "Fun_VARIABLE2.png"),
            ("Variable3", "Fun_VARIABLE3.png"),
            ("Variable4", "Fun_VARIABLE4.png"),
            ("Variable5", "Fun_VARIABLE5.png"),
            ("Variable6", "Fun_VARIABLE6.png"),
            ("Variable7", "Fun_VARIABLE7.png"),
            ("Variable8", "Fun_VARIABLE8.png"),
            ("Variable9", "Fun_VARIABLE9.png"),
            ("AND", "Fun_AND.png"),
            ("NOT", "Fun_NOT.png"),
            ("OR", "Fun_OR_A.png"),
            ("LINEA", "LINEA.png")
        ]
        for name, file in functions:
            item = QListWidgetItem(name)
            item.setData(Qt.UserRole, file)
            self.function_list.addItem(item)

    def startDrag(self, supportedActions):
        item = self.function_list.currentItem()
        if not item:
            return
        drag = QDrag(self.function_list)
        mime = QMimeData()
        mime.setData("application/x-item", bytes(item.data(Qt.UserRole), "utf-8"))
        drag.setMimeData(mime)
        drag.exec_(Qt.MoveAction)

    # ---------------- Generar sketch con AND+OR final ----------------
    def generate_sketch(self):
        if not os.path.exists("build"):
            os.makedirs("build")
        file_path = os.path.join("build", "program.ino")

        items = sorted(
            [i for i in self.scene.items() if hasattr(i, "block_file")],
            key=lambda b: (b.row_index, b.col_index)
        )

        # Organizar por fila
        rows = {}
        for item in items:
            rows.setdefault(item.row_index, []).append(item)

        # Generar .ino
        with open(file_path, "w") as f:
            f.write("// ==== Programa Ladder generado automáticamente ====\n")
            f.write("#include <Arduino.h>\n\n")
            f.write("void setup() {\n  // Configurar pines\n}\n\n")
            f.write("void loop() {\n")

            temp_counter = 0
            row_vars = {}

            # Primer paso: generar AND por fila
            for row in sorted(rows.keys()):
                line_blocks = rows[row]
                expr, var_name, temp_counter = self.process_line_and_or(line_blocks, rows, row, temp_counter)
                row_vars[row] = var_name
                f.write(f"  int {var_name} = {expr};\n")

            # Salida final
            for row in sorted(rows.keys(), reverse=True):
                for block in rows[row]:
                    if block.block_file in ["Out_NA.png", "Out_NC.png", "Fun_GUARDARenMEMORIA.png", "Fun_PWM.png"]:
                        f.write(f"  digitalWrite(PIN_OUT, {row_vars[row]});\n")
                        break
                else:
                    continue
                break

            f.write("}\n")

        print(f"Sketch funcional generado: {file_path}")

    # ---------------- Construye expresión AND + OR ----------------
    def process_line_and_or(self, blocks, rows, row_index, temp_counter):
        and_inputs = []
        or_inputs = []

        # AND horizontal
        for block in blocks:
            bf = block.block_file
            if bf in ["In_NA.png", "In_NC.png", "In_ADC.png"]:
                and_inputs.append(self.input_to_code(bf))

        and_expr = " && ".join(and_inputs) if and_inputs else "0"
        var_name = f"val_temp{temp_counter}"
        temp_counter += 1

        # OR vertical (buscar Fun_OR_A y fila siguiente)
        for block in blocks:
            if block.block_file == "Fun_OR_A.png":
                next_row = row_index + 1
                if next_row in rows:
                    or_inputs.append(var_name)
                    for b2 in rows[next_row]:
                        if b2.block_file in ["In_NA.png", "In_NC.png", "In_ADC.png"]:
                            or_inputs.append(self.input_to_code(b2.block_file))
                    or_expr = " || ".join(or_inputs)
                    var_name2 = f"val_temp{temp_counter}"
                    temp_counter += 1
                    return or_expr, var_name2, temp_counter

        return and_expr, var_name, temp_counter

    def input_to_code(self, block_file):
        if block_file == "In_NA.png":
            return "digitalRead(PIN1)"
        elif block_file == "In_NC.png":
            return "!digitalRead(PIN2)"
        elif block_file == "In_ADC.png":
            return "analogRead(ADC0)"
        return "0"

# ---------------- Main ----------------
if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

y llegue a esto:


Algunas dependencias son problemáticas si no respetamos cierto orden de ejecución... Tengo que pasar en limpio algunas cosas y voy hacer mas hincapié en algunos detalles a tener en cuenta si quieren hacer sus propis aplicaciones o editar esta misma.

Dependencias para la GUI / Drag & Drop
- PyQt5 (La librería principal para la interfaz gráfica. Maneja ventanas, layouts, listas, botones, gráficos, drag & drop, escenas y objetos gráficos. Todo lo que ves en la pantalla está hecho con PyQt5)
- os (Funciones de sistema operativo, usadas para crear carpetas (build), verificar rutas y guardar archivos .ino)
- sys (Necesario para ejecutar la aplicación (sys.argv) y salir correctamente)
-shutil (Para copiar archivos, si en algún momento queremos copiar los .ino o librerías a otra carpeta)

Dependencias para arrastrar, soltar y manejar imágenes... Mi gran dolor de cabeza!!!
- QPixmap / QDrag / QMimeData ( Representan los bloques de funciones como imágenes, permiten arrastrarlas desde el panel izquierdo y soltarlas en el área de programación)
- QGraphicsScene / QGraphicsView (Área donde se colocan las funciones. Permite dibujar líneas guía, colocar bloques, moverlos y luego obtener posiciones X/Y para generar el .ino)
- QPen / QColor (Para dibujar las líneas guía claras que delimitan cada fila en la zona de programación)

Dependencias para generación de archivos .ino
- os.path / os.makedirs / open() (Crear la carpeta build, abrir y escribir archivos .ino)
- sorted / lambda (Ordenar los bloques por fila y columna para que el código Arduino siga el orden lógico)

Herramienta externa
- arduino-cli_1.4.0_Windows_64bit (Opcional pero recomendable por que permite compilar y cargar los .ino desde Python o línea de comando)


Ultimas pruebas (6/1/2025)
En la ultimas pruebas, logre que funcione una función OR con dos entradas y una salida:

CSS:
// ==== Programa Ladder generado autom ticamente ====
#include <Arduino.h>
void setup()
{ // Configurar pines }
void loop()
{ int val_temp0 = digitalRead(PIN1) || digitalRead(PIN1);
digitalWrite(PIN_OUT, val_temp0); }

Una función AND con dos entradas y una salida:

CSS:
// ==== Programa Ladder generado automáticamente ====
#include <Arduino.h>
void setup() { // Configurar pines }
void loop()
 { int val_temp0 = digitalRead(PIN1) && digitalRead(PIN1);
digitalWrite(PIN_OUT, val_temp0); }

Pero fallo implementando una AND y una OR para activar una salida:

CSS:
// ==== Programa Ladder generado automáticamente ====
#include <Arduino.h>
void setup() { // Configurar pines }
void loop()
{ int val_temp0 = digitalRead(PIN1) || digitalRead(PIN1) || digitalRead(PIN1);
digitalWrite(PIN_OUT, val_temp0); }

O sea, el diagrama de contactos seria algo así:

CSS:
Fila 0: Contacto NO1 -- AND -- Contacto NO2 \
                                           OR --> Bobina
Fila 1: Contacto NO3 ----------------------/

La sintaxis correcta y/o la respuesta esperada tendría que ser:

CSS:
int val_temp0 = digitalRead(PIN1) && digitalRead(PIN2);
int val_temp1 = digitalRead(PIN3);
int val_or = val_temp0 || val_temp1;
digitalWrite(PIN_OUT, val_or);

Como veran, tengo para rato, pero voy a ir editando y subiendo los avances, como así también me gustaria que sea un proyecto colaborativo y el que quiera sumar/mejorar, bienvenido sea su aporte... Mas allá de que esto queda acá, tambien me sirve mucho para aprender, leer sus aportes / sugerencias :excelente::apreton:


PD: Proxima sumo links de referencia
Les comparto todos los archivos. no se olviden de instalar todas las dependencias, incluido arduino-cli_1.4.0
 

Adjuntos

  • plc_ladder.rar
    67.9 KB · Visitas: 0
Última edición:
Tal vez YO lo haría de forma diferente, generando el netlist a medida que dibujo el esquema ladder.
Repito, YO lo pensaría así:
-Cada componente del esquema ladder mapea a un componente de software en código Arduino (funciones o POO).
-En el dibujo del esquema ladder yo separaría las conexiones de los componentes. De esa forma puedo ubicar los componentes en la pantalla a mi gusto y luego interconectarlos a mi antojo, pudiendo incluso cambiar componentes pero "manteniendo" las conexiones.
-Cada vez que establezco una conexión entre dos componentes, se crea una "linea" del netlist.
-Con el netlist completo ya se puede escribir un módulo de ejecución que lo recorra y procese. O se puede utilizar ese mismo netlist para generar el código Arduino.
 
Tal vez YO lo haría de forma diferente, generando el netlist a medida que dibujo el esquema ladder.
Repito, YO lo pensaría así:
-Cada componente del esquema ladder mapea a un componente de software en código Arduino (funciones o POO).
-En el dibujo del esquema ladder yo separaría las conexiones de los componentes. De esa forma puedo ubicar los componentes en la pantalla a mi gusto y luego interconectarlos a mi antojo, pudiendo incluso cambiar componentes pero "manteniendo" las conexiones.
-Cada vez que establezco una conexión entre dos componentes, se crea una "linea" del netlist.
-Con el netlist completo ya se puede escribir un módulo de ejecución que lo recorra y procese. O se puede utilizar ese mismo netlist para generar el código Arduino.
Excelente! Creo que más o menos entendiendi; Déjame pensar como trabajar el mapa para generar la lógica y traducirlo...Anoche no dormi 🫣
Espero no colgar de nuevo 🤯
 
Déjame pensar como trabajar el mapa para generar la lógica y traducirlo
YO usaría POO por que sería mucho mas simple, pero depende como andés vos con eso. Igual se pueden usar funciones y estructuras, pero se enrosca un poco mas...
Hace muchos años hice un sistema que trabajaba con Redes de Petri y era bastante parecido a lo que vos querés hacer, solo que no mapeaba nada a Arduino por que aún no existía...
 
YO usaría POO por que sería mucho mas simple, pero depende como andés vos con eso. Igual se pueden usar funciones y estructuras, pero se enrosca un poco mas...
Hace muchos años hice un sistema que trabajaba con Redes de Petri y era bastante parecido a lo que vos querés hacer, solo que no mapeaba nada a Arduino por que aún no existía...
bien, anoche me quede hasta las 2 y moneda de la mañana y estoy siguiendo al pie de la letra tus recomendaciones... perdi tiempo en tratar de modificar lo que ya tenia, asi que arranque practicamente desde cero. Hoy a la mañana ya logre unos avances y me trabe cuando me di cuenta que logre que netlist y poo me armen todo, pero lo que se me escapo, fue trabajar los bloques para asignarles valores (I1,2,3,etc, valores a Variables y memorias, Q1,2,etc), asi que ahora estoy viendo como modificar los bloques :facepalm:
la gui quedo relativamente funcional.

1erCAPTURA_v2.png

Sume un par de reglas para no tener problemas despues con los valores asignados a las variables/memorias, etc:

Funciones de memoria:
Las funciones de memoria (M0 a M9), son solo de estados booleanos para poder usar esa señal en cualquier parte del programa que necesitemos de ese estado.

Funciones de variables:
Las funciones de variables quizás sean un poco mas complejas, ya que por ejemplo el ADC del Arduino NANO, tiene una resolución de 10 bits (0-1023 o tambien se puede configurar para que nos de una resolucion de 12bits) y el PWM trabaja con una resolución de 8bits (0-255), por eso es esencial la FUNCION MAP y no la quiero sacar del cuadro de funciones, ya que para poder amoldar esas lecturas al formato de 8 BITS, o para otras implementaciones que el usuario quiera darle (gauges en display I2c, etc etc)... En fin, para no tener conflictos con las variables, mapeamos directamente la lectura analogica y transformamos de 10 a 8 bits (0-255) internamente. Por ende, los espacios de variables 0 a 9, todas pueden trabajar en formato 8 bits (0-255)...

Ya resolvi el pegado y borrado de bloque, pero me volvi loco con la seleccion de bloque y colocacion + centrado... estoy lejos, pero me gusta tu empujon. Es una lastima que no arranque este tema preguntando primero :silbando:
:no: Termino de corregir HORRORES y arranco con las dudas/consultas 😓
 
Última edición:
Yo soy muy estricto con el modelado para POO, por que de lo contrario, ante un cambio, hay que tirar a la basura la mitad del código que hayas producido.
Yo siempre uso UML y patrones de diseño para modelar, ya que es lo único coherente entre multiples plataformas.
Fijate de ir armando, al menos, el diagrama de clases para la parte de POO...y luego tendrás que armar algunos diagramas de interacción para reflejar las operaciones entre clases.
En tu caso, ya que hay una GUI importante, tambien tendrías que generar los diagramas de casos de uso. De esa forma vas a tener siempre a la vista la interacción GUI-usuario.
 
Atrás
Arriba