diff --git a/Data/Experiments/Transparent Nodes/QML/blueprint_grid.qml b/Data/Experiments/Transparent Nodes/QML/blueprint_grid.qml
new file mode 100644
index 0000000..f21574d
--- /dev/null
+++ b/Data/Experiments/Transparent Nodes/QML/blueprint_grid.qml
@@ -0,0 +1,78 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Shapes 1.15
+import QtQuick.Window 2.15
+
+Item {
+ id: root
+ width: Screen.width
+ height: Screen.height
+
+ // Grid overlay is enabled at startup.
+ property bool editMode: true
+
+ // Blue gradient background (edges fading inward) with stops shifted inward.
+ Rectangle {
+ id: gradientBackground
+ width: parent.width
+ height: parent.height
+ opacity: 0.5
+ gradient: Gradient {
+ // Shifted stops: outer stops moved to 0.1 and 0.9, inner stops to 0.4 and 0.6.
+ GradientStop { position: 0.1; color: Qt.rgba(0, 100/255, 255/255, 0.5) }
+ GradientStop { position: 0.4; color: Qt.rgba(0, 50/255, 180/255, 0.2) }
+ GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.0) }
+ GradientStop { position: 0.6; color: Qt.rgba(0, 50/255, 180/255, 0.2) }
+ GradientStop { position: 0.9; color: Qt.rgba(0, 100/255, 255/255, 0.5) }
+ }
+ visible: editMode // Only show the gradient in edit mode
+ }
+
+ // Top & Bottom fade remains unchanged.
+ Rectangle {
+ id: topBottomGradient
+ width: parent.width
+ height: parent.height
+ opacity: 0.3
+ gradient: Gradient {
+ orientation: Gradient.Vertical
+ GradientStop { position: 0.0; color: Qt.rgba(0, 100/255, 255/255, 0.4) }
+ GradientStop { position: 0.3; color: Qt.rgba(0, 50/255, 180/255, 0.1) }
+ GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.0) }
+ GradientStop { position: 0.7; color: Qt.rgba(0, 50/255, 180/255, 0.1) }
+ GradientStop { position: 1.0; color: Qt.rgba(0, 100/255, 255/255, 0.4) }
+ }
+ visible: editMode
+ }
+
+ // Full-Screen Dynamic Grid with 10% increased transparency (grid lines at 0.3 opacity).
+ Canvas {
+ id: gridCanvas
+ width: parent.width
+ height: parent.height
+ onPaint: {
+ var ctx = getContext("2d");
+ ctx.clearRect(0, 0, width, height);
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.3)"; // Reduced opacity from 0.4 to 0.3.
+ ctx.lineWidth = 1;
+
+ var step = 120; // Grid spacing remains unchanged.
+
+ for (var x = 0; x < width; x += step) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+ for (var y = 0; y < height; y += step) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+ }
+ Component.onCompleted: requestPaint()
+ onVisibleChanged: requestPaint()
+ visible: editMode // Hide when edit mode is off.
+ }
+}
diff --git a/Data/Experiments/Transparent Nodes/blueprint_grid.py b/Data/Experiments/Transparent Nodes/blueprint_grid.py
new file mode 100644
index 0000000..4c029ba
--- /dev/null
+++ b/Data/Experiments/Transparent Nodes/blueprint_grid.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+import pkgutil
+import importlib
+import inspect
+import types
+from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QWidget
+from PyQt5.QtCore import Qt, QUrl, QTimer
+from PyQt5.QtGui import QGuiApplication
+from PyQt5.QtQuick import QQuickView
+
+# OdenGraphQt Fix: Monkey-patch QUndoStack
+import OdenGraphQt.base.graph as base_graph
+from PyQt5 import QtWidgets
+base_graph.QtGui.QUndoStack = QtWidgets.QUndoStack
+
+import OdenGraphQt.base.commands as base_commands
+_original_redo = base_commands.NodesRemovedCmd.redo
+_original_undo = base_commands.NodesRemovedCmd.undo
+
+def _patched_redo(self):
+ try:
+ _original_redo(self)
+ except TypeError as e:
+ if "unexpected type" in str(e) and hasattr(self, 'node'):
+ node_ids = []
+ if isinstance(self.node, list):
+ node_ids = [getattr(n, 'id', str(n)) for n in self.node]
+ else:
+ node_ids = [getattr(self.node, 'id', str(self.node))]
+ self.graph.nodes_deleted.emit(node_ids)
+ else:
+ raise
+
+def _patched_undo(self):
+ try:
+ _original_undo(self)
+ except TypeError as e:
+ if "unexpected type" in str(e) and hasattr(self, 'node'):
+ node_ids = []
+ if isinstance(self.node, list):
+ node_ids = [getattr(n, 'id', str(n)) for n in self.node]
+ else:
+ node_ids = [getattr(self.node, 'id', str(self.node))]
+ self.graph.nodes_deleted.emit(node_ids)
+ else:
+ raise
+
+base_commands.NodesRemovedCmd.redo = _patched_redo
+base_commands.NodesRemovedCmd.undo = _patched_undo
+
+# OdenGraphQt Transparent Viewer
+from OdenGraphQt.widgets.viewer import NodeViewer
+
+class TransparentViewer(NodeViewer):
+ """A NodeViewer that does not paint anything in drawBackground() -> Fully transparent."""
+ def drawBackground(self, painter, rect):
+ pass # Do nothing, ensuring transparency.
+
+# NodeGraph & Node Import Helpers
+from OdenGraphQt import NodeGraph, BaseNode
+
+def import_nodes_from_folder(package_name):
+ imported_nodes = []
+ package = importlib.import_module(package_name)
+ for loader, module_name, is_pkg in pkgutil.walk_packages(
+ package.__path__, package.__name__ + "."):
+ module = importlib.import_module(module_name)
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, BaseNode) and obj.__module__ == module.__name__:
+ imported_nodes.append(obj)
+ return imported_nodes
+
+def make_node_command(graph, node_type):
+ def command():
+ try:
+ graph.create_node(node_type)
+ except Exception as e:
+ print(f"Error creating node of type {node_type}: {e}")
+ return command
+
+# Edit Mode Button
+class EditButton(QPushButton):
+ """A small, frameless button to toggle edit mode."""
+ def __init__(self, parent=None):
+ super().__init__("Toggle Edit Mode", parent)
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
+ # Dark gray background with white text.
+ self.setStyleSheet("background-color: #444444; border: 1px solid black; color: white;")
+ self.resize(140, 40)
+
+# Main Overlay Window
+class MainWindow(QMainWindow):
+ """A frameless, transparent overlay with OdenGraphQt nodes & edit mode toggle."""
+ def __init__(self):
+ super().__init__()
+
+ # Full-screen overlay
+ app = QApplication.instance()
+ screen_geo = app.primaryScreen().geometry()
+ self.setGeometry(screen_geo)
+
+ # Frameless, top-most, fully transparent
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
+
+ # QML Background
+ self.qml_view = QQuickView()
+ self.qml_view.setSource(QUrl("qml/background_grid.qml"))
+ self.qml_view.setFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
+ self.qml_view.setClearBeforeRendering(True)
+ self.qml_view.setColor(Qt.transparent)
+ self.qml_view.show()
+
+ # Save the QML root object for later property sync
+ self.qml_root = self.qml_view.rootObject()
+
+ # NodeGraph with TransparentViewer
+ self.graph = NodeGraph(viewer=TransparentViewer())
+ self.nodeGraphWidget = self.graph.widget
+ self.nodeGraphWidget.setStyleSheet("background: transparent; border: none;")
+
+ # Transparent central widget
+ central = QWidget(self)
+ central.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.setCentralWidget(central)
+
+ self.nodeGraphWidget.setParent(central)
+ self.nodeGraphWidget.setGeometry(central.rect())
+
+ # Edit Mode Button (Python controlled)
+ self.editButton = EditButton(self)
+ self.editButton.move(10, 10)
+ self.editButton.clicked.connect(self.toggleEditMode)
+ self.isEditMode = True # Set edit mode enabled by default
+
+ # Ensure QML grid overlay is enabled at startup
+ if self.qml_root:
+ self.qml_root.setProperty("editMode", self.isEditMode)
+
+ # Import custom nodes
+ try:
+ custom_nodes = import_nodes_from_folder('Nodes')
+ for node_class in custom_nodes:
+ self.graph.register_node(node_class)
+
+ graph_menu = self.graph.get_context_menu('graph')
+ for node_class in custom_nodes:
+ node_type = f"{node_class.__identifier__}.{node_class.__name__}"
+ node_name = node_class.NODE_NAME
+ graph_menu.add_command(
+ f"Add {node_name}",
+ make_node_command(self.graph, node_type)
+ )
+ except Exception as e:
+ print(f"Error setting up custom nodes: {e}")
+
+ # Global update timer
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.global_update)
+ self.timer.start(500)
+
+ # Timer to ensure the button stays on top (hacky, but effective)
+ self.raiseTimer = QTimer(self)
+ self.raiseTimer.timeout.connect(self.editButton.raise_)
+ self.raiseTimer.start(1000) # Raise the button every 1 second
+
+ self.show()
+ self.nodeGraphWidget.setAttribute(Qt.WA_TransparentForMouseEvents, not self.isEditMode)
+
+ def toggleEditMode(self):
+ """Toggle edit mode (pass-through clicks vs interactive)."""
+ self.isEditMode = not self.isEditMode
+ self.nodeGraphWidget.setAttribute(Qt.WA_TransparentForMouseEvents, not self.isEditMode)
+ # Button text remains constant.
+ self.editButton.setText("Toggle Edit Mode")
+ if self.qml_root:
+ self.qml_root.setProperty("editMode", self.isEditMode)
+
+ def global_update(self):
+ """Update all nodes periodically."""
+ for node in self.graph.all_nodes():
+ if hasattr(node, "process_input"):
+ node.process_input()
+
+# Entry Point
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ window = MainWindow()
+ window.show()
+ sys.exit(app.exec_())
\ No newline at end of file
diff --git a/Data/Experiments/Transparent Nodes/borealis_transparent.py b/Data/Experiments/Transparent Nodes/borealis_transparent.py
new file mode 100644
index 0000000..0560967
--- /dev/null
+++ b/Data/Experiments/Transparent Nodes/borealis_transparent.py
@@ -0,0 +1,160 @@
+import sys
+import pkgutil
+import importlib
+import inspect
+from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene, QGraphicsItem, QMenu
+from PyQt5.QtCore import Qt, QTimer, QRectF, QPointF
+from PyQt5.QtGui import QColor, QPainter, QPen, QBrush, QGradient, QLinearGradient
+from PyQt5 import QtWidgets, QtCore, QtGui
+from OdenGraphQt import NodeGraph, BaseNode
+
+# --- Fix Missing QUndoStack in QtGui ---
+import OdenGraphQt.base.graph as base_graph
+base_graph.QtGui.QUndoStack = QtWidgets.QUndoStack # Monkey-patch the missing QUndoStack
+
+# --- Custom Graph Scene ---
+class CustomGraphScene(QGraphicsScene):
+ """
+ Custom scene that draws a blueprint-style transparent grid with gradient shading.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setBackgroundBrush(QtCore.Qt.transparent)
+ self.grid_color = QtGui.QColor(100, 160, 160, 160) # Blueprint grid color (10% more transparent)
+ self.grid_size = 115
+
+ def drawBackground(self, painter, rect):
+ """
+ Custom draw function to render a blueprint-style grid with gradient shading.
+ """
+ painter.save()
+ painter.setRenderHint(QPainter.Antialiasing, False)
+ painter.setBrush(QtCore.Qt.NoBrush) # No background fill
+ pen = QPen(self.grid_color, 0.5)
+
+ left = int(rect.left()) - (int(rect.left()) % self.grid_size)
+ top = int(rect.top()) - (int(rect.top()) % self.grid_size)
+
+ # Draw vertical lines
+ lines = []
+ for x in range(left, int(rect.right()), self.grid_size):
+ lines.append(QtCore.QLineF(x, rect.top(), x, rect.bottom()))
+
+ # Draw horizontal lines
+ for y in range(top, int(rect.bottom()), self.grid_size):
+ lines.append(QtCore.QLineF(rect.left(), y, rect.right(), y))
+
+ painter.setPen(pen)
+ painter.drawLines(lines)
+
+ # Draw gradient shading (top and bottom)
+ gradient = QLinearGradient(QPointF(rect.left(), rect.top()), QPointF(rect.left(), rect.bottom()))
+ gradient.setColorAt(0.0, QColor(0, 40, 100, 220)) # Darker blue at the top
+ gradient.setColorAt(0.5, QColor(0, 0, 0, 0)) # Transparent in the middle
+ gradient.setColorAt(1.0, QColor(0, 40, 100, 220)) # Darker blue at the bottom
+ painter.fillRect(rect, QBrush(gradient))
+
+ painter.restore()
+
+# --- Node Management ---
+def import_nodes_from_folder(package_name):
+ imported_nodes = []
+ package = importlib.import_module(package_name)
+ for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
+ module = importlib.import_module(module_name)
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, BaseNode) and obj.__module__ == module.__name__:
+ imported_nodes.append(obj)
+ return imported_nodes
+
+# --- Custom Graph View ---
+class CustomGraphView(QGraphicsView):
+ """
+ Custom view for the graph that applies full transparency and handles right-click context menu.
+ """
+ def __init__(self, scene, graph, parent=None):
+ super().__init__(scene, parent)
+ self.graph = graph # Reference to NodeGraph
+ self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
+ self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.setStyleSheet("background: transparent; border: none;")
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+
+ # Enable context menu on right-click
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.show_context_menu)
+
+ def show_context_menu(self, position):
+ """
+ Displays the node creation context menu with dynamically loaded nodes.
+ """
+ menu = QMenu()
+ for node_class in self.graph.registered_nodes():
+ node_name = getattr(node_class, "NODE_NAME", node_class.__name__)
+ menu.addAction(f"Create {node_name}", lambda nc=node_class: self.create_node(nc))
+ menu.exec_(self.mapToGlobal(position))
+
+ def create_node(self, node_class):
+ """
+ Creates a node instance of the given class in the NodeGraph.
+ """
+ try:
+ node = self.graph.create_node(f"{node_class.__identifier__}.{node_class.__name__}")
+ print(f"Created node: {node_class.__name__}")
+ except Exception as e:
+ print(f"Error creating node: {e}")
+
+# --- Main Window ---
+class MainWindow(QMainWindow):
+ """A frameless, transparent overlay with a custom graph."""
+ def __init__(self):
+ super().__init__()
+
+ # Full-screen overlay
+ app = QApplication.instance()
+ screen_geo = app.primaryScreen().geometry()
+ self.setGeometry(screen_geo)
+
+ # Frameless, top-most, fully transparent
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
+
+ # Transparent central widget
+ central = QWidget(self)
+ central.setAttribute(Qt.WA_TranslucentBackground, True)
+ layout = QVBoxLayout(central)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setCentralWidget(central)
+
+ # Initialize NodeGraph
+ self.graph = NodeGraph()
+
+ # Load custom nodes
+ custom_nodes = import_nodes_from_folder('Nodes')
+ for node_class in custom_nodes:
+ self.graph.register_node(node_class)
+
+ # Initialize Custom Graph Scene & View
+ self.scene = CustomGraphScene()
+ self.view = CustomGraphView(self.scene, self.graph, self)
+ layout.addWidget(self.view)
+
+ # Global update timer
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.global_update)
+ self.timer.start(500)
+
+ def global_update(self):
+ """Update all nodes periodically."""
+ for node in self.graph.all_nodes():
+ if hasattr(node, "process_input"):
+ node.process_input()
+
+# --- Entry Point ---
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ window = MainWindow()
+ window.show()
+ sys.exit(app.exec_())
diff --git a/Data/Experiments/borealis_overlay.py b/Data/Experiments/borealis_overlay.py
new file mode 100644
index 0000000..74b9d8f
--- /dev/null
+++ b/Data/Experiments/borealis_overlay.py
@@ -0,0 +1,542 @@
+#!/usr/bin/env python3
+
+import sys
+import time
+import re
+import numpy as np
+import cv2
+import pytesseract
+
+try:
+ import winsound
+ HAS_WINSOUND = True
+except ImportError:
+ HAS_WINSOUND = False
+
+from PyQt5.QtWidgets import QApplication, QWidget
+from PyQt5.QtCore import Qt, QRect, QPoint, QTimer
+from PyQt5.QtGui import QPainter, QPen, QColor, QFont
+from PIL import Image, ImageGrab, ImageFilter
+
+from rich.console import Console, Group
+from rich.table import Table
+from rich.progress import Progress, BarColumn, TextColumn
+from rich.text import Text
+from rich.live import Live
+
+# =============================================================================
+# Global Config
+# =============================================================================
+
+pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
+
+POLLING_RATE_MS = 500
+MAX_DATA_POINTS = 8
+
+# We still use these defaults for Region size.
+DEFAULT_WIDTH = 180
+DEFAULT_HEIGHT = 130
+HANDLE_SIZE = 8
+LABEL_HEIGHT = 20
+
+GREEN_HEADER_STYLE = "bold green"
+
+BEEP_INTERVAL_SECONDS = 1.0 # Only beep once every 1 second
+
+# STATUS BAR AUTO-LOCATOR LOGIC (WILL BE BUILT-OUT TO BE MORE ROBUST LATER)
+TEMPLATE_PATH = "G:\\Nextcloud\\Projects\\Scripting\\bars_template.png" # Path to your bars template file
+MATCH_THRESHOLD = 0.4 # The correlation threshold to consider a "good" match
+
+# =============================================================================
+# Helper Functions
+# =============================================================================
+
+def beep_hp_warning():
+ """
+ Only beep if enough time has elapsed since the last beep (BEEP_INTERVAL_SECONDS).
+ """
+ current_time = time.time()
+ if (beep_hp_warning.last_beep_time is None or
+ (current_time - beep_hp_warning.last_beep_time >= BEEP_INTERVAL_SECONDS)):
+
+ beep_hp_warning.last_beep_time = current_time
+ if HAS_WINSOUND:
+ # frequency=376 Hz, duration=100 ms
+ winsound.Beep(376, 100)
+ else:
+ # Attempt terminal bell
+ print('\a', end='')
+
+beep_hp_warning.last_beep_time = None
+
+
+def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD):
+ """
+ Attempt to locate the bars via OpenCV template matching:
+ 1) Grab the full screen using PIL.ImageGrab.
+ 2) Convert to NumPy array in BGR format for cv2.
+ 3) Load template from `template_path`.
+ 4) Use cv2.matchTemplate to find the best match location.
+ 5) If max correlation > threshold, return (x, y, w, h).
+ 6) Else return None.
+ """
+ # 1) Capture full screen
+ screenshot_pil = ImageGrab.grab()
+ screenshot_np = np.array(screenshot_pil) # shape (H, W, 4) possibly
+ # Convert RGBA or RGB to BGR
+ screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR)
+
+ # 2) Load template from file
+ template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR)
+ if template_bgr is None:
+ print(f"[WARN] Could not load template file: {template_path}")
+ return None
+
+ # 3) Template matching
+ result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED)
+
+ # 4) Find best match
+ min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
+ # template width/height
+ th, tw, _ = template_bgr.shape
+
+ if max_val >= threshold:
+ # max_loc is top-left corner of the best match
+ found_x, found_y = max_loc
+ return (found_x, found_y, tw, th)
+ else:
+ return None
+
+
+def format_duration(seconds):
+ if seconds is None:
+ return "???"
+ seconds = int(seconds)
+ hours = seconds // 3600
+ leftover = seconds % 3600
+ mins = leftover // 60
+ secs = leftover % 60
+ if hours > 0:
+ return f"{hours}h {mins}m {secs}s"
+ else:
+ return f"{mins}m {secs}s"
+
+
+def sanitize_experience_string(raw_text):
+ text_no_percent = raw_text.replace('%', '')
+ text_no_spaces = text_no_percent.replace(' ', '')
+ cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces)
+ match = re.search(r'\d+(?:\.\d+)?', cleaned)
+ if not match:
+ return None
+ val = float(match.group(0))
+ if val < 0:
+ val = 0
+ elif val > 100:
+ val = 100
+ return round(val, 4)
+
+
+def format_experience_value(value):
+ if value < 0:
+ value = 0
+ elif value > 100:
+ value = 100
+ float_4 = round(value, 4)
+ raw_str = f"{float_4:.4f}"
+ int_part, dec_part = raw_str.split('.')
+ if int_part == "100":
+ pass
+ elif len(int_part) == 1 and int_part != "0":
+ int_part = "0" + int_part
+ elif int_part == "0":
+ int_part = "00"
+ return f"{int_part}.{dec_part}"
+
+# -----------------------------------------------------------------------------
+# Region Class
+# -----------------------------------------------------------------------------
+class Region:
+ """
+ Defines a draggable/resizable screen region for OCR capture.
+ """
+ def __init__(self, x, y, label="Region", color=QColor(0,0,255)):
+ self.x = x
+ self.y = y
+ self.w = DEFAULT_WIDTH
+ self.h = DEFAULT_HEIGHT
+ self.label = label
+ self.color = color
+ self.visible = True
+ self.data = ""
+
+ def rect(self):
+ return QRect(self.x, self.y, self.w, self.h)
+
+ def label_rect(self):
+ return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT)
+
+ def resize_handles(self):
+ return [
+ QRect(self.x - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
+ QRect(self.x + self.w - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
+ QRect(self.x - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
+ QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
+ ]
+
+# -----------------------------------------------------------------------------
+# OverlayCanvas Class
+# -----------------------------------------------------------------------------
+class OverlayCanvas(QWidget):
+ """
+ Renders the overlay & handles region dragging/resizing.
+ """
+ def __init__(self, regions, parent=None):
+ super().__init__(parent)
+ self.regions = regions
+ self.edit_mode = True
+ self.selected_region = None
+ self.selected_handle = None
+ self.drag_offset = QPoint()
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ for region in self.regions:
+ if region.visible:
+ pen = QPen(region.color)
+ pen.setWidth(3)
+ painter.setPen(pen)
+ painter.drawRect(region.x, region.y, region.w, region.h)
+
+ painter.setFont(QFont("Arial", 12, QFont.Bold))
+ painter.setPen(region.color)
+ painter.drawText(region.x, region.y - 5, region.label)
+
+ if self.edit_mode:
+ for handle in region.resize_handles():
+ painter.fillRect(handle, region.color)
+
+ def mousePressEvent(self, event):
+ if not self.edit_mode:
+ return
+ if event.button() == Qt.LeftButton:
+ for region in reversed(self.regions):
+ for i, handle in enumerate(region.resize_handles()):
+ if handle.contains(event.pos()):
+ self.selected_region = region
+ self.selected_handle = i
+ return
+ if region.label_rect().contains(event.pos()):
+ self.selected_region = region
+ self.selected_handle = None
+ self.drag_offset = event.pos() - QPoint(region.x, region.y)
+ return
+ if region.rect().contains(event.pos()):
+ self.selected_region = region
+ self.selected_handle = None
+ self.drag_offset = event.pos() - QPoint(region.x, region.y)
+ return
+
+ def mouseMoveEvent(self, event):
+ if not self.edit_mode or self.selected_region is None:
+ return
+
+ if self.selected_handle is None:
+ self.selected_region.x = event.x() - self.drag_offset.x()
+ self.selected_region.y = event.y() - self.drag_offset.y()
+ else:
+ sr = self.selected_region
+ if self.selected_handle == 0: # top-left
+ sr.w += sr.x - event.x()
+ sr.h += sr.y - event.y()
+ sr.x = event.x()
+ sr.y = event.y()
+ elif self.selected_handle == 1: # top-right
+ sr.w = event.x() - sr.x
+ sr.h += sr.y - event.y()
+ sr.y = event.y()
+ elif self.selected_handle == 2: # bottom-left
+ sr.w += sr.x - event.x()
+ sr.h = event.y() - sr.y
+ sr.x = event.x()
+ elif self.selected_handle == 3: # bottom-right
+ sr.w = event.x() - sr.x
+ sr.h = event.y() - sr.y
+
+ sr.w = max(sr.w, 10)
+ sr.h = max(sr.h, 10)
+
+ self.update()
+
+ def mouseReleaseEvent(self, event):
+ if not self.edit_mode:
+ return
+ if event.button() == Qt.LeftButton:
+ self.selected_region = None
+ self.selected_handle = None
+
+# -----------------------------------------------------------------------------
+# BorealisOverlay Class
+# -----------------------------------------------------------------------------
+class BorealisOverlay(QWidget):
+ """
+ Single Region Overlay for Player Stats (HP/MP/FP/EXP) with:
+ - Automatic location via OpenCV template matching at startup
+ - OCR scanning
+ - Low-HP beep
+ - Rich Live updates in terminal
+ """
+ def __init__(self, live=None):
+ super().__init__()
+ screen_geo = QApplication.primaryScreen().geometry()
+ self.setGeometry(screen_geo)
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
+
+ # Try to find the bars automatically
+ # If found => use that location, else default
+ initial_x, initial_y = 250, 50
+ region_w, region_h = DEFAULT_WIDTH, DEFAULT_HEIGHT
+
+ match_result = locate_bars_opencv(TEMPLATE_PATH, MATCH_THRESHOLD)
+ if match_result is not None:
+ found_x, found_y, w, h = match_result
+ print(f"Character Status Located at {found_x}, {found_y} with confidence >= {MATCH_THRESHOLD}.")
+ initial_x, initial_y = found_x, found_y
+ # Optionally override region size with template size
+ region_w, region_h = w, h
+ else:
+ print("Could not auto-locate the character status page. Set your theme to Masquerade and Interface Scale to 140%, and browser zoom level to 110%. Using default region.")
+
+ region = Region(initial_x, initial_y, label="Character Status")
+ region.w = region_w
+ region.h = region_h
+ self.regions = [region]
+
+ self.canvas = OverlayCanvas(self.regions, self)
+ self.canvas.setGeometry(self.rect())
+
+ # Tesseract
+ self.engine = pytesseract
+
+ # Keep history of EXP data
+ self.points = []
+
+ self.live = live
+
+ # Timer for periodic OCR scanning
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.collect_ocr_data)
+ self.timer.start(POLLING_RATE_MS)
+
+ def set_live(self, live):
+ self.live = live
+
+ def collect_ocr_data(self):
+ for region in self.regions:
+ if region.visible:
+ screenshot = ImageGrab.grab(
+ bbox=(region.x, region.y, region.x + region.w, region.y + region.h)
+ )
+ processed = self.preprocess_image(screenshot)
+ text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
+ region.data = text.strip()
+
+ if self.live is not None:
+ renderable = self.build_renderable()
+ self.live.update(renderable)
+
+ def preprocess_image(self, image):
+ gray = image.convert("L")
+ scaled = gray.resize((gray.width * 3, gray.height * 3))
+ thresh = scaled.point(lambda p: p > 200 and 255)
+ return thresh.filter(ImageFilter.MedianFilter(3))
+
+ def parse_all_stats(self, raw_text):
+ raw_lines = raw_text.splitlines()
+ lines = [l.strip() for l in raw_lines if l.strip()]
+ stats_dict = {
+ "hp": (0,1),
+ "mp": (0,1),
+ "fp": (0,1),
+ "exp": None
+ }
+ if len(lines) < 4:
+ return stats_dict
+
+ hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
+ if hp_match:
+ stats_dict["hp"] = (int(hp_match.group(1)), int(hp_match.group(2)))
+
+ mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1])
+ if mp_match:
+ stats_dict["mp"] = (int(mp_match.group(1)), int(mp_match.group(2)))
+
+ fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2])
+ if fp_match:
+ stats_dict["fp"] = (int(fp_match.group(1)), int(fp_match.group(2)))
+
+ exp_val = sanitize_experience_string(lines[3])
+ stats_dict["exp"] = exp_val
+ return stats_dict
+
+ def update_points(self, new_val):
+ now = time.time()
+ if self.points:
+ _, last_v = self.points[-1]
+ if abs(new_val - last_v) < 1e-6:
+ return
+ if new_val < last_v:
+ self.points.clear()
+ self.points.append((now, new_val))
+ if len(self.points) > MAX_DATA_POINTS:
+ self.points.pop(0)
+
+ def compute_time_to_100(self):
+ n = len(self.points)
+ if n < 2:
+ return None
+ first_t, first_v = self.points[0]
+ last_t, last_v = self.points[-1]
+ diff_v = last_v - first_v
+ if diff_v <= 0:
+ return None
+
+ steps = n - 1
+ total_time = last_t - first_t
+ if total_time <= 0:
+ return None
+
+ avg_change = diff_v / steps
+ remain = 100.0 - last_v
+ if remain <= 0:
+ return None
+
+ avg_time = total_time / steps
+ rate_per_s = avg_change / avg_time if avg_time > 0 else 0
+ if rate_per_s <= 0:
+ return None
+
+ return int(remain / rate_per_s)
+
+ def build_renderable(self):
+ raw_text = self.regions[0].data
+ stats = self.parse_all_stats(raw_text)
+ hp_cur, hp_max = stats["hp"]
+ mp_cur, mp_max = stats["mp"]
+ fp_cur, fp_max = stats["fp"]
+ exp_val = stats["exp"]
+
+ # HP beep logic
+ if hp_max > 0:
+ hp_ratio = hp_cur / hp_max
+ if 0 < hp_ratio <= 0.40:
+ beep_hp_warning()
+
+ if exp_val is not None:
+ self.update_points(exp_val)
+ current_exp = self.points[-1][1] if self.points else 0.0
+
+ # Title
+ title_text = Text("Project Borealis\n", style="bold white")
+ subtitle_text = Text("Flyff Information Overlay\n\n", style="dim")
+
+ # HP / MP / FP bars
+ bar_progress = Progress(
+ "{task.description}",
+ BarColumn(bar_width=30),
+ TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"),
+ transient=False,
+ auto_refresh=False
+ )
+ bar_progress.add_task("[bold red]HP[/bold red]", total=hp_max, completed=hp_cur,
+ style="red", complete_style="red")
+ bar_progress.add_task("[bold blue]MP[/bold blue]", total=mp_max, completed=mp_cur,
+ style="blue", complete_style="blue")
+ bar_progress.add_task("[bold green]FP[/bold green]", total=fp_max, completed=fp_cur,
+ style="green", complete_style="green")
+ bar_progress.refresh()
+
+ # Historical EXP table
+ table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None)
+ table.add_column("Historical EXP", justify="center", style="green")
+ table.add_column("Time Since Last Kill", justify="center", style="green")
+ table.add_column("Average EXP Per Kill", justify="center", style="green")
+ table.add_column("Average Time Between Kills", justify="center", style="green")
+
+ n = len(self.points)
+ if n == 0:
+ table.add_row("N/A", "N/A", "N/A", "N/A")
+ elif n == 1:
+ _, v0 = self.points[0]
+ exp_str = f"[green]{format_experience_value(v0)}%[/green]"
+ table.add_row(exp_str, "N/A", "N/A", "N/A")
+ else:
+ for i in range(1, n):
+ t_cur, v_cur = self.points[i]
+ t_prev, v_prev = self.points[i - 1]
+ delta_v = v_cur - v_prev
+ delta_str = f"{delta_v:+.4f}%"
+ exp_main = format_experience_value(v_cur)
+ exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]"
+
+ delta_t = t_cur - t_prev
+ t_since_str = f"{delta_t:.1f}s"
+
+ diff_v = v_cur - self.points[0][1]
+ steps = i
+ avg_exp_str = f"{diff_v/steps:.4f}%"
+
+ total_time = t_cur - self.points[0][0]
+ avg_kill_time = total_time / steps
+ avg_time_str = f"{avg_kill_time:.1f}s"
+
+ table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str)
+
+ # Predicted Time to Level
+ secs_left = self.compute_time_to_100()
+ time_str = format_duration(secs_left)
+
+ time_bar = Progress(
+ TextColumn("[bold white]Predicted Time to Level:[/bold white] "),
+ BarColumn(bar_width=30, complete_style="magenta"),
+ TextColumn(" [green]{task.percentage:>5.2f}%[/green] "),
+ TextColumn(f"[magenta]{time_str}[/magenta] until 100%", justify="right"),
+ transient=False,
+ auto_refresh=False
+ )
+ time_bar.add_task("", total=100, completed=current_exp)
+ time_bar.refresh()
+
+ return Group(
+ title_text,
+ subtitle_text,
+ bar_progress,
+ table,
+ time_bar
+ )
+
+# -----------------------------------------------------------------------------
+# main
+# -----------------------------------------------------------------------------
+def main():
+ """
+ 1) Attempt to locate HP/MP/FP/Exp bars using OpenCV template matching.
+ 2) Position overlay region accordingly if found, else default.
+ 3) Start PyQt, periodically OCR the region, update Rich Live in terminal.
+ """
+ app = QApplication(sys.argv)
+ window = BorealisOverlay()
+ window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
+ window.show()
+
+ console = Console()
+
+ with Live(console=console, refresh_per_second=4) as live:
+ window.set_live(live)
+ exit_code = app.exec_()
+
+ sys.exit(exit_code)
+
+if __name__ == "__main__":
+ main()
diff --git a/Data/Experiments/flowpipe.py b/Data/Experiments/flowpipe.py
new file mode 100644
index 0000000..47aedaf
--- /dev/null
+++ b/Data/Experiments/flowpipe.py
@@ -0,0 +1,80 @@
+from flask import Flask, jsonify
+from flowpipe.node import Node
+from flowpipe.graph import Graph
+from flowpipe.plug import InputPlug, OutputPlug
+
+app = Flask(__name__)
+
+# ===========================
+# Define Custom Nodes
+# ===========================
+
+class MultiplyNode(Node):
+ """Multiplies an input value by a factor"""
+ factor = InputPlug()
+ value = InputPlug()
+ result = OutputPlug()
+
+ def compute(self):
+ self.result.value = self.value.value * self.factor.value
+
+
+class AddNode(Node):
+ """Adds two input values"""
+ input1 = InputPlug()
+ input2 = InputPlug()
+ sum = OutputPlug()
+
+ def compute(self):
+ self.sum.value = self.input1.value + self.input2.value
+
+
+class OutputNode(Node):
+ """Outputs the final result"""
+ input_value = InputPlug()
+ output_value = OutputPlug()
+
+ def compute(self):
+ self.output_value.value = self.input_value.value
+
+
+# ===========================
+# Define Graph Workflow
+# ===========================
+
+def create_workflow():
+ """Creates a sample workflow using nodes"""
+ graph = Graph(name="Sample Workflow")
+
+ # Create nodes
+ multiply = MultiplyNode(name="Multiplier", graph=graph)
+ add = AddNode(name="Adder", graph=graph)
+ output = OutputNode(name="Output", graph=graph)
+
+ # Connect nodes
+ multiply.result.connect(add.input1) # Multiply output -> Add input1
+ add.sum.connect(output.input_value) # Add output -> Output node
+
+ # Set static input values
+ multiply.factor.value = 2
+ multiply.value.value = 5 # 5 * 2 = 10
+ add.input2.value = 3 # 10 + 3 = 13
+
+ return graph
+
+
+@app.route('/run-workflow', methods=['GET'])
+def run_workflow():
+ """Runs the defined node-based workflow"""
+ graph = create_workflow()
+ graph.evaluate() # Execute the graph
+
+ # Extract the final result from the output node
+ output_node = graph.nodes["Output"]
+ result = output_node.output_value.value
+
+ return jsonify({"workflow_result": result})
+
+
+if __name__ == '__main__':
+ app.run(debug=True)
diff --git a/Data/Experiments/gui_elements.py b/Data/Experiments/gui_elements.py
new file mode 100644
index 0000000..f65f513
--- /dev/null
+++ b/Data/Experiments/gui_elements.py
@@ -0,0 +1,98 @@
+# example_qt_interface.py
+import sys
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction, QIcon
+from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout,
+ QLabel, QMenuBar, QToolBar, QSplitter, QListWidget,
+ QTextEdit, QStatusBar, QFileDialog, QPushButton
+)
+
+
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+
+ self.setWindowTitle("Example Qt Interface")
+
+ # Create and set up the menu bar.
+ menu_bar = QMenuBar(self)
+ self.setMenuBar(menu_bar)
+
+ # File menu.
+ file_menu = menu_bar.addMenu("File")
+
+ # Create some actions to populate the File menu.
+ open_action = QAction("Open", self)
+ open_action.triggered.connect(self.open_file)
+ file_menu.addAction(open_action)
+
+ save_action = QAction("Save", self)
+ save_action.triggered.connect(self.save_file)
+ file_menu.addAction(save_action)
+
+ exit_action = QAction("Exit", self)
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ # Create a toolbar and add some actions.
+ tool_bar = QToolBar("Main Toolbar", self)
+ tool_bar.addAction(open_action)
+ tool_bar.addAction(save_action)
+ self.addToolBar(Qt.TopToolBarArea, tool_bar)
+
+ # Set up a status bar at the bottom.
+ self.setStatusBar(QStatusBar(self))
+ self.statusBar().showMessage("Ready")
+
+ # Create your central widget area.
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+ layout = QVBoxLayout(central_widget)
+
+ # A splitter as an example container that can hold multiple widgets side-by-side.
+ splitter = QSplitter()
+
+ # Left side: a simple list widget.
+ self.list_widget = QListWidget()
+ self.list_widget.addItem("Item A")
+ self.list_widget.addItem("Item B")
+ self.list_widget.addItem("Item C")
+ splitter.addWidget(self.list_widget)
+
+ # Right side: a text edit widget.
+ self.text_edit = QTextEdit()
+ self.text_edit.setPlainText("Type here...")
+ splitter.addWidget(self.text_edit)
+
+ layout.addWidget(splitter)
+
+ # Example button in the central widget area.
+ example_button = QPushButton("Click Me")
+ example_button.clicked.connect(self.on_button_clicked)
+ layout.addWidget(example_button)
+
+ def open_file(self):
+ file_name, _ = QFileDialog.getOpenFileName(self, "Open File", "", "All Files (*.*)")
+ if file_name:
+ self.statusBar().showMessage(f"Opened: {file_name}")
+
+ def save_file(self):
+ file_name, _ = QFileDialog.getSaveFileName(self, "Save File", "", "All Files (*.*)")
+ if file_name:
+ self.statusBar().showMessage(f"Saved: {file_name}")
+
+ def on_button_clicked(self):
+ self.statusBar().showMessage("Button clicked!")
+
+
+def main():
+ app = QApplication(sys.argv)
+ window = MainWindow()
+ window.resize(800, 600)
+ window.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Data/Modules/data_collector.py b/Data/Modules/data_collector.py
new file mode 100644
index 0000000..74d025c
--- /dev/null
+++ b/Data/Modules/data_collector.py
@@ -0,0 +1,398 @@
+# Modules/data_collector.py
+
+import threading
+import time
+import re
+import sys
+import numpy as np
+import cv2
+import concurrent.futures
+
+# Vision-related Imports
+import pytesseract
+import easyocr
+import torch
+
+from PIL import Image, ImageGrab, ImageFilter
+
+from PyQt5.QtWidgets import QApplication, QWidget
+from PyQt5.QtCore import QRect, QPoint, Qt, QMutex, QTimer
+from PyQt5.QtGui import QPainter, QPen, QColor, QFont
+
+# Initialize EasyOCR with CUDA support
+reader_cpu = None
+reader_gpu = None
+
+def initialize_ocr_engines():
+ global reader_cpu, reader_gpu
+ reader_cpu = easyocr.Reader(['en'], gpu=False)
+ reader_gpu = easyocr.Reader(['en'], gpu=True if torch.cuda.is_available() else False)
+
+pytesseract.pytesseract.tesseract_cmd = r"C:\\Program Files\\Tesseract-OCR\\tesseract.exe"
+
+DEFAULT_WIDTH = 180
+DEFAULT_HEIGHT = 130
+HANDLE_SIZE = 5
+LABEL_HEIGHT = 20
+
+collector_mutex = QMutex()
+regions = {}
+
+app_instance = None
+
+def _ensure_qapplication():
+ """
+ Ensures that QApplication is initialized before creating widgets.
+ Must be called from the main thread.
+ """
+ global app_instance
+ if app_instance is None:
+ app_instance = QApplication(sys.argv) # Start in main thread
+
+def capture_region_as_image(region_id):
+ collector_mutex.lock()
+ if region_id not in regions:
+ collector_mutex.unlock()
+ return None
+ x, y, w, h = regions[region_id]['bbox'][:]
+ collector_mutex.unlock()
+ screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
+ return screenshot
+
+def create_ocr_region(region_id, x=250, y=50, w=DEFAULT_WIDTH, h=DEFAULT_HEIGHT, color=(255, 255, 0), thickness=2):
+ """
+ Creates an OCR region with a visible, resizable box on the screen.
+ Allows setting custom color (RGB) and line thickness.
+ """
+ _ensure_qapplication()
+
+ collector_mutex.lock()
+ if region_id in regions:
+ collector_mutex.unlock()
+ return
+ regions[region_id] = {
+ 'bbox': [x, y, w, h],
+ 'raw_text': "",
+ 'widget': OCRRegionWidget(x, y, w, h, region_id, color, thickness)
+ }
+ collector_mutex.unlock()
+
+def get_raw_text(region_id):
+ collector_mutex.lock()
+ if region_id not in regions:
+ collector_mutex.unlock()
+ return ""
+ text = regions[region_id]['raw_text']
+ collector_mutex.unlock()
+ return text
+
+def start_collector():
+ initialize_ocr_engines()
+ t = threading.Thread(target=_update_ocr_loop, daemon=True)
+ t.start()
+
+def _update_ocr_loop():
+ while True:
+ collector_mutex.lock()
+ region_ids = list(regions.keys())
+ collector_mutex.unlock()
+
+ for rid in region_ids:
+ collector_mutex.lock()
+ bbox = regions[rid]['bbox'][:]
+ collector_mutex.unlock()
+
+ x, y, w, h = bbox
+ screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
+ processed = _preprocess_image(screenshot)
+ raw_text = pytesseract.image_to_string(processed, config='--psm 6 --oem 1')
+
+ collector_mutex.lock()
+ if rid in regions:
+ regions[rid]['raw_text'] = raw_text
+ collector_mutex.unlock()
+
+ time.sleep(0.7)
+
+def _preprocess_image(image):
+ gray = image.convert("L")
+ scaled = gray.resize((gray.width * 3, gray.height * 3))
+ thresh = scaled.point(lambda p: 255 if p > 200 else 0)
+ return thresh.filter(ImageFilter.MedianFilter(3))
+
+
+def find_word_positions(region_id, word, offset_x=0, offset_y=0, margin=5, ocr_engine="CPU", num_slices=1):
+ """
+ Uses user-defined horizontal slices and threading for faster inference.
+ """
+ collector_mutex.lock()
+ if region_id not in regions:
+ collector_mutex.unlock()
+ return []
+
+ bbox = regions[region_id]['bbox']
+ collector_mutex.unlock()
+
+ x, y, w, h = bbox
+ left, top, right, bottom = x, y, x + w, y + h
+
+ if right <= left or bottom <= top:
+ print(f"[ERROR] Invalid OCR region bounds: {bbox}")
+ return []
+
+ try:
+ image = ImageGrab.grab(bbox=(left, top, right, bottom))
+ orig_width, orig_height = image.size
+
+ word_positions = []
+
+ # Ensure number of slices does not exceed image height
+ num_slices = min(num_slices, orig_height)
+ strip_height = max(1, orig_height // num_slices)
+
+ def process_strip(strip_id):
+ strip_y = strip_id * strip_height
+ strip = image.crop((0, strip_y, orig_width, strip_y + strip_height))
+
+ strip_np = np.array(strip)
+
+ detected_positions = []
+ if ocr_engine == "CPU":
+ ocr_data = pytesseract.image_to_data(strip, config='--psm 6 --oem 1', output_type=pytesseract.Output.DICT)
+
+ for i in range(len(ocr_data['text'])):
+ if re.search(rf"\b{word}\b", ocr_data['text'][i], re.IGNORECASE):
+ x_scaled = int(ocr_data['left'][i])
+ y_scaled = int(ocr_data['top'][i]) + strip_y
+ w_scaled = int(ocr_data['width'][i])
+ h_scaled = int(ocr_data['height'][i])
+
+ detected_positions.append((x_scaled + offset_x, y_scaled + offset_y, w_scaled + (margin * 2), h_scaled + (margin * 2)))
+
+ else:
+ results = reader_gpu.readtext(strip_np)
+ for (bbox, text, _) in results:
+ if re.search(rf"\b{word}\b", text, re.IGNORECASE):
+ (x_min, y_min), (x_max, y_max) = bbox[0], bbox[2]
+
+ x_scaled = int(x_min)
+ y_scaled = int(y_min) + strip_y
+ w_scaled = int(x_max - x_min)
+ h_scaled = int(y_max - y_min)
+
+ detected_positions.append((x_scaled + offset_x, y_scaled + offset_y, w_scaled + (margin * 2), h_scaled + (margin * 2)))
+
+ return detected_positions
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=num_slices) as executor:
+ strip_results = list(executor.map(process_strip, range(num_slices)))
+
+ for strip_result in strip_results:
+ word_positions.extend(strip_result)
+
+ return word_positions
+
+ except Exception as e:
+ print(f"[ERROR] Failed to capture OCR region: {e}")
+ return []
+
+def draw_identification_boxes(region_id, positions, color=(0, 0, 255), thickness=2):
+ """
+ Draws non-interactive rectangles at specified positions within the given OCR region.
+ Uses a separate rendering thread to prevent blocking OCR processing.
+ """
+ collector_mutex.lock()
+ if region_id in regions and 'widget' in regions[region_id]:
+ widget = regions[region_id]['widget']
+ widget.update_draw_positions(positions, color, thickness)
+ collector_mutex.unlock()
+
+def update_region_slices(region_id, num_slices):
+ """
+ Updates the number of visual slices in the OCR region.
+ """
+ collector_mutex.lock()
+ if region_id in regions and 'widget' in regions[region_id]:
+ widget = regions[region_id]['widget']
+ widget.set_num_slices(num_slices)
+ collector_mutex.unlock()
+
+class OCRRegionWidget(QWidget):
+ def __init__(self, x, y, w, h, region_id, color, thickness):
+ super().__init__()
+
+ self.setGeometry(x, y, w, h)
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
+
+ self.region_id = region_id
+ self.box_color = QColor(*color)
+ self.line_thickness = thickness
+ self.draw_positions = []
+ self.previous_positions = [] # This prevents redundant redraws
+ self.num_slices = 1 # Ensures slice count is initialized
+
+ # --- Initialization for interactive handles ---
+ self.selected_handle = None # Tracks which handle is being dragged/resized
+ self.drag_offset = None # Tracks the offset for moving the widget
+
+ self.show()
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ pen = QPen(self.box_color)
+ pen.setWidth(self.line_thickness)
+ painter.setPen(pen)
+
+ # Draw main rectangle
+ painter.drawRect(0, 0, self.width(), self.height())
+
+ # Draw detected word overlays
+ for x, y, w, h in self.draw_positions:
+ painter.drawRect(x, y, w, h)
+
+ # Draw faint slice division lines
+ if self.num_slices > 1:
+ strip_height = self.height() // self.num_slices
+ pen.setColor(QColor(150, 150, 150, 100)) # Light gray, semi-transparent
+ pen.setWidth(1)
+ painter.setPen(pen)
+
+ for i in range(1, self.num_slices): # Do not draw the last one at the bottom
+ painter.drawLine(0, i * strip_height, self.width(), i * strip_height)
+
+ # --- Draw interactive handles (grabbers) with reduced opacity (15%) ---
+ # 15% opacity of 255 is approximately 38
+ handle_color = QColor(0, 0, 0, 50)
+ for handle in self._resize_handles():
+ painter.fillRect(handle, handle_color)
+ painter.drawRect(handle) # Optional: draw a border around the handle
+
+ def set_draw_positions(self, positions, color, thickness):
+ """
+ Updates the overlay positions and visual settings.
+ """
+ self.draw_positions = positions
+ self.box_color = QColor(*color)
+ self.line_thickness = thickness
+ self.update()
+
+ def update_draw_positions(self, positions, color, thickness):
+ """
+ Updates the overlay positions and redraws only if the positions have changed.
+ This prevents unnecessary flickering.
+ """
+ if positions == self.previous_positions:
+ return # No change, do not update
+
+ self.previous_positions = positions # Store last known positions
+ self.draw_positions = positions
+ self.box_color = QColor(*color)
+ self.line_thickness = thickness
+ self.update() # Redraw only if needed
+
+ def set_num_slices(self, num_slices):
+ """
+ Updates the number of horizontal slices for visualization.
+ """
+ self.num_slices = num_slices
+ self.update()
+
+ def _resize_handles(self):
+ """
+ Returns a list of QRect objects representing the interactive handles:
+ - Index 0: Top-left (resize)
+ - Index 1: Top-right (resize)
+ - Index 2: Bottom-left (resize)
+ - Index 3: Bottom-right (resize)
+ - Index 4: Top-center (dragger)
+ """
+ w, h = self.width(), self.height()
+ handles = [
+ QRect(0, 0, HANDLE_SIZE, HANDLE_SIZE), # Top-left
+ QRect(w - HANDLE_SIZE, 0, HANDLE_SIZE, HANDLE_SIZE), # Top-right
+ QRect(0, h - HANDLE_SIZE, HANDLE_SIZE, HANDLE_SIZE), # Bottom-left
+ QRect(w - HANDLE_SIZE, h - HANDLE_SIZE, HANDLE_SIZE, HANDLE_SIZE) # Bottom-right
+ ]
+ # Top-center handle: centered along the top edge
+ top_center_x = (w - HANDLE_SIZE) // 2
+ top_center = QRect(top_center_x, 0, HANDLE_SIZE, HANDLE_SIZE)
+ handles.append(top_center)
+ return handles
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ # Check if any handle (including the new top-center) is clicked
+ for i, handle in enumerate(self._resize_handles()):
+ if handle.contains(event.pos()):
+ self.selected_handle = i
+ # For the top-center handle (index 4), initialize drag offset for moving
+ if i == 4:
+ self.drag_offset = event.pos()
+ return
+ # If no handle is clicked, allow dragging by clicking anywhere in the widget
+ self.drag_offset = event.pos()
+
+ def mouseMoveEvent(self, event):
+ if self.selected_handle is not None:
+ if self.selected_handle == 4:
+ # --- Top-center handle dragging ---
+ new_x = event.globalX() - self.drag_offset.x()
+ new_y = event.globalY() - self.drag_offset.y()
+ self.move(new_x, new_y)
+ collector_mutex.lock()
+ if self.region_id in regions:
+ regions[self.region_id]["bbox"] = [new_x, new_y, self.width(), self.height()]
+ collector_mutex.unlock()
+ self.update()
+ else:
+ # --- Resizing logic for corner handles ---
+ if self.selected_handle == 0: # Top-left
+ new_w = self.width() + (self.x() - event.globalX())
+ new_h = self.height() + (self.y() - event.globalY())
+ new_x = event.globalX()
+ new_y = event.globalY()
+ elif self.selected_handle == 1: # Top-right
+ new_w = event.globalX() - self.x()
+ new_h = self.height() + (self.y() - event.globalY())
+ new_x = self.x()
+ new_y = event.globalY()
+ elif self.selected_handle == 2: # Bottom-left
+ new_w = self.width() + (self.x() - event.globalX())
+ new_h = event.globalY() - self.y()
+ new_x = event.globalX()
+ new_y = self.y()
+ elif self.selected_handle == 3: # Bottom-right
+ new_w = event.globalX() - self.x()
+ new_h = event.globalY() - self.y()
+ new_x = self.x()
+ new_y = self.y()
+
+ if new_w < 20:
+ new_w = 20
+ if new_h < 20:
+ new_h = 20
+
+ self.setGeometry(new_x, new_y, new_w, new_h)
+ collector_mutex.lock()
+ if self.region_id in regions:
+ regions[self.region_id]["bbox"] = [self.x(), self.y(), self.width(), self.height()]
+ collector_mutex.unlock()
+ self.update()
+ elif self.drag_offset:
+ # --- General widget dragging (if no handle was clicked) ---
+ new_x = event.globalX() - self.drag_offset.x()
+ new_y = event.globalY() - self.drag_offset.y()
+ self.move(new_x, new_y)
+ collector_mutex.lock()
+ if self.region_id in regions:
+ regions[self.region_id]["bbox"] = [new_x, new_y, self.width(), self.height()]
+ collector_mutex.unlock()
+
+ def mouseReleaseEvent(self, event):
+ """
+ Resets the drag/resize state once the mouse button is released.
+ """
+ self.selected_handle = None
+ self.drag_offset = None
diff --git a/Data/Modules/data_manager.py b/Data/Modules/data_manager.py
new file mode 100644
index 0000000..28071bf
--- /dev/null
+++ b/Data/Modules/data_manager.py
@@ -0,0 +1,156 @@
+import threading
+import time
+import base64
+from flask import Flask, jsonify, Response
+from PyQt5.QtCore import QMutex
+
+# Global datastore for character metrics
+data_store = {
+ "hp_current": 0,
+ "hp_total": 0,
+ "mp_current": 0,
+ "mp_total": 0,
+ "fp_current": 0,
+ "fp_total": 0,
+ "exp": 0.0
+}
+
+# Mutex for thread safety
+data_mutex = QMutex()
+
+# Flag to ensure only one character status collector node exists
+character_status_collector_exists = False
+
+# A place to store the screenshot in base64
+status_screenshot_base64 = ""
+
+# Flask Application
+app = Flask(__name__)
+
+@app.route('/data')
+def data_api():
+ """
+ Returns the current character metrics as JSON.
+ """
+ return jsonify(get_data())
+
+@app.route('/exp')
+def exp_api():
+ """
+ Returns the EXP data.
+ """
+ return jsonify({"exp": get_data()["exp"]})
+
+@app.route('/hp')
+def hp_api():
+ """
+ Returns the HP data.
+ """
+ return jsonify({
+ "hp_current": get_data()["hp_current"],
+ "hp_total": get_data()["hp_total"]
+ })
+
+@app.route('/mp')
+def mp_api():
+ """
+ Returns the MP data.
+ """
+ return jsonify({
+ "mp_current": get_data()["mp_current"],
+ "mp_total": get_data()["mp_total"]
+ })
+
+@app.route('/fp')
+def fp_api():
+ """
+ Returns the FP data.
+ """
+ return jsonify({
+ "fp_current": get_data()["fp_current"],
+ "fp_total": get_data()["fp_total"]
+ })
+
+@app.route('/flyff/status')
+def status_screenshot():
+ """
+ Returns an HTML page that displays the stored screenshot and
+ automatically refreshes it every second without requiring a manual page reload.
+ """
+ html = """
+
+
+ Borealis - Live Status
+
+
+
+
+
+
+ """
+ return html
+
+@app.route('/flyff/status_rawdata')
+def status_screenshot_data():
+ """
+ Serves the raw PNG bytes (decoded from base64) used by
in /status_screenshot.
+ """
+ data_mutex.lock()
+ encoded = status_screenshot_base64
+ data_mutex.unlock()
+
+ if not encoded:
+ # No image captured yet, return HTTP 204 "No Content"
+ return "", 204
+
+ raw_img = base64.b64decode(encoded)
+ return Response(raw_img, mimetype='image/png')
+
+def start_api_server():
+ """
+ Starts the Flask API server in a separate daemon thread.
+ """
+ def run():
+ app.run(host="0.0.0.0", port=5000) # Allows external connections
+ t = threading.Thread(target=run, daemon=True)
+ t.start()
+
+def get_data():
+ """
+ Return a copy of the global data_store.
+ """
+ data_mutex.lock()
+ data_copy = data_store.copy()
+ data_mutex.unlock()
+ return data_copy
+
+def set_data(key, value):
+ """
+ Set a single metric in the global data_store.
+ """
+ data_mutex.lock()
+ data_store[key] = value
+ data_mutex.unlock()
+
+def set_data_bulk(metrics_dict):
+ """
+ Update multiple metrics in the global data_store at once.
+ """
+ data_mutex.lock()
+ data_store.update(metrics_dict)
+ data_mutex.unlock()
+
+def set_status_screenshot(encoded_str):
+ """
+ Called by the OCR node to store the base64-encoded screenshot data.
+ """
+ global status_screenshot_base64
+ data_mutex.lock()
+ status_screenshot_base64 = encoded_str
+ data_mutex.unlock()
diff --git a/Data/Nodes/Experimental/blueprint_node.py b/Data/Nodes/Experimental/blueprint_node.py
new file mode 100644
index 0000000..cd6c742
--- /dev/null
+++ b/Data/Nodes/Experimental/blueprint_node.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+
+from OdenGraphQt import BaseNode
+from Qt import QtCore
+
+class BlueprintNode(BaseNode):
+ """
+ A placeholder node used to preview placement before spawning
+ the real node. It has a distinct color and minimal UI.
+ """
+ __identifier__ = 'bunny-lab.io.blueprint'
+ NODE_NAME = 'Blueprint Node'
+
+ def __init__(self):
+ super(BlueprintNode, self).__init__()
+ # Display a name so the user sees "Click to Place Node"
+ self.set_name("Click to Place Node")
+
+ # Give it a bluish color + white text, for visibility
+ self.set_color(60, 120, 220) # R, G, B
+ self.view.text_color = (255, 255, 255, 200)
+ self.view.border_color = (255, 255, 255, 180)
+
+ # Make it slightly transparent if desired (alpha=150)
+ self.view._bg_color = (60, 120, 220, 150)
+
+ # Remove any default inputs/outputs (make it minimal)
+ for port in self.input_ports() + self.output_ports():
+ self.model.delete_port(port.name(), port.port_type)
+
+ # Store the "actual node" we want to spawn
+ self.create_property("actual_node_type", "", widget_type=0)
+
+ def process_input(self):
+ """
+ We do nothing here; it is purely a placeholder node.
+ """
+ pass
diff --git a/Data/Nodes/Flyff/Resources/bars_template.png b/Data/Nodes/Flyff/Resources/bars_template.png
new file mode 100644
index 0000000..179037b
Binary files /dev/null and b/Data/Nodes/Flyff/Resources/bars_template.png differ
diff --git a/Data/Nodes/Flyff/flyff_EXP_current.py b/Data/Nodes/Flyff/flyff_EXP_current.py
new file mode 100644
index 0000000..3ca7595
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_EXP_current.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""
+Flyff EXP Node (Final Combined Version)
+ - Pulls the EXP value directly from data_manager.py
+ - Outputs only the "exp" value as a string
+ - Uses color (48, 116, 143) for its output port
+ - Displays "exp" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import traceback
+from OdenGraphQt import BaseNode
+from Modules import data_manager # Importing data_manager from Modules
+
+class FlyffEXPCurrentNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_exp_current_node'
+ NODE_NAME = 'Flyff - EXP'
+
+ def __init__(self):
+ super(FlyffEXPCurrentNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(48, 116, 143))
+
+ self.set_name("Flyff - EXP")
+
+ def process_input(self):
+ try:
+ new_value = data_manager.get_data().get("exp", "N/A")
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+ except Exception as e:
+ tb = traceback.format_exc()
+ print(f"[ERROR] Exception in FlyffEXPCurrentNode: {e}\nTraceback:\n{tb}")
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
diff --git a/Data/Nodes/Flyff/flyff_FP_current.py b/Data/Nodes/Flyff/flyff_FP_current.py
new file mode 100644
index 0000000..5e902c5
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_FP_current.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Flyff FP Current Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "fp_current" value as a string
+ - Uses color (36, 116, 32) for its output port
+ - Displays "fp_current" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffFPCurrentNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_fp_current_node'
+ NODE_NAME = 'Flyff - FP Current'
+
+ def __init__(self):
+ super(FlyffFPCurrentNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(36, 116, 32))
+
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ self.set_name("Flyff - FP Current (API Disconnected)")
+
+ def process_input(self):
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffFPCurrentNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ if isinstance(data, list):
+ data = {}
+
+ self._api_down = False
+ self.set_name("Flyff - FP Current (API Connected)")
+
+ new_value = data.get("fp_current", "N/A")
+ print(f"[DEBUG] FlyffFPCurrentNode: fp_current = {new_value}")
+
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+
+ else:
+ self._handle_api_error(f"HTTP {status_code} from FlyffFPCurrentNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffFPCurrentNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - FP Current (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_FP_total.py b/Data/Nodes/Flyff/flyff_FP_total.py
new file mode 100644
index 0000000..61a0aa5
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_FP_total.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Flyff FP Total Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "fp_total" value as a string
+ - Uses color (36, 116, 32) for its output port
+ - Displays "fp_total" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffFPTotalNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_fp_total_node'
+ NODE_NAME = 'Flyff - FP Total'
+
+ def __init__(self):
+ super(FlyffFPTotalNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(36, 116, 32))
+
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ self.set_name("Flyff - FP Total (API Disconnected)")
+
+ def process_input(self):
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffFPTotalNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ if isinstance(data, list):
+ data = {}
+
+ self._api_down = False
+ self.set_name("Flyff - FP Total (API Connected)")
+
+ new_value = data.get("fp_total", "N/A")
+ print(f"[DEBUG] FlyffFPTotalNode: fp_total = {new_value}")
+
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+
+ else:
+ self._handle_api_error(f"HTTP {status_code} from FlyffFPTotalNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffFPTotalNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - FP Total (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_HP_current.py b/Data/Nodes/Flyff/flyff_HP_current.py
new file mode 100644
index 0000000..5075d4d
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_HP_current.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""
+Flyff HP Current Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "hp_current" value as a string
+ - Uses color (126, 36, 57) for its output port
+ - Displays "hp_current" in a text field labeled "Value"
+ - Avoids "list indices must be integers" by retrieving the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffHPCurrentNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_hp_current_node'
+ NODE_NAME = 'Flyff - HP Current'
+
+ def __init__(self):
+ super(FlyffHPCurrentNode, self).__init__()
+
+ # 1) Add a text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Add an output port also named "value"
+ self.add_output('value', color=(126, 36, 57))
+
+ # Start in "disconnected" state
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ # Default node title
+ self.set_name("Flyff - HP Current (API Disconnected)")
+
+ def process_input(self):
+ """
+ Called periodically by the global timer in borealis.py
+ """
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffHPCurrentNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ # Attempt to parse JSON
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ # If data is a list, ignore or convert to {}
+ if isinstance(data, list):
+ data = {}
+
+ # Mark node as connected
+ self._api_down = False
+ self.set_name("Flyff - HP Current (API Connected)")
+
+ # Retrieve hp_current (default "N/A" if missing)
+ new_value = data.get("hp_current", "N/A")
+ print(f"[DEBUG] FlyffHPCurrentNode: hp_current = {new_value}")
+
+ # Convert to string
+ new_value_str = str(new_value)
+
+ # 3) Update the text input property so the user sees it
+ self.set_property('value', new_value_str)
+
+ # 4) Transmit to downstream nodes
+ self.transmit_data(new_value_str)
+
+ else:
+ # Non-200 => disconnected
+ self._handle_api_error(f"HTTP {status_code} from FlyffHPCurrentNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffHPCurrentNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ """
+ Sends 'data' to any connected node via the "value" port.
+ (Uses self.outputs().get('value') instead of self.output('value'))
+ """
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - HP Current (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_HP_total.py b/Data/Nodes/Flyff/flyff_HP_total.py
new file mode 100644
index 0000000..fc01a48
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_HP_total.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Flyff HP Total Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "hp_total" value as a string
+ - Uses color (126, 36, 57) for its output port
+ - Displays "hp_total" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffHPTotalNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_hp_total_node'
+ NODE_NAME = 'Flyff - HP Total'
+
+ def __init__(self):
+ super(FlyffHPTotalNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(126, 36, 57))
+
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ self.set_name("Flyff - HP Total (API Disconnected)")
+
+ def process_input(self):
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffHPTotalNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ if isinstance(data, list):
+ data = {}
+
+ self._api_down = False
+ self.set_name("Flyff - HP Total (API Connected)")
+
+ new_value = data.get("hp_total", "N/A")
+ print(f"[DEBUG] FlyffHPTotalNode: hp_total = {new_value}")
+
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+
+ else:
+ self._handle_api_error(f"HTTP {status_code} from FlyffHPTotalNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffHPTotalNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - HP Total (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_MP_current.py b/Data/Nodes/Flyff/flyff_MP_current.py
new file mode 100644
index 0000000..bf74f05
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_MP_current.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Flyff MP Current Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "mp_current" value as a string
+ - Uses color (35, 89, 144) for its output port
+ - Displays "mp_current" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffMPCurrentNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_mp_current_node'
+ NODE_NAME = 'Flyff - MP Current'
+
+ def __init__(self):
+ super(FlyffMPCurrentNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(35, 89, 144))
+
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ self.set_name("Flyff - MP Current (API Disconnected)")
+
+ def process_input(self):
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffMPCurrentNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ if isinstance(data, list):
+ data = {}
+
+ self._api_down = False
+ self.set_name("Flyff - MP Current (API Connected)")
+
+ new_value = data.get("mp_current", "N/A")
+ print(f"[DEBUG] FlyffMPCurrentNode: mp_current = {new_value}")
+
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+
+ else:
+ self._handle_api_error(f"HTTP {status_code} from FlyffMPCurrentNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffMPCurrentNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - MP Current (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_MP_total.py b/Data/Nodes/Flyff/flyff_MP_total.py
new file mode 100644
index 0000000..1f1cdcd
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_MP_total.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Flyff MP Total Node (Final Combined Version)
+ - Polls the API at http://127.0.0.1:5000/data
+ - Outputs only the "mp_total" value as a string
+ - Uses color (35, 89, 144) for its output port
+ - Displays "mp_total" in a text field labeled "Value"
+ - Retrieves the port with self.outputs().get('value')
+"""
+
+import time
+import requests
+import traceback
+from OdenGraphQt import BaseNode
+
+class FlyffMPTotalNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_mp_total_node'
+ NODE_NAME = 'Flyff - MP Total'
+
+ def __init__(self):
+ super(FlyffMPTotalNode, self).__init__()
+
+ # 1) Text input property named "value" for UI display
+ self.add_text_input('value', 'Value', text='N/A')
+
+ # 2) Output port also named "value"
+ self.add_output('value', color=(35, 89, 144))
+
+ self._api_down = True
+ self._last_api_attempt = 0.0
+ self._retry_interval = 5.0
+ self._last_error_printed = 0.0
+
+ self.set_name("Flyff - MP Total (API Disconnected)")
+
+ def process_input(self):
+ current_time = time.time()
+ if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
+ return
+
+ self._last_api_attempt = current_time
+
+ try:
+ response = requests.get("http://127.0.0.1:5000/data", timeout=1)
+ status_code = response.status_code
+ print(f"[DEBUG] FlyffMPTotalNode: HTTP Status Code = {status_code}")
+
+ if status_code == 200:
+ try:
+ data = response.json() or {}
+ except ValueError:
+ data = {}
+
+ if isinstance(data, list):
+ data = {}
+
+ self._api_down = False
+ self.set_name("Flyff - MP Total (API Connected)")
+
+ new_value = data.get("mp_total", "N/A")
+ print(f"[DEBUG] FlyffMPTotalNode: mp_total = {new_value}")
+
+ new_value_str = str(new_value)
+ self.set_property('value', new_value_str)
+ self.transmit_data(new_value_str)
+
+ else:
+ self._handle_api_error(f"HTTP {status_code} from FlyffMPTotalNode")
+ self._api_down = True
+
+ except Exception as e:
+ tb = traceback.format_exc()
+ self._handle_api_error(f"Exception in FlyffMPTotalNode: {e}\nTraceback:\n{tb}")
+ self._api_down = True
+
+ def transmit_data(self, data):
+ output_port = self.outputs().get('value')
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ connected_node.receive_data(data, source_port_name='value')
+ except Exception as e:
+ print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
+
+ def _handle_api_error(self, msg):
+ current_time = time.time()
+ if (current_time - self._last_error_printed) >= self._retry_interval:
+ print(f"[ERROR] {msg}")
+ self._last_error_printed = current_time
+
+ self.set_name("Flyff - MP Total (API Disconnected)")
diff --git a/Data/Nodes/Flyff/flyff_character_status_node.py b/Data/Nodes/Flyff/flyff_character_status_node.py
new file mode 100644
index 0000000..9d5511c
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_character_status_node.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""
+Flyff Character Status Node:
+- Creates an OCR region in data_collector.
+- Periodically captures a screenshot and updates Flask.
+- If OCR is enabled, it extracts character status and updates the data_manager.
+"""
+
+import re
+import base64
+from io import BytesIO
+
+from OdenGraphQt import BaseNode
+from PyQt5.QtWidgets import QMessageBox
+from PyQt5.QtCore import QTimer
+
+# Import the existing modules
+from Modules import data_manager, data_collector
+
+class FlyffCharacterStatusNode(BaseNode):
+ __identifier__ = "bunny-lab.io.flyff_character_status_node"
+ NODE_NAME = "Flyff - Character Status"
+
+ def __init__(self):
+ super(FlyffCharacterStatusNode, self).__init__()
+
+ if data_manager.character_status_collector_exists:
+ QMessageBox.critical(None, "Error", "Only one Flyff Character Status Collector node is allowed.")
+ raise Exception("Duplicate Character Status Node.")
+ data_manager.character_status_collector_exists = True
+
+ # Add the Data Collection dropdown menu
+ self.add_combo_menu("data_collection", "Data Collection", items=["Disabled", "Enabled"])
+ self.set_property("data_collection", "Disabled") # Default to Disabled
+
+ self.add_text_input("hp", "HP", text="HP: 0/0")
+ self.add_text_input("mp", "MP", text="MP: 0/0")
+ self.add_text_input("fp", "FP", text="FP: 0/0")
+ self.add_text_input("exp", "EXP", text="EXP: 0%")
+
+ self.region_id = "character_status"
+ data_collector.create_ocr_region(
+ self.region_id, x=250, y=50, w=180, h=130,
+ color=(255, 255, 0), thickness=2
+ )
+
+ data_collector.start_collector()
+ self.set_name("Flyff - Character Status")
+
+ # Set up a timer to periodically update character stats
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.process_input)
+ self.timer.start(1000) # Update every second
+
+ def parse_character_stats(self, raw_text):
+ """
+ Extract HP, MP, FP, EXP from the raw OCR text lines.
+ """
+ lines = [l.strip() for l in raw_text.splitlines() if l.strip()]
+ hp_current, hp_total = 0, 0
+ mp_current, mp_total = 0, 0
+ fp_current, fp_total = 0, 0
+ exp_value = 0.0
+
+ if len(lines) >= 4:
+ # line 1: HP
+ hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
+ if hp_match:
+ hp_current = int(hp_match.group(1))
+ hp_total = int(hp_match.group(2))
+
+ # line 2: MP
+ mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1])
+ if mp_match:
+ mp_current = int(mp_match.group(1))
+ mp_total = int(mp_match.group(2))
+
+ # line 3: FP
+ fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2])
+ if fp_match:
+ fp_current = int(fp_match.group(1))
+ fp_total = int(fp_match.group(2))
+
+ # line 4: EXP
+ exp_match = re.search(r"(\d+(?:\.\d+)?)", lines[3])
+ if exp_match:
+ val = float(exp_match.group(1))
+ if val < 0: val = 0
+ if val > 100: val = 100
+ exp_value = val
+
+ return hp_current, hp_total, mp_current, mp_total, fp_current, fp_total, exp_value
+
+ def process_input(self):
+ """
+ Called periodically to capture a screenshot and update character status (if enabled).
+ """
+ # Always capture the screenshot, regardless of OCR status
+ screenshot_img = data_collector.capture_region_as_image(self.region_id)
+ if screenshot_img:
+ buf = BytesIO()
+ screenshot_img.save(buf, format='PNG')
+ image_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
+ data_manager.set_status_screenshot(image_b64)
+
+ # If OCR is disabled, return early (skip OCR processing)
+ if self.get_property("data_collection") == "Disabled":
+ return
+
+ # Process OCR if enabled
+ raw_text = data_collector.get_raw_text(self.region_id)
+ hp_c, hp_t, mp_c, mp_t, fp_c, fp_t, exp_v = self.parse_character_stats(raw_text)
+
+ # Update data_manager with parsed values
+ data_manager.set_data_bulk({
+ "hp_current": hp_c,
+ "hp_total": hp_t,
+ "mp_current": mp_c,
+ "mp_total": mp_t,
+ "fp_current": fp_c,
+ "fp_total": fp_t,
+ "exp": exp_v
+ })
+
+ # Update the node's UI text fields
+ self.set_property("hp", f"HP: {hp_c}/{hp_t}")
+ self.set_property("mp", f"MP: {mp_c}/{mp_t}")
+ self.set_property("fp", f"FP: {fp_c}/{fp_t}")
+ self.set_property("exp", f"EXP: {exp_v}%")
diff --git a/Data/Nodes/Flyff/flyff_leveling_predictor_node.py b/Data/Nodes/Flyff/flyff_leveling_predictor_node.py
new file mode 100644
index 0000000..43e56a2
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_leveling_predictor_node.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""
+Flyff - Leveling Predictor Node:
+- Tracks the last N changes in EXP values.
+- Calculates the average change rate and time intervals.
+- Predicts the estimated time to reach level 100.
+"""
+
+import time
+import numpy as np
+from OdenGraphQt import BaseNode
+from PyQt5.QtCore import QTimer
+from Modules import data_manager
+
+class FlyffLevelingPredictorNode(BaseNode):
+ __identifier__ = "bunny-lab.io.flyff_leveling_predictor_node"
+ NODE_NAME = "Flyff - Leveling Predictor"
+
+ def __init__(self):
+ super(FlyffLevelingPredictorNode, self).__init__()
+
+ # Input port for EXP values
+ self.add_input("exp", "EXP")
+
+ # User-defined number of changes to track
+ self.add_text_input("exp_track_count", "# of EXP Changes to Track", text="7")
+
+ # Output widgets
+ self.add_text_input("time_to_level", "Time to Level", text="Calculating...")
+ self.add_text_input("time_between_kills", "Time Between Kills", text="N/A")
+ self.add_text_input("exp_per_kill", "EXP Per Kill", text="N/A")
+
+ # Internal tracking lists
+ self.exp_history = []
+ self.time_intervals = []
+ self.last_exp_value = None
+ self.last_update_time = None
+
+ # Timer to periodically process EXP changes
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.process_exp_change)
+ self.timer.start(1000) # Check for updates every second
+
+ def reset_tracking_arrays(self):
+ """
+ Resets the EXP history and time interval arrays when a level-up is detected.
+ """
+ self.exp_history.clear()
+ self.time_intervals.clear()
+ self.last_exp_value = None
+ self.last_update_time = None
+
+ def process_exp_change(self):
+ """
+ Monitors changes in EXP values and calculates various statistics.
+ """
+ exp_value = data_manager.get_data().get("exp", None)
+ if exp_value is None:
+ return
+
+ exp_track_count = self.get_property("exp_track_count")
+ try:
+ exp_track_count = int(exp_track_count)
+ except ValueError:
+ exp_track_count = 7 # Default to 7 if invalid input
+
+ # Reset if EXP value decreases (indicating a level-up)
+ if self.last_exp_value is not None and exp_value < self.last_exp_value:
+ self.reset_tracking_arrays()
+
+ if self.last_exp_value is not None and exp_value != self.last_exp_value:
+ current_time = time.time()
+
+ # Store EXP change history
+ self.exp_history.append(exp_value)
+ if len(self.exp_history) > exp_track_count:
+ self.exp_history.pop(0)
+
+ # Store time intervals
+ if self.last_update_time is not None:
+ interval = current_time - self.last_update_time
+ self.time_intervals.append(interval)
+ if len(self.time_intervals) > exp_track_count:
+ self.time_intervals.pop(0)
+
+ # Perform calculations
+ self.calculate_time_to_level()
+ self.calculate_additional_metrics()
+
+ # Update last tracking values
+ self.last_update_time = current_time
+
+ self.last_exp_value = exp_value
+
+ def calculate_time_to_level(self):
+ """
+ Calculates the estimated time to reach level 100 based on EXP change history.
+ """
+ if len(self.exp_history) < 2 or len(self.time_intervals) < 1:
+ self.set_property("time_to_level", "Insufficient data")
+ return
+
+ exp_deltas = np.diff(self.exp_history) # Compute EXP change per interval
+ avg_exp_change = np.mean(exp_deltas) if len(exp_deltas) > 0 else 0
+ avg_time_change = np.mean(self.time_intervals)
+
+ if avg_exp_change <= 0:
+ self.set_property("time_to_level", "Not gaining EXP")
+ return
+
+ current_exp = self.exp_history[-1]
+ remaining_exp = 100.0 - current_exp # Distance to level 100
+
+ estimated_time = (remaining_exp / avg_exp_change) * avg_time_change
+
+ # Convert estimated time into hours, minutes, and seconds
+ hours = int(estimated_time // 3600)
+ minutes = int((estimated_time % 3600) // 60)
+ seconds = int(estimated_time % 60)
+
+ time_str = f"{hours}h {minutes}m {seconds}s"
+ self.set_property("time_to_level", time_str)
+
+ def calculate_additional_metrics(self):
+ """
+ Calculates and updates the "Time Between Kills" and "EXP Per Kill".
+ """
+ if len(self.time_intervals) > 0:
+ avg_time_between_kills = np.mean(self.time_intervals)
+ minutes = int(avg_time_between_kills // 60)
+ seconds = int(avg_time_between_kills % 60)
+ self.set_property("time_between_kills", f"{minutes}m {seconds}s")
+ else:
+ self.set_property("time_between_kills", "N/A")
+
+ if len(self.exp_history) > 1:
+ exp_deltas = np.diff(self.exp_history)
+ avg_exp_per_kill = np.mean(exp_deltas) if len(exp_deltas) > 0 else 0
+ self.set_property("exp_per_kill", f"{avg_exp_per_kill:.2f}%")
+ else:
+ self.set_property("exp_per_kill", "N/A")
diff --git a/Data/Nodes/Flyff/flyff_low_health_alert_node.py b/Data/Nodes/Flyff/flyff_low_health_alert_node.py
new file mode 100644
index 0000000..f1006a7
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_low_health_alert_node.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+"""
+Standardized Flyff Low Health Alert Node:
+ - Monitors an input value (1 = health alert, 0 = normal).
+ - Displays a visual alert and plays a sound if enabled.
+ - Uses a global update timer for processing.
+ - Automatically processes float, int, and string values.
+"""
+
+import time
+from OdenGraphQt import BaseNode
+from Qt import QtCore, QtWidgets, QtGui
+
+try:
+ import winsound
+ HAS_WINSOUND = True
+except ImportError:
+ winsound = None
+ HAS_WINSOUND = False
+
+class OverlayCanvas(QtWidgets.QWidget):
+ """
+ UI overlay for displaying a red warning box, which can be repositioned by dragging.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ screen_geo = QtWidgets.QApplication.primaryScreen().geometry()
+ self.setGeometry(screen_geo)
+ self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint)
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+ self.setVisible(False)
+ self.helper_LowHealthAlert = QtCore.QRect(250, 300, 900, 35)
+ self.dragging = False
+ self.drag_offset = None
+
+ def paintEvent(self, event):
+ if not self.isVisible():
+ return
+ painter = QtGui.QPainter(self)
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(QtGui.QColor(255, 0, 0))
+ painter.drawRect(self.helper_LowHealthAlert)
+ font = QtGui.QFont("Arial", 14, QtGui.QFont.Bold)
+ painter.setFont(font)
+ painter.setPen(QtGui.QColor(255, 255, 255))
+ text_x = self.helper_LowHealthAlert.center().x() - 50
+ text_y = self.helper_LowHealthAlert.center().y() + 5
+ painter.drawText(text_x, text_y, "LOW HEALTH")
+
+ def toggle_alert(self, state):
+ self.setVisible(state == 1)
+ self.update()
+
+ def mousePressEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ if self.helper_LowHealthAlert.contains(event.pos()):
+ self.dragging = True
+ self.drag_offset = event.pos() - self.helper_LowHealthAlert.topLeft()
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ if self.dragging:
+ new_top_left = event.pos() - self.drag_offset
+ self.helper_LowHealthAlert.moveTo(new_top_left)
+ self.update()
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self.dragging = False
+ super().mouseReleaseEvent(event)
+
+class FlyffLowHealthAlertNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.flyff_low_health_alert_node'
+ NODE_NAME = 'Flyff - Low Health Alert'
+
+ overlay_instance = None
+ last_beep_time = 0
+ BEEP_INTERVAL_SECONDS = 2
+
+ def __init__(self):
+ super(FlyffLowHealthAlertNode, self).__init__()
+ self.add_checkbox('cb_1', '', 'Sound Alert', True)
+ self.add_checkbox('cb_2', '', 'Visual Alert', True)
+ self.add_input('Toggle (1 = On | 0 = Off)', color=(200, 100, 0))
+ self.add_text_input('value', 'Current Value', text='0')
+ self.add_combo_menu('beep_interval', 'Beep Interval', items=["0.5s", "1.0s", "2.0s"])
+
+ if not FlyffLowHealthAlertNode.overlay_instance:
+ FlyffLowHealthAlertNode.overlay_instance = OverlayCanvas()
+ FlyffLowHealthAlertNode.overlay_instance.show()
+
+ def process_input(self):
+ input_port = self.input(0)
+ value = input_port.connected_ports()[0].node().get_property('value') if input_port.connected_ports() else "0"
+ self.receive_data(value)
+
+ def receive_data(self, data, source_port_name=None):
+ try:
+ if isinstance(data, str):
+ data = float(data) if '.' in data else int(data)
+ if isinstance(data, (float, int)):
+ data = 1 if data > 1 else 0 if data <= 0 else int(data)
+ else:
+ data = 0
+ except ValueError:
+ data = 0
+
+ self.set_property('value', str(data))
+ if self.get_property('cb_2'):
+ FlyffLowHealthAlertNode.overlay_instance.toggle_alert(data)
+ self.handle_beep(data)
+
+ def handle_beep(self, input_value):
+ # Update beep interval from the dropdown property
+ interval_str = self.get_property('beep_interval')
+ if interval_str.endswith("s"):
+ interval_seconds = float(interval_str[:-1])
+ else:
+ interval_seconds = float(interval_str)
+ self.BEEP_INTERVAL_SECONDS = interval_seconds
+
+ if input_value == 1 and self.get_property('cb_1'):
+ current_time = time.time()
+ if (current_time - FlyffLowHealthAlertNode.last_beep_time) >= self.BEEP_INTERVAL_SECONDS:
+ FlyffLowHealthAlertNode.last_beep_time = current_time
+ self.play_beep()
+
+ def play_beep(self):
+ if HAS_WINSOUND:
+ winsound.Beep(376, 100)
+ else:
+ print('\a', end='')
diff --git a/Data/Nodes/Flyff/flyff_mob_identification_overlay.py b/Data/Nodes/Flyff/flyff_mob_identification_overlay.py
new file mode 100644
index 0000000..b8df463
--- /dev/null
+++ b/Data/Nodes/Flyff/flyff_mob_identification_overlay.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""
+Identification Overlay Node:
+- Users can configure threads/slices for parallel processing.
+"""
+
+import re
+from OdenGraphQt import BaseNode
+from PyQt5.QtCore import QTimer
+from PyQt5.QtGui import QColor
+from Modules import data_collector
+
+
+class IdentificationOverlayNode(BaseNode):
+ __identifier__ = "bunny-lab.io.identification_overlay_node"
+ NODE_NAME = "Identification Overlay"
+
+ def __init__(self):
+ super(IdentificationOverlayNode, self).__init__()
+
+ # User-configurable options
+ self.add_text_input("search_term", "Search Term", text="Aibatt")
+ self.add_text_input("offset_value", "Offset Value (X,Y)", text="0,0") # X,Y Offset
+ self.add_text_input("margin", "Margin", text="5") # Box Margin
+ self.add_text_input("polling_freq", "Polling Frequency (ms)", text="500") # Polling Rate
+ self.add_combo_menu("ocr_engine", "Type", items=["CPU", "GPU"])
+ self.set_property("ocr_engine", "CPU") # Default to CPU mode
+
+ # Custom overlay options
+ self.add_text_input("overlay_color", "Overlay Color (RGB)", text="0,0,255") # Default blue
+ self.add_text_input("thickness", "Line Thickness", text="2") # Default 2px
+ self.add_text_input("threads_slices", "Threads / Slices", text="8") # Default 8 threads/slices
+
+ self.region_id = "identification_overlay"
+ data_collector.create_ocr_region(self.region_id, x=250, y=50, w=300, h=200, color=(0, 0, 255), thickness=2)
+
+ data_collector.start_collector()
+ self.set_name("Identification Overlay")
+
+ # Timer for updating overlays
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.update_overlay)
+
+ # Set initial polling frequency
+ self.update_polling_frequency()
+
+ def update_polling_frequency(self):
+ polling_text = self.get_property("polling_freq")
+ try:
+ polling_interval = max(50, int(polling_text))
+ except ValueError:
+ polling_interval = 500
+
+ self.timer.start(polling_interval)
+
+ def update_overlay(self):
+ search_term = self.get_property("search_term")
+ offset_text = self.get_property("offset_value")
+ margin_text = self.get_property("margin")
+ ocr_engine = self.get_property("ocr_engine")
+ threads_slices_text = self.get_property("threads_slices")
+
+ self.update_polling_frequency()
+
+ try:
+ offset_x, offset_y = map(int, offset_text.split(","))
+ except ValueError:
+ offset_x, offset_y = 0, 0
+
+ try:
+ margin = int(margin_text)
+ except ValueError:
+ margin = 5
+
+ color_text = self.get_property("overlay_color")
+ try:
+ color = tuple(map(int, color_text.split(",")))
+ except ValueError:
+ color = (0, 0, 255)
+
+ thickness_text = self.get_property("thickness")
+ try:
+ thickness = max(1, int(thickness_text))
+ except ValueError:
+ thickness = 2
+
+ try:
+ num_slices = max(1, int(threads_slices_text)) # Ensure at least 1 slice
+ except ValueError:
+ num_slices = 1
+
+ if not search_term:
+ return
+
+ detected_positions = data_collector.find_word_positions(
+ self.region_id, search_term, offset_x, offset_y, margin, ocr_engine, num_slices
+ )
+
+ # Ensure slice count is updated visually in the region widget
+ data_collector.update_region_slices(self.region_id, num_slices)
+
+ data_collector.draw_identification_boxes(self.region_id, detected_positions, color=color, thickness=thickness)
+
diff --git a/Data/Nodes/General Purpose/array_node.py b/Data/Nodes/General Purpose/array_node.py
new file mode 100644
index 0000000..8f4e09e
--- /dev/null
+++ b/Data/Nodes/General Purpose/array_node.py
@@ -0,0 +1,49 @@
+from OdenGraphQt import BaseNode
+
+class ArrayNode(BaseNode):
+ """
+ Array Node:
+ - Inputs: 'in' (value to store), 'ArraySize' (defines maximum length)
+ - Output: 'Array' (the current array as a string)
+ - Stores incoming values in an array with a size defined by ArraySize.
+ - Updates are now handled via a global update timer.
+ """
+ __identifier__ = 'bunny-lab.io.array_node'
+ NODE_NAME = 'Array'
+
+ def __init__(self):
+ super(ArrayNode, self).__init__()
+ self.values = {} # Ensure values is a dictionary.
+ self.add_input('in')
+ self.add_input('ArraySize')
+ self.add_output('Array')
+ self.array = []
+ self.value = "[]" # Output as a string.
+ self.array_size = 10 # Default array size.
+ self.set_name("Array: []")
+
+ def process_input(self):
+ # Get array size from 'ArraySize' input if available.
+ size_port = self.input('ArraySize')
+ connected_size = size_port.connected_ports() if size_port is not None else []
+ if connected_size:
+ connected_port = connected_size[0]
+ parent_node = connected_port.node()
+ try:
+ self.array_size = int(float(getattr(parent_node, 'value', 10)))
+ except (ValueError, TypeError):
+ self.array_size = 10
+
+ # Get new value from 'in' input if available.
+ in_port = self.input('in')
+ connected_in = in_port.connected_ports() if in_port is not None else []
+ if connected_in:
+ connected_port = connected_in[0]
+ parent_node = connected_port.node()
+ new_value = getattr(parent_node, 'value', None)
+ if new_value is not None:
+ self.array.append(new_value)
+ while len(self.array) > self.array_size:
+ self.array.pop(0)
+ self.value = str(self.array)
+ self.set_name(f"Array: {self.value}")
diff --git a/Data/Nodes/General Purpose/comparison_node.py b/Data/Nodes/General Purpose/comparison_node.py
new file mode 100644
index 0000000..34ad35a
--- /dev/null
+++ b/Data/Nodes/General Purpose/comparison_node.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+
+"""
+Standardized Comparison Node:
+ - Compares two input values using a selected operator (==, !=, >, <, >=, <=).
+ - Outputs a result of 1 (True) or 0 (False).
+ - Uses a global update timer for processing.
+ - Supports an additional 'Input Type' dropdown to choose between 'Number' and 'String'.
+"""
+
+from OdenGraphQt import BaseNode
+from Qt import QtCore
+
+class ComparisonNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.comparison_node'
+ NODE_NAME = 'Comparison Node'
+
+ def __init__(self):
+ super(ComparisonNode, self).__init__()
+ self.add_input('A')
+ self.add_input('B')
+ self.add_output('Result')
+
+ # Add the Input Type dropdown first.
+ self.add_combo_menu('input_type', 'Input Type', items=['Number', 'String'])
+ self.add_combo_menu('operator', 'Operator', items=[
+ 'Equal (==)', 'Not Equal (!=)', 'Greater Than (>)',
+ 'Less Than (<)', 'Greater Than or Equal (>=)', 'Less Than or Equal (<=)'
+ ])
+ # Replace calc_result with a standardized "value" text input.
+ self.add_text_input('value', 'Value', text='0')
+ self.value = 0
+ self.set_name("Comparison Node")
+ self.processing = False # Guard for process_input
+
+ # Set default properties explicitly
+ self.set_property('input_type', 'Number')
+ self.set_property('operator', 'Equal (==)')
+
+ def process_input(self):
+ if self.processing:
+ return
+ self.processing = True
+
+ # Retrieve input values; if no connection or None, default to "0"
+ input_a = self.input(0)
+ input_b = self.input(1)
+ a_raw = (input_a.connected_ports()[0].node().get_property('value')
+ if input_a.connected_ports() else "0")
+ b_raw = (input_b.connected_ports()[0].node().get_property('value')
+ if input_b.connected_ports() else "0")
+ a_raw = a_raw if a_raw is not None else "0"
+ b_raw = b_raw if b_raw is not None else "0"
+
+ # Get input type property
+ input_type = self.get_property('input_type')
+
+ # Convert values based on input type
+ if input_type == 'Number':
+ try:
+ a_val = float(a_raw)
+ except (ValueError, TypeError):
+ a_val = 0.0
+ try:
+ b_val = float(b_raw)
+ except (ValueError, TypeError):
+ b_val = 0.0
+ elif input_type == 'String':
+ a_val = str(a_raw)
+ b_val = str(b_raw)
+ else:
+ try:
+ a_val = float(a_raw)
+ except (ValueError, TypeError):
+ a_val = 0.0
+ try:
+ b_val = float(b_raw)
+ except (ValueError, TypeError):
+ b_val = 0.0
+
+ operator = self.get_property('operator')
+
+ # Perform the comparison
+ result = {
+ 'Equal (==)': a_val == b_val,
+ 'Not Equal (!=)': a_val != b_val,
+ 'Greater Than (>)': a_val > b_val,
+ 'Less Than (<)': a_val < b_val,
+ 'Greater Than or Equal (>=)': a_val >= b_val,
+ 'Less Than or Equal (<=)': a_val <= b_val
+ }.get(operator, False)
+
+ new_value = 1 if result else 0
+ self.value = new_value
+ self.set_property('value', str(self.value))
+ self.transmit_data(self.value)
+
+ self.processing = False
+
+ def on_input_connected(self, input_port, output_port):
+ pass
+
+ def on_input_disconnected(self, input_port, output_port):
+ pass
+
+ def property_changed(self, property_name):
+ pass
+
+ def receive_data(self, data, source_port_name=None):
+ pass
+
+ def transmit_data(self, data):
+ output_port = self.output(0)
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ data_int = int(data)
+ connected_node.receive_data(data_int, source_port_name='Result')
+ except ValueError:
+ pass
diff --git a/Data/Nodes/General Purpose/data_node.py b/Data/Nodes/General Purpose/data_node.py
new file mode 100644
index 0000000..6803833
--- /dev/null
+++ b/Data/Nodes/General Purpose/data_node.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+"""
+Standardized Data Node:
+ - Accepts and transmits values consistently.
+ - Updates its value based on a global update timer.
+"""
+
+from OdenGraphQt import BaseNode
+from Qt import QtCore
+
+class DataNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.data_node'
+ NODE_NAME = 'Data Node'
+
+ def __init__(self):
+ super(DataNode, self).__init__()
+ self.add_input('Input')
+ self.add_output('Output')
+ self.add_text_input('value', 'Value', text='')
+ self.process_widget_event()
+ self.set_name("Data Node")
+ # Removed self-contained update timer; global timer now drives updates.
+
+ def post_create(self):
+ text_widget = self.get_widget('value')
+ if text_widget is not None:
+ try:
+ # Removed textChanged signal connection; global timer will call process_input.
+ pass
+ except Exception as e:
+ print("Error connecting textChanged signal:", e)
+
+ def process_widget_event(self, event=None):
+ current_text = self.get_property('value')
+ self.value = current_text
+ self.transmit_data(current_text)
+
+ def property_changed(self, property_name):
+ if property_name == 'value':
+ # Immediate update removed; relying on global timer.
+ pass
+
+ def process_input(self):
+ input_port = self.input(0)
+ output_port = self.output(0)
+ if input_port.connected_ports():
+ input_value = input_port.connected_ports()[0].node().get_property('value')
+ self.set_property('value', input_value)
+ self.transmit_data(input_value)
+ elif output_port.connected_ports():
+ self.transmit_data(self.get_property('value'))
+
+ def on_input_connected(self, input_port, output_port):
+ # Removed immediate update; global timer handles updates.
+ pass
+
+ def on_input_disconnected(self, input_port, output_port):
+ # Removed immediate update; global timer handles updates.
+ pass
+
+ def receive_data(self, data, source_port_name=None):
+ self.set_property('value', str(data))
+ self.transmit_data(data)
+
+ def transmit_data(self, data):
+ output_port = self.output(0)
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ connected_node.receive_data(data, source_port_name="Output")
diff --git a/Data/Nodes/General Purpose/math_operation_node.py b/Data/Nodes/General Purpose/math_operation_node.py
new file mode 100644
index 0000000..1aea0fa
--- /dev/null
+++ b/Data/Nodes/General Purpose/math_operation_node.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+
+"""
+Standardized Math Operation Node:
+ - Performs mathematical operations (+, -, *, /, avg) on two inputs.
+ - Outputs the computed result.
+ - Uses a global update timer for processing (defined in borealis.py).
+ - Ensures it always has a "value" property that the Comparison Node can read.
+"""
+
+from OdenGraphQt import BaseNode
+from Qt import QtCore
+
+class MathOperationNode(BaseNode):
+ __identifier__ = 'bunny-lab.io.math_node'
+ NODE_NAME = 'Math Operation'
+
+ def __init__(self):
+ super(MathOperationNode, self).__init__()
+ self.add_input('A')
+ self.add_input('B')
+ self.add_output('Result')
+
+ # Drop-down to choose which operation we do:
+ self.add_combo_menu('operator', 'Operator', items=[
+ 'Add', 'Subtract', 'Multiply', 'Divide', 'Average'
+ ])
+
+ # A text field for showing the result to the user:
+ self.add_text_input('calc_result', 'Result', text='0')
+
+ # IMPORTANT: define a "value" property that the Comparison Node can read
+ # We do not necessarily need a text input for it, but adding it ensures
+ # it becomes an official property recognized by OdenGraphQt.
+ self.add_text_input('value', 'Internal Value', text='0')
+
+ # Keep a Python-side float of the current computed result:
+ self.value = 0
+
+ # Give the node a nice name:
+ self.set_name("Math Operation")
+
+ # Removed self-contained timer; global timer calls process_input().
+
+ def process_input(self):
+ # Attempt to read "value" from both inputs:
+ input_a = self.input(0)
+ input_b = self.input(1)
+ a_raw = input_a.connected_ports()[0].node().get_property('value') if input_a.connected_ports() else "0"
+ b_raw = input_b.connected_ports()[0].node().get_property('value') if input_b.connected_ports() else "0"
+
+ try:
+ a_val = float(a_raw)
+ except (ValueError, TypeError):
+ a_val = 0.0
+ try:
+ b_val = float(b_raw)
+ except (ValueError, TypeError):
+ b_val = 0.0
+
+ operator = self.get_property('operator')
+ if operator == 'Add':
+ result = a_val + b_val
+ elif operator == 'Subtract':
+ result = a_val - b_val
+ elif operator == 'Multiply':
+ result = a_val * b_val
+ elif operator == 'Divide':
+ result = a_val / b_val if b_val != 0 else 0.0
+ elif operator == 'Average':
+ result = (a_val + b_val) / 2.0
+ else:
+ result = 0.0
+
+ # If the computed result changed, update our internal properties and transmit
+ if self.value != result:
+ self.value = result
+
+ # Update the two text fields so the user sees the numeric result:
+ self.set_property('calc_result', str(result))
+ self.set_property('value', str(result)) # <= This is the critical step
+
+ # Let downstream nodes know there's new data:
+ self.transmit_data(result)
+
+ def on_input_connected(self, input_port, output_port):
+ pass
+
+ def on_input_disconnected(self, input_port, output_port):
+ pass
+
+ def property_changed(self, property_name):
+ pass
+
+ def receive_data(self, data, source_port_name=None):
+ pass
+
+ def transmit_data(self, data):
+ output_port = self.output(0)
+ if output_port and output_port.connected_ports():
+ for connected_port in output_port.connected_ports():
+ connected_node = connected_port.node()
+ if hasattr(connected_node, 'receive_data'):
+ try:
+ # Attempt to convert to int if possible, else float
+ data_int = int(data)
+ connected_node.receive_data(data_int, source_port_name='Result')
+ except ValueError:
+ connected_node.receive_data(data, source_port_name='Result')
diff --git a/Data/Nodes/Organization/backdrop_node.py b/Data/Nodes/Organization/backdrop_node.py
new file mode 100644
index 0000000..e2167f3
--- /dev/null
+++ b/Data/Nodes/Organization/backdrop_node.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+
+from Qt import QtWidgets, QtGui, QtCore
+from OdenGraphQt import BaseNode
+from OdenGraphQt.constants import NodePropWidgetEnum
+from OdenGraphQt.qgraphics.node_backdrop import BackdropNodeItem
+
+class BackdropNode(BaseNode):
+ """
+ Backdrop Node:
+ - Allows grouping or annotating other nodes by resizing a large rectangle.
+ - Title is set by double-clicking in the title area.
+ """
+
+ __identifier__ = 'bunny-lab.io.backdrop'
+ NODE_NAME = 'Backdrop'
+
+ def __init__(self):
+ # Use BackdropNodeItem for the specialized QGraphicsItem.
+ super(BackdropNode, self).__init__(qgraphics_item=BackdropNodeItem)
+
+ # Default color (teal).
+ self.model.color = (5, 129, 138, 255)
+
+ # Set default title without prompting:
+ self.set_name("Double-Click to Add Name to Backdrop")
+
+ # Multi-line text property for storing the backdrop text.
+ self.create_property(
+ 'backdrop_text',
+ '',
+ widget_type=NodePropWidgetEnum.QTEXT_EDIT.value,
+ tab='Backdrop'
+ )
+
+ # Override the view's double-click event to allow editing the title.
+ original_double_click = self.view.mouseDoubleClickEvent
+
+ def new_double_click_event(event):
+ # Assume the title is in the top 30 pixels of the node.
+ if event.pos().y() < 30:
+ new_title, ok = QtWidgets.QInputDialog.getText(
+ None, "Edit Title", "Enter new backdrop title:", text=self.name()
+ )
+ if ok and new_title:
+ self.set_name(new_title)
+ self.view.update() # force immediate update of the node title
+ else:
+ if original_double_click:
+ original_double_click(event)
+
+ self.view.mouseDoubleClickEvent = new_double_click_event
+
+ # --------------------------------------------------------------------------
+ # Resizing / Geometry
+ # --------------------------------------------------------------------------
+ def on_backdrop_updated(self, update_prop, value=None):
+ """
+ Triggered when the user resizes or double-clicks the backdrop sizer handle.
+ """
+ if not self.graph:
+ return
+
+ if update_prop == 'sizer_mouse_release':
+ # User finished dragging the resize handle
+ self.view.prepareGeometryChange()
+ self.graph.begin_undo(f'resized "{self.name()}"')
+ self.set_property('width', value['width'])
+ self.set_property('height', value['height'])
+ self.set_pos(*value['pos'])
+ self.graph.end_undo()
+ self.view.update()
+
+ elif update_prop == 'sizer_double_clicked':
+ # User double-clicked the resize handle (auto-resize)
+ self.view.prepareGeometryChange()
+ self.graph.begin_undo(f'"{self.name()}" auto resize')
+ self.set_property('width', value['width'])
+ self.set_property('height', value['height'])
+ self.set_pos(*value['pos'])
+ self.graph.end_undo()
+ self.view.update()
+
+ def auto_size(self):
+ """
+ Auto-resize the backdrop to fit around intersecting nodes.
+ """
+ if not self.graph:
+ return
+ self.view.prepareGeometryChange()
+ self.graph.begin_undo(f'"{self.name()}" auto resize')
+ size = self.view.calc_backdrop_size()
+ self.set_property('width', size['width'])
+ self.set_property('height', size['height'])
+ self.set_pos(*size['pos'])
+ self.graph.end_undo()
+ self.view.update()
+
+ def wrap_nodes(self, nodes):
+ """
+ Fit the backdrop around the specified nodes.
+ """
+ if not self.graph or not nodes:
+ return
+ self.view.prepareGeometryChange()
+ self.graph.begin_undo(f'"{self.name()}" wrap nodes')
+ size = self.view.calc_backdrop_size([n.view for n in nodes])
+ self.set_property('width', size['width'])
+ self.set_property('height', size['height'])
+ self.set_pos(*size['pos'])
+ self.graph.end_undo()
+ self.view.update()
+
+ def nodes(self):
+ """
+ Return a list of nodes wrapped by this backdrop.
+ """
+ node_ids = [n.id for n in self.view.get_nodes()]
+ return [self.graph.get_node_by_id(nid) for nid in node_ids]
+
+ def set_text(self, text=''):
+ """
+ Set the multi-line text in the backdrop.
+ """
+ self.set_property('backdrop_text', text)
+
+ def text(self):
+ """
+ Return the text content in the backdrop.
+ """
+ return self.get_property('backdrop_text')
+
+ def set_size(self, width, height):
+ """
+ Manually set the backdrop size.
+ """
+ if self.graph:
+ self.view.prepareGeometryChange()
+ self.graph.begin_undo('backdrop size')
+ self.set_property('width', width)
+ self.set_property('height', height)
+ self.graph.end_undo()
+ self.view.update()
+ else:
+ self.view.width, self.view.height = width, height
+ self.model.width, self.model.height = width, height
+
+ def size(self):
+ """
+ Return (width, height) of the backdrop.
+ """
+ self.model.width = self.view.width
+ self.model.height = self.view.height
+ return self.model.width, self.model.height
+
+ # No ports for a backdrop:
+ def inputs(self):
+ return
+
+ def outputs(self):
+ return
diff --git a/Data/Nodes/Reporting/Export_to_CSV.py b/Data/Nodes/Reporting/Export_to_CSV.py
new file mode 100644
index 0000000..89b1985
--- /dev/null
+++ b/Data/Nodes/Reporting/Export_to_CSV.py
@@ -0,0 +1,3 @@
+# HIGH-LEVEL OVERVIEW
+# - This node takes an input source and either replaces or appends data fed into it into a CSV file on disk.
+# - There will be a checkbox to allow the user to change the behavior (Replace / Append)
\ No newline at end of file
diff --git a/Data/Nodes/Reporting/Export_to_Image.py b/Data/Nodes/Reporting/Export_to_Image.py
new file mode 100644
index 0000000..5cec7e8
--- /dev/null
+++ b/Data/Nodes/Reporting/Export_to_Image.py
@@ -0,0 +1,4 @@
+# HIGH-LEVEL OVERVIEW
+# - This node takes an input source and dumps the data to disk in a dropdown menu of various image formats
+# - Ability to view image processing results would be an interesting bonus if displayed within the node.
+# - Could be used to show the life cycle of an image processing pipeline.
\ No newline at end of file
diff --git a/Data/Nodes/__init__.py b/Data/Nodes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Data/WebUI/public/index.html b/Data/WebUI/public/index.html
deleted file mode 100644
index da117a9..0000000
--- a/Data/WebUI/public/index.html
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Borealis
-
-
-
-
-
-
-
diff --git a/Data/WebUI/src/App.js b/Data/WebUI/src/App.js
deleted file mode 100644
index a26f26b..0000000
--- a/Data/WebUI/src/App.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from "react";
-import FlowEditor from "./components/FlowEditor";
-import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
-import {
- AppBar,
- Toolbar,
- Typography,
- Box,
- Menu,
- MenuItem,
- Button,
- CssBaseline,
- ThemeProvider,
- createTheme
-} from "@mui/material";
-
-const darkTheme = createTheme({
- palette: {
- mode: "dark",
- background: {
- default: "#121212",
- paper: "#1e1e1e"
- },
- text: {
- primary: "#ffffff"
- }
- }
-});
-
-export default function App() {
- const [workflowsAnchorEl, setWorkflowsAnchorEl] = React.useState(null);
- const [aboutAnchorEl, setAboutAnchorEl] = React.useState(null);
-
- const handleWorkflowsMenuOpen = (event) => {
- setWorkflowsAnchorEl(event.currentTarget);
- };
-
- const handleAboutMenuOpen = (event) => {
- setAboutAnchorEl(event.currentTarget);
- };
-
- const handleWorkflowsMenuClose = () => {
- setWorkflowsAnchorEl(null);
- };
-
- const handleAboutMenuClose = () => {
- setAboutAnchorEl(null);
- };
-
- return (
-
-
- {/*
- Main container that:
- - fills 100% viewport height
- - organizes content with flexbox (vertical)
- */}
-
- {/* --- TOP BAR --- */}
-
-
-
- Borealis - Workflow Automation Tool
-
-
- {/* Workflows Menu */}
- }
- >
- Workflows
-
-
-
- {/* About Menu */}
- }
- >
- About
-
-
-
-
-
- {/* --- REACT FLOW EDITOR --- */}
- {/*
- flexGrow={1} ⇒ This box expands to fill remaining vertical space
- overflow="hidden" ⇒ No scroll bars, so React Flow does internal panning
- mt: 1 ⇒ Add top margin so the gradient starts closer to the AppBar.
- */}
-
- {
- document.getElementById("nodeCount").innerText = count;
- }}
- />
-
-
- {/* --- STATUS BAR at BOTTOM --- */}
-
- Nodes: 0 | Update Rate: 500ms | Flask API Server:{" "}
-
- http://127.0.0.1:5000/data/api/nodes
-
-
-
-
- );
-}
diff --git a/Data/WebUI/src/components/FlowEditor.css b/Data/WebUI/src/components/FlowEditor.css
deleted file mode 100644
index 4990c4f..0000000
--- a/Data/WebUI/src/components/FlowEditor.css
+++ /dev/null
@@ -1,23 +0,0 @@
-/* FlowEditor background container */
-.flow-editor-container {
- position: relative;
- width: 100vw;
- height: 100vh;
-}
-
- /* Blue Gradient Overlay */
- .flow-editor-container::before {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- pointer-events: none; /* Ensures grid and nodes remain fully interactive */
- background: linear-gradient( to bottom, rgba(9, 44, 68, 0.9) 0%, /* Deep blue at the top */
- rgba(30, 30, 30, 0) 45%, /* Fade out towards center */
- rgba(30, 30, 30, 0) 75%, /* No gradient in the middle */
- rgba(9, 44, 68, 0.7) 100% /* Deep blue at the bottom */
- );
- z-index: -1; /* Ensures it stays behind the React Flow elements */
- }
diff --git a/Data/WebUI/src/components/FlowEditor.jsx b/Data/WebUI/src/components/FlowEditor.jsx
deleted file mode 100644
index 4c72a61..0000000
--- a/Data/WebUI/src/components/FlowEditor.jsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import React, { useState, useEffect, useCallback } from "react";
-import ReactFlow, {
- addEdge,
- Controls,
- Background,
-} from "reactflow";
-import "reactflow/dist/style.css";
-import "./FlowEditor.css";
-
-const fetchNodes = async () => {
- const response = await fetch("/api/workflow");
- return response.json();
-};
-
-const saveWorkflow = async (workflow) => {
- await fetch("/api/workflow", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(workflow),
- });
-};
-
-export default function FlowEditor() {
- const [elements, setElements] = useState([]);
-
- useEffect(() => {
- fetchNodes().then((data) => {
- // Data should contain nodes and edges arrays
- const newElements = [...data.nodes, ...data.edges];
- setElements(newElements);
- });
- }, []);
-
- const onConnect = useCallback(
- (params) => {
- const newEdge = { id: `e${params.source}-${params.target}`, ...params };
- setElements((els) => [...els, newEdge]);
-
- // Separate nodes/edges for saving:
- const nodes = elements.filter((el) => el.type);
- const edges = elements.filter((el) => !el.type);
-
- saveWorkflow({
- nodes,
- edges: [...edges, newEdge],
- });
- },
- [elements]
- );
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/Data/Workflows/Flyff/Flyff - Low Health Alert.json b/Data/Workflows/Flyff/Flyff - Low Health Alert.json
new file mode 100644
index 0000000..19bcc9b
--- /dev/null
+++ b/Data/Workflows/Flyff/Flyff - Low Health Alert.json
@@ -0,0 +1,379 @@
+{
+ "graph":{
+ "layout_direction":0,
+ "acyclic":true,
+ "pipe_collision":false,
+ "pipe_slicing":true,
+ "pipe_style":1,
+ "accept_connection_types":{},
+ "reject_connection_types":{}
+ },
+ "nodes":{
+ "0x2697e9777d0":{
+ "type_":"bunny-lab.io.flyff_character_status_node.FlyffCharacterStatusNode",
+ "icon":null,
+ "name":"Flyff - Character Status",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":278.0,
+ "height":200.20000000000002,
+ "pos":[
+ -162.4474451079301,
+ 412.29351565404465
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "hp":"HP: 0/0",
+ "mp":"MP: 0/0",
+ "fp":"FP: 0/0",
+ "exp":"EXP: 0.0%"
+ }
+ },
+ "0x2697f589250":{
+ "type_":"bunny-lab.io.data_node.DataNode",
+ "icon":null,
+ "name":"Data Node",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":269.0,
+ "height":74.2,
+ "pos":[
+ -46.54926789642434,
+ 276.44565220121416
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"0.40"
+ }
+ },
+ "0x2697eeb2960":{
+ "type_":"bunny-lab.io.math_node.MathOperationNode",
+ "icon":null,
+ "name":"Math Operation",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":269.0,
+ "height":162.4,
+ "pos":[
+ 263.14586137366473,
+ 175.74723593547986
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "operator":"Multiply",
+ "calc_result":"0.0",
+ "value":"0.0"
+ }
+ },
+ "0x2697ea1b560":{
+ "type_":"bunny-lab.io.flyff_hp_current_node.FlyffHPCurrentNode",
+ "icon":null,
+ "name":"Flyff - HP Current (API Connected)",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":378.0,
+ "height":74.2,
+ "pos":[
+ 188.09704170391905,
+ 29.44953683243171
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"0"
+ }
+ },
+ "0x2697f589be0":{
+ "type_":"bunny-lab.io.flyff_hp_total_node.FlyffHPTotalNode",
+ "icon":null,
+ "name":"Flyff - HP Total (API Connected)",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":364.0,
+ "height":74.2,
+ "pos":[
+ -138.69781863016254,
+ 175.74723593547975
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"0"
+ }
+ },
+ "0x2697eb0e8d0":{
+ "type_":"bunny-lab.io.backdrop.BackdropNode",
+ "icon":null,
+ "name":"Calculate 40% of Total HP",
+ "color":[
+ 5,
+ 129,
+ 138,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":728.2402137175101,
+ "height":257.0476243986018,
+ "pos":[
+ -164.34741522615138,
+ 125.39802780261283
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "backdrop_text":""
+ }
+ },
+ "0x2697e856d20":{
+ "type_":"bunny-lab.io.comparison_node.ComparisonNode",
+ "icon":null,
+ "name":"Comparison Node",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":322.0,
+ "height":166.6,
+ "pos":[
+ 625.0901688948422,
+ 218.49656359546154
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "input_type":"Number",
+ "operator":"Less Than or Equal (<=)",
+ "value":"1"
+ }
+ },
+ "0x2697eeb1100":{
+ "type_":"bunny-lab.io.flyff_low_health_alert_node.FlyffLowHealthAlertNode",
+ "icon":null,
+ "name":"Flyff - Low Health Alert",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":324.0,
+ "height":181.3,
+ "pos":[
+ 630.7900792495066,
+ 585.1907964121928
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "cb_1":true,
+ "cb_2":true,
+ "value":"1",
+ "beep_interval":"1.0s"
+ }
+ }
+ },
+ "connections":[
+ {
+ "out":[
+ "0x2697f589250",
+ "Output"
+ ],
+ "in":[
+ "0x2697eeb2960",
+ "B"
+ ]
+ },
+ {
+ "in":[
+ "0x2697eeb2960",
+ "A"
+ ],
+ "out":[
+ "0x2697f589be0",
+ "value"
+ ]
+ },
+ {
+ "out":[
+ "0x2697eeb2960",
+ "Result"
+ ],
+ "in":[
+ "0x2697e856d20",
+ "B"
+ ]
+ },
+ {
+ "out":[
+ "0x2697ea1b560",
+ "value"
+ ],
+ "in":[
+ "0x2697e856d20",
+ "A"
+ ]
+ },
+ {
+ "out":[
+ "0x2697e856d20",
+ "Result"
+ ],
+ "in":[
+ "0x2697eeb1100",
+ "Toggle (1 = On | 0 = Off)"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Data/Workflows/Flyff/Flyff EXP Predictor.json b/Data/Workflows/Flyff/Flyff EXP Predictor.json
new file mode 100644
index 0000000..2f64762
--- /dev/null
+++ b/Data/Workflows/Flyff/Flyff EXP Predictor.json
@@ -0,0 +1,183 @@
+{
+ "graph":{
+ "layout_direction":0,
+ "acyclic":true,
+ "pipe_collision":false,
+ "pipe_slicing":true,
+ "pipe_style":1,
+ "accept_connection_types":{},
+ "reject_connection_types":{}
+ },
+ "nodes":{
+ "0x191410fec90":{
+ "type_":"bunny-lab.io.flyff_character_status_node.FlyffCharacterStatusNode",
+ "icon":null,
+ "name":"Flyff - Character Status",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":278.0,
+ "height":200.20000000000002,
+ "pos":[
+ -234.47843187544638,
+ 171.50740184739476
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "hp":"HP: 5848/5848",
+ "mp":"MP: 955/555",
+ "fp":"FP: 0/0",
+ "exp":"EXP: 49.0%"
+ }
+ },
+ "0x19173496de0":{
+ "type_":"bunny-lab.io.flyff_exp_current_node.FlyffEXPCurrentNode",
+ "icon":null,
+ "name":"Flyff - EXP (API Connected)",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":339.0,
+ "height":74.2,
+ "pos":[
+ -237.34556433027646,
+ 77.62806051403777
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"49.0"
+ }
+ },
+ "0x191735ae690":{
+ "type_":"bunny-lab.io.flyff_leveling_predictor_node.FlyffLevelingPredictorNode",
+ "icon":null,
+ "name":"Flyff - Leveling Predictor",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":324.0,
+ "height":200.20000000000002,
+ "pos":[
+ 170.42482250783007,
+ 77.62806051403777
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "exp_track_count":"7",
+ "time_to_level":"Insufficient data",
+ "time_between_kills":"N/A",
+ "exp_per_kill":"N/A"
+ }
+ },
+ "0x191735ae9c0":{
+ "type_":"bunny-lab.io.backdrop.BackdropNode",
+ "icon":null,
+ "name":"Track EXP Changes Over Time to Predict Leveling Up",
+ "color":[
+ 5,
+ 129,
+ 138,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":777.8842478973615,
+ "height":380.82117975084645,
+ "pos":[
+ -264.113861059255,
+ 23.199190498448075
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "backdrop_text":""
+ }
+ }
+ },
+ "connections":[
+ {
+ "out":[
+ "0x19173496de0",
+ "value"
+ ],
+ "in":[
+ "0x191735ae690",
+ "exp"
+ ]
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/Data/Workflows/Testing/Basic_Data_Node_Connection.json b/Data/Workflows/Testing/Basic_Data_Node_Connection.json
new file mode 100644
index 0000000..902306b
--- /dev/null
+++ b/Data/Workflows/Testing/Basic_Data_Node_Connection.json
@@ -0,0 +1,101 @@
+{
+ "graph":{
+ "layout_direction":0,
+ "acyclic":true,
+ "pipe_collision":false,
+ "pipe_slicing":true,
+ "pipe_style":1,
+ "accept_connection_types":{},
+ "reject_connection_types":{}
+ },
+ "nodes":{
+ "0x1ad82a5c620":{
+ "type_":"bunny-lab.io.data_node.DataNode",
+ "icon":null,
+ "name":"Data Node",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":269.0,
+ "height":74.2,
+ "pos":[
+ -93.6890385514249,
+ 181.13214119942148
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"57"
+ }
+ },
+ "0x1ad82a5cef0":{
+ "type_":"bunny-lab.io.data_node.DataNode",
+ "icon":null,
+ "name":"Data Node 1",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":269.0,
+ "height":74.2,
+ "pos":[
+ 361.37200584121035,
+ 287.313051557703
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "value":"57"
+ }
+ }
+ },
+ "connections":[
+ {
+ "out":[
+ "0x1ad82a5c620",
+ "Output"
+ ],
+ "in":[
+ "0x1ad82a5cef0",
+ "Input"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Data/Workflows/Testing/Identification_Overlay.json b/Data/Workflows/Testing/Identification_Overlay.json
new file mode 100644
index 0000000..834a649
--- /dev/null
+++ b/Data/Workflows/Testing/Identification_Overlay.json
@@ -0,0 +1,57 @@
+{
+ "graph":{
+ "layout_direction":0,
+ "acyclic":true,
+ "pipe_collision":false,
+ "pipe_slicing":true,
+ "pipe_style":1,
+ "accept_connection_types":{},
+ "reject_connection_types":{}
+ },
+ "nodes":{
+ "0x20c129abb30":{
+ "type_":"bunny-lab.io.identification_overlay_node.IdentificationOverlayNode",
+ "icon":null,
+ "name":"Identification Overlay",
+ "color":[
+ 13,
+ 18,
+ 23,
+ 255
+ ],
+ "border_color":[
+ 74,
+ 84,
+ 85,
+ 255
+ ],
+ "text_color":[
+ 255,
+ 255,
+ 255,
+ 180
+ ],
+ "disabled":false,
+ "selected":false,
+ "visible":true,
+ "width":271.0,
+ "height":330.40000000000003,
+ "pos":[
+ 44.64929777820301,
+ 256.49596595988965
+ ],
+ "layout_direction":0,
+ "port_deletion_allowed":false,
+ "subgraph_session":{},
+ "custom":{
+ "search_term":"Aibatt",
+ "offset_value":"-10,-10",
+ "margin":"10",
+ "polling_freq":"50",
+ "ocr_engine":"GPU",
+ "overlay_color":"255,255,255",
+ "thickness":"5"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Data/borealis.py b/Data/borealis.py
new file mode 100644
index 0000000..32d1d6d
--- /dev/null
+++ b/Data/borealis.py
@@ -0,0 +1,440 @@
+# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
+
+import sys
+import pkgutil
+import importlib
+import inspect
+import os
+
+from Qt import QtWidgets, QtCore, QtGui
+
+# -------------------------------------------------------#
+# MONKEY PATCHES - MODIFICATIONS TO OdenGraphQT BEHAVIOR #
+# -------------------------------------------------------#
+
+# PATCH: Override the color of interconnection pipes between nodes
+try:
+ from OdenGraphQt.qgraphics.pipe import PipeItem
+ from OdenGraphQt.qgraphics.node_base import NodeItem
+ from qtpy.QtGui import QPen, QColor
+ from qtpy import QtCore
+
+ # If you want the original paint logic, capture it first:
+ _orig_paint_pipe = PipeItem.paint
+ _orig_paint_node = NodeItem.paint
+
+ # Custom pipe painting function
+ def _custom_paint_pipe(self, painter, option, widget=None):
+ painter.save()
+ my_pen = QPen(QColor(0, 161, 115, 255)) # Match desired RGBA
+ my_pen.setWidthF(2.0)
+ painter.setPen(my_pen)
+ _orig_paint_pipe(self, painter, option, widget)
+ painter.restore()
+
+ # Custom node painting function
+ def _custom_paint_node(self, painter, option, widget=None):
+ painter.save()
+ _orig_paint_node(self, painter, option, widget) # Call original method
+ if self.isSelected():
+ pen = QPen(QColor(0, 161, 115, 255)) # Set selected border color
+ pen.setWidth(3)
+ painter.setPen(pen)
+ painter.drawRect(self.boundingRect())
+ painter.restore()
+
+ # Apply the patches
+ PipeItem.paint = _custom_paint_pipe
+ NodeItem.paint = _custom_paint_node
+
+except ImportError as e:
+ print(f"WARNING: Could not patch PipeItem or NodeItem: {e}")
+except Exception as e:
+ print(f"Patch for PipeItem or NodeItem override failed: {e}")
+
+## PATCH: Fix "module 'qtpy.QtGui' has no attribute 'QUndoStack'" (KEEP AROUND FOR LEGACY DOCUMENTATION)
+#try:
+# from qtpy.QtWidgets import QUndoStack
+# import qtpy
+# qtpy.QtGui.QUndoStack = QUndoStack
+#except ImportError:
+# print("WARNING: Could not monkey-patch QUndoStack.")
+
+# PATCH: Fix "'BackdropNodeItem' object has no attribute 'widgets'" by giving BackdropNodeItem a trivial widgets dictionary.
+try:
+ from OdenGraphQt.nodes.backdrop_node import BackdropNodeItem
+ if not hasattr(BackdropNodeItem, "widgets"):
+ BackdropNodeItem.widgets = {}
+except ImportError:
+ print("WARNING: Could not monkey-patch BackdropNodeItem to add `widgets`.")
+
+# PATCH: BEGIN ROBUST PATCH FOR QGraphicsScene.setSelectionArea
+_original_setSelectionArea = QtWidgets.QGraphicsScene.setSelectionArea
+
+def _patched_setSelectionArea(self, *args, **kwargs):
+ """
+ A robust patch that handles various call signatures for QGraphicsScene.setSelectionArea().
+ """
+ try:
+ return _original_setSelectionArea(self, *args, **kwargs)
+ except TypeError:
+ if not args:
+ raise
+ painterPath = args[0]
+ selection_op = QtCore.Qt.ReplaceSelection
+ selection_mode = QtCore.Qt.IntersectsItemShape
+ transform = QtGui.QTransform()
+ return _original_setSelectionArea(self, painterPath, selection_op, selection_mode, transform)
+
+QtWidgets.QGraphicsScene.setSelectionArea = _patched_setSelectionArea
+
+# ----------------------------------------------------------------------------------------------------- #
+
+# Import data_manager so we can start the Flask server
+from Modules import data_manager
+
+from OdenGraphQt import NodeGraph, BaseNode
+from OdenGraphQt.widgets.dialogs import FileDialog
+
+def import_nodes_from_folder(package_name):
+ """
+ Recursively import all modules from the given package.
+ Returns a dictionary where keys are subfolder names, and values are lists of BaseNode subclasses.
+ """
+ nodes_by_category = {}
+ package = importlib.import_module(package_name)
+ package_path = package.__path__[0]
+
+ for root, _, files in os.walk(package_path):
+ rel_path = os.path.relpath(root, package_path).replace(os.sep, '.')
+ module_prefix = f"{package_name}.{rel_path}" if rel_path != '.' else package_name
+ category_name = os.path.basename(root)
+
+ for file in files:
+ if file.endswith(".py") and file != "__init__.py":
+ module_name = f"{module_prefix}.{file[:-3]}"
+ try:
+ module = importlib.import_module(module_name)
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, BaseNode) and obj.__module__ == module.__name__:
+ if category_name not in nodes_by_category:
+ nodes_by_category[category_name] = []
+ nodes_by_category[category_name].append(obj)
+ except Exception as e:
+ print(f"Failed to import {module_name}: {e}")
+
+ return nodes_by_category
+
+
+def make_node_command(graph, node_type_str):
+ """
+ Return a function that creates a node of the given type at the current cursor position.
+ Ensures that only one FlyffCharacterStatusNode exists.
+ """
+ def real_create():
+ if node_type_str.startswith("bunny-lab.io.flyff_character_status_node"):
+ for node in graph.all_nodes():
+ if node.__class__.__name__ == "FlyffCharacterStatusNode":
+ QtWidgets.QMessageBox.critical(
+ None,
+ "Error",
+ "Only one Flyff Character Status Collector node is allowed."
+ )
+ return
+ try:
+ pos = graph.cursor_pos()
+ graph.create_node(node_type_str, pos=pos)
+ except Exception as e:
+ QtWidgets.QMessageBox.critical(None, "Error", str(e))
+
+ def command():
+ if QtWidgets.QApplication.instance():
+ real_create()
+ else:
+ QtCore.QTimer.singleShot(0, real_create)
+
+ return command
+
+def ensure_workflows_folder():
+ """
+ Ensures a 'Workflows' subfolder exists.
+ """
+ if not os.path.exists("Workflows"):
+ os.makedirs("Workflows")
+
+def close_workflow(graph: NodeGraph):
+ """
+ Closes the current workflow (removes all nodes and connections).
+ """
+ graph.clear_session()
+
+def save_workflow(graph: NodeGraph):
+ """
+ Saves the current workflow (including custom names, positions, wires, etc.) into a JSON file
+ in the 'Workflows' subfolder.
+ """
+ ensure_workflows_folder()
+ file_filter = "JSON Files (*.json);;All Files (*.*)"
+ dlg = FileDialog.getSaveFileName(None, "Save Workflow", os.path.join("Workflows", ""), file_filter)
+ file_path = dlg[0]
+ if not file_path:
+ return # User canceled
+
+ if not file_path.lower().endswith(".json"):
+ file_path += ".json"
+
+ try:
+ graph.save_session(file_path)
+ print(f"Workflow saved to {file_path}")
+ except Exception as e:
+ QtWidgets.QMessageBox.critical(None, "Error Saving Workflow", str(e))
+
+def load_workflow(graph: NodeGraph):
+ """
+ Loads a workflow (including node values, connections, positions, etc.) from a specified JSON file
+ and centers it within the graph.
+ """
+ ensure_workflows_folder()
+ file_filter = "JSON Files (*.json);;All Files (*.*)"
+ dlg = FileDialog.getOpenFileName(None, "Load Workflow", os.path.join("Workflows", ""), file_filter)
+ file_path = dlg[0]
+ if not file_path:
+ return # User canceled
+
+ try:
+ graph.load_session(file_path)
+ print(f"Workflow loaded from {file_path}")
+
+ # Center the workflow within the graph
+ nodes = graph.all_nodes()
+ if nodes:
+ graph.center_on(nodes)
+ else:
+ print("No nodes found in the loaded workflow.")
+
+ except Exception as e:
+ QtWidgets.QMessageBox.critical(None, "Error Loading Workflow", str(e))
+
+if __name__ == "__main__":
+ app = QtWidgets.QApplication([])
+
+ # Start Flask API Server
+ data_manager.start_api_server()
+
+ # Create the NodeGraph
+ graph = NodeGraph()
+ graph.widget.setWindowTitle("Borealis - Workflow Automation Tool")
+
+ # Dynamically import custom node classes from the 'Nodes' package.
+ custom_nodes_by_category = import_nodes_from_folder("Nodes")
+
+ # Register each node in its category
+ for category, node_classes in custom_nodes_by_category.items():
+ for node_class in node_classes:
+ graph.register_node(node_class)
+
+ # Recursively apply the stylesheet to all submenus
+ def apply_styles_to_submenus(menu):
+ """ Recursively applies the stylesheet to all submenus in the menu. """
+ menu.setStyleSheet(menu_stylesheet)
+ for action in menu.actions():
+ if action.menu(): # Check if action has a submenu
+ apply_styles_to_submenus(action.menu())
+
+ # Override the Color of the Context Menu to Blue
+ menu_stylesheet = """
+ QMenu {
+ background-color: rgb(30, 30, 30);
+ border: 1px solid rgba(200, 200, 200, 60);
+ }
+ QMenu::item {
+ padding: 5px 18px 2px;
+ background-color: transparent;
+ }
+ QMenu::item:selected {
+ color: rgb(255, 255, 255);
+ background-color: rgba(60, 120, 180, 150);
+ }
+ QMenu::separator {
+ height: 1px;
+ background: rgba(255, 255, 255, 50);
+ margin: 4px 8px;
+ }
+ """
+
+ # Create categorized context menu
+ graph_context_menu = graph.get_context_menu("graph")
+ add_node_menu = graph_context_menu.add_menu("Add Node")
+
+ for category, node_classes in custom_nodes_by_category.items():
+ category_menu = add_node_menu.add_menu(category) # Create submenu
+ category_menu.qmenu.setStyleSheet(menu_stylesheet) # Apply to submenu
+
+ for node_class in node_classes:
+ node_type = f"{node_class.__identifier__}.{node_class.__name__}"
+ node_name = node_class.NODE_NAME
+ category_menu.add_command(f"{node_name}", make_node_command(graph, node_type))
+
+ # Ensure styles are propagated across all dynamically created submenus
+ apply_styles_to_submenus(graph_context_menu.qmenu)
+
+ # Add a "Remove Selected Node" command
+ graph_context_menu.add_command(
+ "Remove Selected Node",
+ lambda: [graph.remove_node(node) for node in graph.selected_nodes()] if graph.selected_nodes() else None
+ )
+
+ # ------------------------------#
+ # WRAPPER: QMainWindow Integration with Additional UI Elements
+ # ------------------------------#
+ # SECTION: Enhanced Graph Wrapper for QMainWindow
+ # This section wraps the NodeGraph widget in a QMainWindow with:
+ # - A menu bar at the top (named "Workflows" menu)
+ # - A status bar at the bottom
+ # - A central QSplitter dividing the window horizontally:
+ # * Left side (2/3): the NodeGraph widget
+ # * Right side (1/3): an empty text box for future use
+ _original_show = graph.widget.show # Save original method
+
+ def _wrapped_show():
+ """
+ Wrap the NodeGraph widget inside a QMainWindow with a "Workflows" menu,
+ a status bar, and a central splitter for layout.
+ """
+ # Create a new QMainWindow instance
+ main_window = QtWidgets.QMainWindow()
+
+ # Create a menu bar and add a "Workflows" menu
+ menu_bar = main_window.menuBar()
+ workflows_menu = menu_bar.addMenu("Workflows")
+
+ # Add "Open" action
+ open_action = QtWidgets.QAction("Open", main_window)
+ open_action.triggered.connect(lambda: load_workflow(graph))
+ workflows_menu.addAction(open_action)
+
+ # Add "Save" action
+ save_action = QtWidgets.QAction("Save", main_window)
+ save_action.triggered.connect(lambda: save_workflow(graph))
+ workflows_menu.addAction(save_action)
+
+ # Add "Close" action
+ close_action = QtWidgets.QAction("Close", main_window)
+ close_action.triggered.connect(lambda: close_workflow(graph))
+ workflows_menu.addAction(close_action)
+
+ # Create and set a blank status bar at the bottom.
+ main_window.setStatusBar(QtWidgets.QStatusBar())
+
+ # ---------------------------------------------------------------------
+ # SECTION: Status Bar Enhancement - Dynamic Status Display
+ # Add a QLabel to the status bar that shows:
+ # - The number of nodes in the graph.
+ # - A fixed update rate (500ms).
+ # - A clickable hyperlink to the Flask API server.
+ status_bar = main_window.statusBar()
+
+ status_label = QtWidgets.QLabel()
+ status_label.setTextFormat(QtCore.Qt.RichText) # Enable rich text for clickable links.
+ status_label.setStyleSheet("color: white;") # Set default text color to white.
+ status_label.setOpenExternalLinks(True) # Allow hyperlinks to be clickable.
+ status_bar.setSizeGripEnabled(False) # Disable resizing via the size grip.
+ status_bar.addWidget(status_label)
+ status_bar.setStyleSheet("""
+ QStatusBar::item {
+ border: none; /* remove the line around items */
+ }
+ """)
+
+ def update_status():
+ node_count = len(graph.all_nodes())
+ api_link = (
+ ''
+ 'http://127.0.0.1:5000/data'
+ )
+ status_label.setText(
+ f'Nodes: {node_count} | Update Rate: 500ms | Flask API Server: {api_link}'
+ )
+
+ # Create the timer, pass the main_window as parent, and store the reference.
+ status_timer = QtCore.QTimer(main_window)
+ status_timer.timeout.connect(update_status)
+ status_timer.start(500)
+
+ main_window._status_timer = status_timer # Keep a reference so it's not GCed
+ # ---------------------------------------------------------------------
+
+ # Create a QSplitter for horizontal division.
+ splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
+
+ # SECTION: Left Pane - Graph Widget
+ splitter.addWidget(graph.widget)
+
+ # SECTION: Right Pane - Empty Text Box
+ text_edit = QtWidgets.QTextEdit()
+ splitter.addWidget(text_edit)
+
+ # Set stretch factors
+ splitter.setStretchFactor(0, 2) # Split of Left Side
+ splitter.setStretchFactor(1, 3) # Split of Right Side
+
+ # Reduce the Size of the Splitter Handle
+ splitter.setHandleWidth(1)
+ splitter.setStyleSheet("""
+ QSplitter::handle {
+ background: none;
+ }
+ """)
+
+ # Set the splitter as the central widget of the main window.
+ main_window.setCentralWidget(splitter)
+
+ # Transfer the window title from the graph widget to the main window.
+ main_window.setWindowTitle(graph.widget.windowTitle())
+ # Resize the main window using the size set for the graph widget.
+ main_window.resize(graph.widget.size())
+
+ # Store a reference to the main window to prevent it from being garbage collected.
+ graph.widget._main_window = main_window
+ # Show the main window instead of the standalone graph widget.
+ main_window.show()
+
+ # Monkey-patch the show method of the graph widget.
+ graph.widget.show = _wrapped_show
+
+ # Grid styling changes
+ graph.set_background_color(20, 20, 20) # Dark gray
+ graph.set_grid_color(60, 60, 60) # Gray grid lines
+
+ # Add gradient background
+ scene = graph.scene()
+ gradient = QtGui.QLinearGradient(0, 0, 0, 1)
+ gradient.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
+ gradient.setColorAt(0.0, QtGui.QColor(9, 44, 68))
+ gradient.setColorAt(0.3, QtGui.QColor(30, 30, 30))
+ gradient.setColorAt(0.7, QtGui.QColor(30, 30, 30))
+ gradient.setColorAt(1.0, QtGui.QColor(9, 44, 68))
+ scene.setBackgroundBrush(QtGui.QBrush(gradient))
+
+ # Resize and show the graph widget (which now triggers the QMainWindow wrapper)
+ graph.widget.resize(1600, 900)
+ graph.widget.show()
+
+ graph_context_menu.qmenu.setStyleSheet(menu_stylesheet)
+
+ # Global update function
+ def global_update():
+ for node in graph.all_nodes():
+ if hasattr(node, "process_input"):
+ try:
+ node.process_input()
+ except Exception as e:
+ print("Error updating node", node, e)
+
+ timer = QtCore.QTimer()
+ timer.timeout.connect(global_update)
+ timer.start(500)
+
+ sys.exit(app.exec_())
diff --git a/Data/server.py b/Data/server.py
deleted file mode 100644
index 377227e..0000000
--- a/Data/server.py
+++ /dev/null
@@ -1,131 +0,0 @@
-from flask import Flask, send_from_directory, jsonify, request, abort
-import os
-import importlib
-import inspect
-import uuid
-from OdenGraphQt import BaseNode
-
-# Determine the absolute path for the React build folder
-build_folder = os.path.join(os.getcwd(), "web-interface", "build")
-if not os.path.exists(build_folder):
- print("WARNING: web-interface build folder not found. Please build your React app.")
-
-app = Flask(__name__, static_folder=build_folder, static_url_path="/")
-
-# Directory where nodes are stored
-NODES_PACKAGE = "Nodes"
-
-# In-memory workflow storage
-workflow_data = {
- "nodes": [],
- "edges": [] # Store connections separately
-}
-
-def import_nodes_from_folder(package_name):
- """Dynamically import node classes from the given package and list them."""
- nodes_by_category = {}
- package = importlib.import_module(package_name)
- package_path = package.__path__[0]
-
- for root, _, files in os.walk(package_path):
- rel_path = os.path.relpath(root, package_path).replace(os.sep, ".")
- module_prefix = f"{package_name}.{rel_path}" if rel_path != "." else package_name
- category_name = os.path.basename(root)
-
- for file in files:
- if file.endswith(".py") and file != "__init__.py":
- module_name = f"{module_prefix}.{file[:-3]}"
- try:
- module = importlib.import_module(module_name)
- for name, obj in inspect.getmembers(module, inspect.isclass):
- if issubclass(obj, BaseNode) and obj.__module__ == module.__name__:
- if category_name not in nodes_by_category:
- nodes_by_category[category_name] = []
- nodes_by_category[category_name].append(obj.NODE_NAME)
- except Exception as e:
- print(f"Failed to import {module_name}: {e}")
-
- return nodes_by_category
-
-@app.route("/")
-def serve_frontend():
- """Serve the React app."""
- index_path = os.path.join(build_folder, "index.html")
- if os.path.exists(index_path):
- return send_from_directory(app.static_folder, "index.html")
- return "Borealis React App Code Not Found
Please re-deploy Borealis Workflow Automation Tool
", 404
-
-@app.route("/api/nodes", methods=["GET"])
-def get_available_nodes():
- """Return available node types."""
- nodes = import_nodes_from_folder(NODES_PACKAGE)
- return jsonify(nodes)
-
-@app.route("/api/workflow", methods=["GET", "POST"])
-def handle_workflow():
- """Retrieve or update the workflow."""
- global workflow_data
- if request.method == "GET":
- return jsonify(workflow_data)
- elif request.method == "POST":
- data = request.get_json()
- if not data:
- abort(400, "Invalid workflow data")
- workflow_data = data
- return jsonify({"status": "success", "workflow": workflow_data})
-
-@app.route("/api/node", methods=["POST"])
-def create_node():
- """Create a new node with a unique UUID."""
- data = request.get_json()
- if not data or "nodeType" not in data:
- abort(400, "Invalid node data")
-
- node_id = str(uuid.uuid4()) # Generate a unique ID
- node = {
- "id": node_id,
- "type": data["nodeType"],
- "position": data.get("position", {"x": 100, "y": 100}),
- "properties": data.get("properties", {})
- }
- workflow_data["nodes"].append(node)
- return jsonify({"status": "success", "node": node})
-
-@app.route("/api/node/", methods=["PUT", "DELETE"])
-def modify_node(node_id):
- """Update or delete a node."""
- global workflow_data
- if request.method == "PUT":
- data = request.get_json()
- for node in workflow_data["nodes"]:
- if node["id"] == node_id:
- node["position"] = data.get("position", node["position"])
- node["properties"] = data.get("properties", node["properties"])
- return jsonify({"status": "success", "node": node})
- abort(404, "Node not found")
-
- elif request.method == "DELETE":
- workflow_data["nodes"] = [n for n in workflow_data["nodes"] if n["id"] != node_id]
- return jsonify({"status": "success", "deletedNode": node_id})
-
-@app.route("/api/edge", methods=["POST"])
-def create_edge():
- """Create a new connection (edge) between nodes."""
- data = request.get_json()
- if not data or "source" not in data or "target" not in data:
- abort(400, "Invalid edge data")
-
- edge_id = str(uuid.uuid4())
- edge = {"id": edge_id, "source": data["source"], "target": data["target"]}
- workflow_data["edges"].append(edge)
- return jsonify({"status": "success", "edge": edge})
-
-@app.route("/api/edge/", methods=["DELETE"])
-def delete_edge(edge_id):
- """Delete an edge by ID."""
- global workflow_data
- workflow_data["edges"] = [e for e in workflow_data["edges"] if e["id"] != edge_id]
- return jsonify({"status": "success", "deletedEdge": edge_id})
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5000, debug=False)
diff --git a/Launch-Borealis-Legacy.ps1 b/Launch-Borealis-Legacy.ps1
new file mode 100644
index 0000000..372d851
--- /dev/null
+++ b/Launch-Borealis-Legacy.ps1
@@ -0,0 +1,50 @@
+# Bootstrap Borealis Virtual Python Environment
+# Run Script: "Set-ExecutionPolicy Unrestricted -Scope Process .\Start_Windows.ps1"
+
+# Define paths
+$venvPath = "Borealis-Workflow-Automation-Tool"
+$dataSource = "Data"
+$dataDestination = "$venvPath\Borealis"
+
+# Check if virtual environment exists
+if (!(Test-Path "$venvPath\Scripts\Activate")) {
+ Write-Output "Creating virtual environment '$venvPath'..."
+ python -m venv $venvPath
+}
+
+# Ensure the Data folder exists before copying
+if (Test-Path $dataSource) {
+ Write-Output "Copying Data folder into virtual environment..."
+
+ # Remove old data if it exists
+ if (Test-Path $dataDestination) {
+ Remove-Item -Recurse -Force $dataDestination
+ }
+
+ # Create the Borealis directory inside the virtual environment
+ New-Item -Path $dataDestination -ItemType Directory -Force | Out-Null
+
+ # Copy Data into the virtual environment under Borealis
+ Copy-Item -Path "$dataSource\*" -Destination $dataDestination -Recurse
+} else {
+ Write-Output "Warning: Data folder not found, skipping copy."
+}
+
+# Activate virtual environment
+Write-Output "Activating virtual environment..."
+. "$venvPath\Scripts\Activate"
+
+# Install dependencies
+if (Test-Path "requirements.txt") {
+ Write-Output "Installing dependencies..."
+ pip install -q -r requirements.txt
+} else {
+ Write-Output "No requirements.txt found, skipping installation."
+}
+
+# Run the main script from inside the copied Data folder
+Write-Output "Starting Borealis Workflow Automation Tool..."
+python "$dataDestination\borealis.py"
+
+# Deactivate after execution
+deactivate
\ No newline at end of file
diff --git a/Launch-Borealis.ps1 b/Launch-Borealis.ps1
deleted file mode 100644
index 36a0b1b..0000000
--- a/Launch-Borealis.ps1
+++ /dev/null
@@ -1,149 +0,0 @@
-# Start_Windows - WebServer.ps1
-# Run this script with:
-# Set-ExecutionPolicy Unrestricted -Scope Process; .\Start_Windows -WebServer.ps1
-
-# ---------------------- Initialization & Visuals ----------------------
-$symbols = @{
- Success = [char]0x2705
- Running = [char]0x23F3
- Fail = [char]0x274C
- Info = [char]0x2139
-}
-
-function Write-ProgressStep {
- param (
- [string]$Message,
- [string]$Status = $symbols["Info"] # Ensure proper lookup
- )
- Write-Host "`r$Status $Message... " -NoNewline
-}
-
-function Run-Step {
- param (
- [string]$Message,
- [scriptblock]$Script
- )
- Write-ProgressStep -Message $Message -Status "$($symbols.Running)"
- try {
- & $Script
- if ($LASTEXITCODE -eq 0 -or $?) {
- Write-Host "`r$($symbols.Success) $Message " # Fix symbol lookup
- } else {
- throw "Non-zero exit code"
- }
- } catch {
- Write-Host "`r$($symbols.Fail) $Message - Failed: $_ " -ForegroundColor Red
- exit 1
- }
-}
-
-Clear-Host
-Write-Host "Deploying Borealis - Workflow Automation Tool..." -ForegroundColor Green
-Write-Host "===================================================================================="
-
-# ---------------------- Node.js Check ----------------------
-if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
- Write-Host "`r$($symbols.Fail) Node.js is not installed. Please install Node.js and try again." -ForegroundColor Red
- exit 1
-}
-
-# ---------------------- Path Definitions ----------------------
-$venvFolder = "Borealis-Workflow-Automation-Tool"
-$dataSource = "Data"
-$dataDestination = "$venvFolder\Borealis"
-$customUIPath = "$dataSource\WebUI"
-$webUIDestination = "$venvFolder\web-interface"
-
-# ---------------------- Create Python Virtual Environment ----------------------
-Run-Step "Create Virtual Python Environment" {
- if (!(Test-Path "$venvFolder\Scripts\Activate")) {
- python -m venv $venvFolder | Out-Null
- }
-}
-
-# ---------------------- Copy Server Data ----------------------
-Run-Step "Copy Borealis Server Data into Virtual Python Environment" {
- if (Test-Path $dataSource) {
- if (Test-Path $dataDestination) {
- Remove-Item -Recurse -Force $dataDestination | Out-Null
- }
- New-Item -Path $dataDestination -ItemType Directory -Force | Out-Null
- Copy-Item -Path "$dataSource\*" -Destination $dataDestination -Recurse
- } else {
- Write-Host "`r$($symbols.Info) Warning: Data folder not found, skipping copy." -ForegroundColor Yellow
- }
-}
-
-# ---------------------- React UI Deployment ----------------------
-Run-Step "Create a new ReactJS App in $webUIDestination" {
- if (-not (Test-Path $webUIDestination)) {
- npx create-react-app $webUIDestination | Out-Null
- }
-}
-
-Run-Step "Overwrite ReactJS App Files with Borealis ReactJS Files" {
- if (Test-Path $customUIPath) {
- Copy-Item -Path "$customUIPath\*" -Destination $webUIDestination -Recurse -Force
- } else {
- Write-Host "`r$($symbols.Info) No custom UI found, using default React app." -ForegroundColor Yellow
- }
-}
-
-Run-Step "Remove Existing ReactJS Build Folder (If Exists)" {
- if (Test-Path "$webUIDestination\build") {
- Remove-Item -Path "$webUIDestination\build" -Recurse -Force
- }
-}
-
-# ---------------------- Activate Python Virtual Environment ----------------------
-Run-Step "Activate Virtual Python Environment" {
- . "$venvFolder\Scripts\Activate"
-}
-
-# ---------------------- Install Python Dependencies ----------------------
-Run-Step "Install Python Dependencies into Virtual Python Environment" {
- if (Test-Path "requirements.txt") {
- pip install -q -r requirements.txt 2>&1 | Out-Null
- } else {
- Write-Host "`r$($symbols.Info) No requirements.txt found, skipping Python packages." -ForegroundColor Yellow
- }
-}
-
-# ---------------------- Build React App ----------------------
-Run-Step "Install NPM into ReactJS App" {
- $packageJsonPath = Join-Path $webUIDestination "package.json"
- if (Test-Path $packageJsonPath) {
- Push-Location $webUIDestination
- $env:npm_config_loglevel = "silent"
- npm install --silent --no-fund --audit=false 2>&1 | Out-Null
- Pop-Location
- }
-}
-
-Run-Step "Install React Flow into ReactJS App" {
- Push-Location $webUIDestination
- npm install reactflow --no-fund --audit=false | Out-Null
- Pop-Location
-}
-
-Run-Step "Install Material UI Libraries into ReactJS App" {
- Push-Location $webUIDestination
- $env:npm_config_loglevel = "silent" # Force NPM to be completely silent
- npm install --silent @mui/material @mui/icons-material @emotion/react @emotion/styled --no-fund --audit=false 2>&1 | Out-Null
- Pop-Location
-}
-
-Run-Step "Build ReactJS App" {
- Push-Location $webUIDestination
- npm run build | Out-Null
- Pop-Location
-}
-
-# ---------------------- Launch Flask Server ----------------------
-Push-Location $venvFolder
-Write-Host "`nLaunching Borealis..." -ForegroundColor Green
-Write-Host "===================================================================================="
-Write-Host "$($symbols.Running) Starting the Python Flask server..." -NoNewline
-python "Borealis\server.py"
-Write-Host "`r$($symbols.Success) Borealis Launched Successfully!"
-Pop-Location
diff --git a/Launch-Borealis.sh b/Launch-Borealis.sh
deleted file mode 100644
index d6eb4c2..0000000
--- a/Launch-Borealis.sh
+++ /dev/null
@@ -1,185 +0,0 @@
-#!/usr/bin/env bash
-# --------------------------------------------------------------------
-# Deploying Borealis - Workflow Automation Tool
-#
-# This script deploys the Borealis Workflow Automation Tool by:
-# - Detecting the Linux distro and installing required system dependencies.
-# - Creating a Python virtual environment.
-# - Copying server data.
-# - Setting up a React UI application.
-# - Installing Python and Node dependencies.
-# - Building the React app.
-# - Launching the Flask server.
-#
-# Usage:
-# chmod +x deploy_borealis.sh
-# ./deploy_borealis.sh
-# --------------------------------------------------------------------
-
-# ---------------------- Initialization & Visuals ----------------------
-GREEN="\033[0;32m"
-YELLOW="\033[1;33m"
-RED="\033[0;31m"
-RESET="\033[0m"
-CHECKMARK="✅"
-HOURGLASS="⏳"
-CROSSMARK="❌"
-INFO="ℹ️"
-
-# Function to run a step with progress visuals and error checking
-run_step() {
- local message="$1"
- shift
- echo -ne "${HOURGLASS} ${message}... "
- if "$@"; then
- echo -e "\r${CHECKMARK} ${message}"
- else
- echo -e "\r${CROSSMARK} ${message} - Failed${RESET}"
- exit 1
- fi
-}
-
-echo -e "${GREEN}Deploying Borealis - Workflow Automation Tool...${RESET}"
-echo "===================================================================================="
-
-# ---------------------- Detect Linux Distribution ----------------------
-detect_distro() {
- # This function detects the Linux distribution by sourcing /etc/os-release.
- if [ -f /etc/os-release ]; then
- . /etc/os-release
- DISTRO_ID=$ID
- else
- DISTRO_ID="unknown"
- fi
- echo -e "${INFO} Detected OS: ${DISTRO_ID}"
-}
-detect_distro
-
-# ---------------------- Install System Dependencies ----------------------
-install_core_dependencies() {
- # Install required packages based on detected Linux distribution.
- case "$DISTRO_ID" in
- ubuntu|debian)
- sudo apt update -qq
- sudo apt install -y python3 python3-venv python3-pip nodejs npm git curl
- ;;
- rhel|centos|fedora|rocky)
- # For Fedora and similar distributions, the venv module is built-in so we omit python3-venv.
- sudo dnf install -y python3 python3-pip nodejs npm git curl
- ;;
- arch)
- sudo pacman -Sy --noconfirm python python-venv python-pip nodejs npm git curl
- ;;
- *)
- echo -e "${RED}${CROSSMARK} Unsupported Linux distribution: ${DISTRO_ID}${RESET}"
- exit 1
- ;;
- esac
-}
-run_step "Install System Dependencies" install_core_dependencies
-
-# ---------------------- Path Setup ----------------------
-# Variables and path definitions
-venvFolder="Borealis-Workflow-Automation-Tool"
-dataSource="Data"
-dataDestination="${venvFolder}/Borealis"
-customUIPath="${dataSource}/WebUI"
-webUIDestination="${venvFolder}/web-interface"
-
-# ---------------------- Create Python Virtual Environment ----------------------
-run_step "Create Virtual Python Environment" bash -c "
- # Check if virtual environment already exists; if not, create one.
- if [ ! -f '${venvFolder}/bin/activate' ]; then
- python3 -m venv '${venvFolder}'
- fi
-"
-
-# ---------------------- Copy Borealis Data ----------------------
-run_step "Copy Borealis Server Data into Virtual Python Environment" bash -c "
- # If the Data folder exists, remove any existing server data folder and copy fresh data.
- if [ -d \"$dataSource\" ]; then
- rm -rf \"$dataDestination\"
- mkdir -p \"$dataDestination\"
- cp -r \"$dataSource/\"* \"$dataDestination\"
- else
- echo -e \"\r${INFO} Warning: Data folder not found, skipping copy.${RESET}\"
- fi
- true
-"
-
-# ---------------------- React UI Setup ----------------------
-run_step "Create a new ReactJS App in ${webUIDestination}" bash -c "
- # Create a React app if the destination folder does not exist.
- if [ ! -d \"$webUIDestination\" ]; then
- # Set CI=true and add --loglevel=error to suppress funding and audit messages
- CI=true npx create-react-app \"$webUIDestination\" --silent --use-npm --loglevel=error
- fi
-"
-
-run_step "Overwrite React App with Custom Files" bash -c "
- # If custom UI files exist, copy them into the React app folder.
- if [ -d \"$customUIPath\" ]; then
- cp -r \"$customUIPath/\"* \"$webUIDestination\"
- else
- echo -e \"\r${INFO} No custom UI found, using default React app.${RESET}\"
- fi
- true
-"
-
-run_step "Remove Existing React Build (if any)" bash -c "
- # Remove the build folder if it exists to ensure a fresh build.
- if [ -d \"$webUIDestination/build\" ]; then
- rm -rf \"$webUIDestination/build\"
- fi
- true
-"
-
-# ---------------------- Activate Python Virtual Environment ----------------------
-# Activate the Python virtual environment for subsequent commands.
-source "${venvFolder}/bin/activate"
-
-# ---------------------- Install Python Dependencies ----------------------
-run_step "Install Python Dependencies into Virtual Python Environment" bash -c "
- # Install Python packages if a requirements.txt file is present.
- if [ -f \"requirements.txt\" ]; then
- pip install -q -r requirements.txt
- else
- echo -e \"\r${INFO} No requirements.txt found, skipping Python packages.${RESET}\"
- fi
- true
-"
-
-# ---------------------- Install Node Dependencies & Build React UI ----------------------
-run_step "Install React App Dependencies" bash -c "
- # Install npm dependencies if package.json exists.
- if [ -f \"$webUIDestination/package.json\" ]; then
- cd \"$webUIDestination\"
- # Add --loglevel=error to suppress npm's funding and audit messages
- npm install --silent --no-fund --audit=false --loglevel=error
- cd -
- fi
-"
-
-run_step "Install React Flow and UI Libraries" bash -c "
- # Install additional React libraries.
- cd \"$webUIDestination\"
- npm install reactflow --silent --no-fund --audit=false --loglevel=error
- npm install --silent @mui/material @mui/icons-material @emotion/react @emotion/styled --no-fund --audit=false --loglevel=error
- cd -
-"
-
-run_step "Build React App" bash -c "
- # Build the React app to create production-ready files.
- cd \"$webUIDestination\"
- npm run build --silent --loglevel=error
- cd -
-"
-
-# ---------------------- Launch Flask Server ----------------------
-cd "${venvFolder}"
-echo -e "\n${GREEN}Launching Borealis...${RESET}"
-echo "===================================================================================="
-echo -ne "${HOURGLASS} Starting Flask server... "
-python3 Borealis/server.py
-echo -e "\r${CHECKMARK} Borealis Launched Successfully!"
-