diff --git a/backend/ros_template/ros_template/execute_docker.py b/backend/ros_template/ros_template/execute_docker.py index 5fd5f75a1..1f2a0a905 100644 --- a/backend/ros_template/ros_template/execute_docker.py +++ b/backend/ros_template/ros_template/execute_docker.py @@ -1,22 +1,40 @@ +import functools import rclpy +import py_trees from rclpy.node import Node import tree_factory import os +from tree_tools import ascii_bt_to_json class TreeExecutor(Node): def __init__(self): - super().__init__("tree_executor_node") - # Get the path to the root of the package ws_path = "/workspace/code" tree_path = os.path.join(ws_path, "self_contained_tree.xml") factory = tree_factory.TreeFactory() self.tree = factory.create_tree_from_file(tree_path) + snapshot_visitor = py_trees.visitors.SnapshotVisitor() + self.tree.add_post_tick_handler( + functools.partial(self.post_tick_handler, snapshot_visitor) + ) + self.tree.visitors.append(snapshot_visitor) self.tree.tick_tock(period_ms=50) + def post_tick_handler(self, snapshot_visitor, behaviour_tree): + with open("/tmp/tree_state", "w") as f: + ascii_bt_to_json( + py_trees.display.ascii_tree( + behaviour_tree.root, + visited=snapshot_visitor.visited, + previously_visited=snapshot_visitor.visited, + ), + py_trees.display.ascii_blackboard(), + f, + ) + def spin_tree(self): try: @@ -28,11 +46,9 @@ def spin_tree(self): def main(): - # Init the components rclpy.init() executor = TreeExecutor() - # Spin the tree executor.spin_tree() diff --git a/backend/tree_api/json_translator.py b/backend/tree_api/json_translator.py index da2d22a76..b0c9ee86e 100644 --- a/backend/tree_api/json_translator.py +++ b/backend/tree_api/json_translator.py @@ -107,6 +107,35 @@ def build_xml(node_models, link_models, tree_structure, node_id, xml_parent, ord ) +def build_tree_structure(node_models, link_models, tree_structure, node_id, order): + + node_name = node_models[node_id]["name"] + data_ports = get_data_ports(node_models, link_models, node_id) + + # Add data_ports as attributes to current_element + attributes = {"name": node_name, "id": node_id, "childs": []} + + # Apply recursion to all its children + if node_id in tree_structure: + tree_structure[node_id] = sorted( + tree_structure[node_id], + key=lambda item: node_models[item]["y"], + reverse=order, + ) # Fixed: issue #73 + for child_id in tree_structure[node_id]: + attributes["childs"].append( + build_tree_structure( + node_models, + link_models, + tree_structure, + child_id, + order, + ) + ) + + return attributes + + def get_start_node_id(node_models, link_models): start_node_id = "" @@ -157,3 +186,26 @@ def translate(content, tree_path, raw_order): f = open(tree_path, "w") f.write(xml_string) f.close() + + +def translate_tree_structure(content): + # Parse the JSON data + parsed_json = content + + # Extract nodes and links information + node_models = parsed_json["layers"][1]["models"] + link_models = parsed_json["layers"][0]["models"] + + # Get the tree structure + tree_structure = get_tree_structure(link_models, node_models) + # Get the order of bt: True = Ascendent; False = Descendent + # order = raw_order == "bottom-to-top" + + # Generate XML + start_node_id = get_start_node_id(node_models, link_models) + print(start_node_id) + root = build_tree_structure( + node_models, link_models, tree_structure, start_node_id, False + ) + + return root diff --git a/backend/tree_api/urls.py b/backend/tree_api/urls.py index 5e7e8cf54..e952ab456 100644 --- a/backend/tree_api/urls.py +++ b/backend/tree_api/urls.py @@ -19,6 +19,7 @@ path("delete_file/", views.delete_file, name="delete_file"), path("save_file/", views.save_file, name="save_file"), path("translate_json/", views.translate_json, name="translate_json"), + path("get_tree_structure/", views.get_tree_structure, name="get_tree_structure"), path( "get_universe_configuration/", views.get_universe_configuration, diff --git a/backend/tree_api/views.py b/backend/tree_api/views.py index 73ce5bb01..503827d08 100644 --- a/backend/tree_api/views.py +++ b/backend/tree_api/views.py @@ -173,6 +173,37 @@ def get_project_graph(request): ) +@api_view(["GET"]) +def get_tree_structure(request): + + project_name = request.GET.get("project_name") + + # Generate the paths + base_path = os.path.join(settings.BASE_DIR, "filesystem") + project_path = os.path.join(base_path, project_name) + graph_path = os.path.join(project_path, "code/graph.json") + + # Check if the project exists + if os.path.exists(graph_path): + try: + with open(graph_path, "r") as f: + graph_data = json.load(f) + + # Get the tree structure + tree_structure = json_translator.translate_tree_structure(graph_data) + + return JsonResponse({"success": True, "tree_structure": tree_structure}) + except Exception as e: + return JsonResponse( + {"success": False, "message": f"Error reading file: {str(e)}"}, + status=500, + ) + else: + return Response( + {"error": "The project does not have a graph definition"}, status=404 + ) + + @api_view(["GET"]) def get_universes_list(request): @@ -629,7 +660,6 @@ def generate_app(request): # Get the parameters app_name = request.data.get("app_name") tree_graph = request.data.get("tree_graph") - print(tree_graph) bt_order = request.data.get("bt_order") # Make folder path relative to Django app diff --git a/backend/tree_gardener/tree_gardener/tree_tools.py b/backend/tree_gardener/tree_gardener/tree_tools.py index c866e20d2..39516e8bc 100644 --- a/backend/tree_gardener/tree_gardener/tree_tools.py +++ b/backend/tree_gardener/tree_gardener/tree_tools.py @@ -1,3 +1,4 @@ +import re import py_trees @@ -46,3 +47,86 @@ def set_port_content(port_value, value): # Set the value for the key in the blackboard setattr(blackboard, key, value) + + +########### ASCII BT STATUS TO JSON ############################################ +def ascii_state_to_state(state_raw): + letter = [x for x in state_raw] + state = letter[1] + + match state: + case "*": + return "RUNNING" + case "o": + return "SUCCESS" + case "x": + return "FAILURE" + case "-": + return "INVALID" + case _: + return "INVALID" + + +def ascii_tree_to_json(tree): + indent_levels = 4 # 4 spaces = 1 level deep + do_append_coma = False + last_indent_level = -1 + json_str = '"tree":{' + + # Remove escape chars + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + tree = ansi_escape.sub("", tree) + + for line in iter(tree.splitlines()): + entry = line.strip().split(" ") + name = entry[1] + if len(entry) == 2: + state = "NONE" + else: + state = ascii_state_to_state(entry[2]) + + indent = int((len(line) - len(line.lstrip())) / indent_levels) + if not (indent > last_indent_level): + json_str += "}" * (last_indent_level - indent + 1) + + last_indent_level = indent + + if do_append_coma: + json_str += "," + else: + do_append_coma = True + json_str += '"' + name + '":{' + json_str += f'"state":"{state}"' + + json_str += "}" * (last_indent_level + 1) + "}" + return json_str + + +def ascii_blackboard_to_json(blackboard): + json_str = '"blackboard":{' + do_append_coma = False + + # FIX: [entry, value] = line.strip()[1:].split(":") + + for line in iter(blackboard.splitlines()): + if "Blackboard Data" in line: + continue + if len(line.strip()) == 0: + continue + if do_append_coma: + json_str += "," + else: + do_append_coma = True + # Remove whitespaces with strip and remove / from entry + [entry, value] = line.strip()[1:].split(":") + json_str += f'"{entry.strip()}":"{value.strip()}"' + json_str += "}" + return json_str + + +def ascii_bt_to_json(tree, blackboard, file): + file.write("{") + # file.write(f"{ascii_tree_to_json(tree)},{ascii_blackboard_to_json(blackboard)}") + file.write(f"{ascii_tree_to_json(tree)}") + file.write("}") + file.close() diff --git a/frontend/src/App.css b/frontend/src/App.css index b2d73b095..c0ad3705d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -241,4 +241,9 @@ h3 { --bt-action-default-color: red; --bt-tag-blackboard-background: #5ba498; --bt-tag-normal-background: #a45b67; + + --bt-status-running: #ee942e; + --bt-status-success: #29ac29; + --bt-status-failure: #b11111; + --bt-status-invalid: #494949; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f705c0180..ca58717ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import VncViewer from "./components/vnc_viewer/VncViewer"; import ErrorModal from "./components/error_popup/ErrorModal"; import axios from "axios"; import EditorContainer from "./components/diagram_editor/EditorContainer"; +import DiagramVisualizerContainer from "./components/bt_status_visualizer/DiagramVisualizerContainer"; import CommsManager from "./api_helper/CommsManager"; import { loadProjectConfig } from "./api_helper/TreeWrapper"; @@ -30,6 +31,12 @@ const App = () => { const [diagramEditorReady, setDiagramEditorReady] = useState(false); const [appRunning, setAppRunning] = useState(false); + // TODO: temporary + // const [showExecStatus, setShowExecStatus] = useState(true); + // const onSetShowExecStatus = () => { + // setShowExecStatus(!showExecStatus) + // } + // const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; // const [theme, setTheme] = useLocalStorage('theme', defaultDark ? 'dark' : 'light'); ////////////////////// SETTINGS ////////////////////// @@ -128,6 +135,7 @@ const App = () => { settingsProps={settings} gazeboEnabled={gazeboEnabled} setGazeboEnabled={setGazeboEnabled} + // onSetShowExecStatus={onSetShowExecStatus} manager={manager} /> @@ -169,11 +177,20 @@ const App = () => { >
{currentProjectname ? ( - + <> + {true ? ( + + ) : ( + + )} + ) : (

Loading...

)} diff --git a/frontend/src/api_helper/CommsManager.ts b/frontend/src/api_helper/CommsManager.ts index df6c084d7..44652036b 100644 --- a/frontend/src/api_helper/CommsManager.ts +++ b/frontend/src/api_helper/CommsManager.ts @@ -6,9 +6,12 @@ type PromiseHandlers = { reject: (reason?: any) => void; }; +type Events = string | string[]; + export default class CommsManager { private static instance: CommsManager; private ws: WebSocket; + private observers: { [id: string]: Function[] } = {}; private pendingPromises: Map = new Map(); // Private constructor to only allow single instatiation @@ -18,7 +21,7 @@ export default class CommsManager { // Message callback this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); - console.log(msg); + // console.log(msg); // Check if the message ID exists in the pending promises map const handlers = this.pendingPromises.get(msg.id); @@ -33,6 +36,13 @@ export default class CommsManager { // Clean up after handling the response this.pendingPromises.delete(msg.id); + } else { + // Look if there are any subscribers + const subscriptions = this.observers[msg.command] || []; + let length = subscriptions.length; + while (length--) { + subscriptions[length](msg); + } } }; @@ -56,6 +66,41 @@ export default class CommsManager { return CommsManager.instance; } + public subscribe = (events: Events, callback: Function) => { + if (typeof events === "string") { + events = [events]; + } + for (let i = 0, length = events.length; i < length; i++) { + this.observers[events[i]] = this.observers[events[i]] || []; + this.observers[events[i]].push(callback); + } + }; + + public subscribeOnce = (event: Events, callback: Function) => { + this.subscribe(event, (response: any) => { + callback(response); + this.unsubscribe(event, callback); + }); + }; + + public unsubscribe = (events: Events, callback: Function) => { + if (typeof events === "string") { + events = [events]; + } + for (let i = 0, length = events.length; i < length; i++) { + this.observers[events[i]] = this.observers[events[i]] || []; + this.observers[events[i]].splice( + this.observers[events[i]].indexOf(callback), + ); + } + }; + + public unsuscribeAll = () => { + for (const event in this.observers) { + this.observers[event].length = 0; + } + }; + // Send messages and manage promises public async send(message: string, data?: Object): Promise { const id = uuidv4(); diff --git a/frontend/src/api_helper/TreeWrapper.ts b/frontend/src/api_helper/TreeWrapper.ts index bc5c33c0a..53378371a 100644 --- a/frontend/src/api_helper/TreeWrapper.ts +++ b/frontend/src/api_helper/TreeWrapper.ts @@ -149,42 +149,115 @@ const getCustomUniverseZip = async ( // App management +// const generateApp = async ( +// modelJson: string, +// currentProjectname: string, +// btOrder: string, +// ) => { +// if (!modelJson) throw new Error("Tree JSON is empty!"); +// if (!currentProjectname) throw new Error("Current Project name is not set"); + +// const apiUrl = "/tree_api/generate_app/"; +// try { +// // Configure the request options +// const config = { +// method: "POST", +// url: apiUrl, +// headers: { +// "Content-Type": "application/json", +// }, +// data: { +// app_name: currentProjectname, +// tree_graph: JSON.stringify(modelJson), +// bt_order: btOrder, +// }, +// }; + +// // Make the request +// const response = await axios(config); +// console.log(response.status); + +// // Handle unsuccessful response status (e.g., non-2xx status) +// if (!isSuccessful(response)) { +// throw new Error(response.data.message || "Failed to create app."); // Response error +// } +// return new Blob([response.data], { type: "application/zip" }); +// } catch (error: unknown) { +// throw error; // Rethrow +// } +// }; + +// const generateDockerizedApp = async ( +// modelJson: string, +// currentProjectname: string, +// btOrder: string, +// ) => { +// if (!modelJson) throw new Error("Tree JSON is empty!"); +// if (!currentProjectname) throw new Error("Current Project name is not set"); + +// const apiUrl = "/tree_api/generate_dockerized_app/"; +// try { +// // Configure the request options +// const config = { +// method: "POST", +// url: apiUrl, +// headers: { +// "Content-Type": "application/json", +// }, +// data: { +// app_name: currentProjectname, +// tree_graph: JSON.stringify(modelJson), +// bt_order: btOrder, +// }, +// }; + +// // Make the request +// const response = await axios(config); +// console.log(response.status); + +// // Handle unsuccessful response status (e.g., non-2xx status) +// if (!isSuccessful(response)) { +// throw new Error(response.data.message || "Failed to create app."); // Response error +// } +// return new Blob([response.data], { type: "application/zip" }); +// } catch (error: unknown) { +// throw error; // Rethrow +// } +// }; + const generateApp = async ( modelJson: string, currentProjectname: string, btOrder: string, + dockerized: boolean = false, ) => { if (!modelJson) throw new Error("Tree JSON is empty!"); if (!currentProjectname) throw new Error("Current Project name is not set"); - const apiUrl = "/tree_api/generate_app/"; - try { - // Configure the request options - const config = { - method: "POST", - url: apiUrl, - headers: { - "Content-Type": "application/json", - }, - data: { - app_name: currentProjectname, - tree_graph: JSON.stringify(modelJson), - bt_order: btOrder, - }, - }; + var apiUrl = "/tree_api/generate_app/"; - // Make the request - const response = await axios(config); - console.log(response.status); + if (dockerized) { + apiUrl = "/tree_api/generate_dockerized_app/"; + } - // Handle unsuccessful response status (e.g., non-2xx status) - if (!isSuccessful(response)) { - throw new Error(response.data.message || "Failed to create app."); // Response error - } - return new Blob([response.data], { type: "application/octet-stream" }); - } catch (error: unknown) { - throw error; // Rethrow + const api_response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app_name: currentProjectname, + tree_graph: JSON.stringify(modelJson), + bt_order: btOrder, + }), + }); + + if (!api_response.ok) { + var json_response = await api_response.json(); + throw new Error(json_response.message || "An error occurred."); } + + return api_response.blob(); }; // Named export @@ -193,6 +266,7 @@ export { saveProject, loadProjectConfig, generateApp, + // generateDockerizedApp, getUniverseConfig, getCustomUniverseZip, }; diff --git a/frontend/src/components/bt_status_visualizer/DiagramEditor.css b/frontend/src/components/bt_status_visualizer/DiagramEditor.css new file mode 100644 index 000000000..05ae75e19 --- /dev/null +++ b/frontend/src/components/bt_status_visualizer/DiagramEditor.css @@ -0,0 +1,5 @@ +.canvas { + height: 40vh; + width: 100%; + background-color: var(--canvas-background); +} diff --git a/frontend/src/components/bt_status_visualizer/DiagramVisualizer.tsx b/frontend/src/components/bt_status_visualizer/DiagramVisualizer.tsx new file mode 100644 index 000000000..b566c1d73 --- /dev/null +++ b/frontend/src/components/bt_status_visualizer/DiagramVisualizer.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useReducer, useState } from "react"; +import { useRef, memo } from "react"; + +import createEngine, { + DiagramModel, + ZoomCanvasAction, +} from "@projectstorm/react-diagrams"; +import { CanvasWidget } from "@projectstorm/react-canvas-core"; + +import "./DiagramEditor.css"; +import { BasicNodeFactory } from "../diagram_editor/nodes/basic_node/BasicNodeFactory"; +import { TagNodeFactory } from "../diagram_editor/nodes/tag_node/TagNodeFactory"; +import { SimplePortFactory } from "../diagram_editor/nodes/SimplePortFactory"; +import { ChildrenPortModel } from "../diagram_editor/nodes/basic_node/ports/children_port/ChildrenPortModel"; +import { ParentPortModel } from "../diagram_editor/nodes/basic_node/ports/parent_port/ParentPortModel"; +import { OutputPortModel } from "../diagram_editor/nodes/basic_node/ports/output_port/OutputPortModel"; +import { InputPortModel } from "../diagram_editor/nodes/basic_node/ports/input_port/InputPortModel"; +import { TagOutputPortModel } from "../diagram_editor/nodes/tag_node/ports/output_port/TagOutputPortModel"; +import { TagInputPortModel } from "../diagram_editor/nodes/tag_node/ports/input_port/TagInputPortModel"; + +// MODAL MANAGEMENT +const testFunction = () => { + console.log("Hello!"); +}; + +// HELPERS + +// Configures an engine with all the factories +const configureEngine = (engine: any) => { + console.log("Configuring engine!"); + // Register factories + engine.current + .getNodeFactories() + .registerFactory(new BasicNodeFactory(testFunction)); + engine.current + .getNodeFactories() + .registerFactory(new TagNodeFactory(testFunction)); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("children", (config) => new ChildrenPortModel()), + ); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("parent", (config) => new ParentPortModel()), + ); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("output", (config) => new OutputPortModel("")), + ); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("input", (config) => new InputPortModel("")), + ); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("tag output", (config) => new TagOutputPortModel()), + ); + engine.current + .getPortFactories() + .registerFactory( + new SimplePortFactory("tag input", (config) => new TagInputPortModel()), + ); + + // Disable loose links + const state: any = engine.current.getStateMachine().getCurrentState(); + state.dragNewLink.config.allowLooseLinks = false; + + engine.current + .getActionEventBus() + .registerAction(new ZoomCanvasAction({ inverseZoom: true })); +}; + +const setTreeStatus = (model: any, updateTree: any, baseTree: any) => { + console.log(updateTree); + + setStatusNode(model, updateTree, baseTree); +}; + +const setStatusNode = (model: any, updateTree: any, baseTree: any) => { + var nodeName = baseTree["name"]; + var nodeId = baseTree["id"]; + + var nodeChilds; + try { + nodeChilds = baseTree["childs"]; + } catch (error) { + nodeChilds = []; + } + + console.log(updateTree[nodeName], nodeName); + var nodeStatus = updateTree[nodeName]["state"]; + var node = model.current.getNode(nodeId); + + nodeChilds.forEach((element: any) => { + setStatusNode(model, updateTree[nodeName], element); + }); + node.setExecStatus(nodeStatus); + model.current.addNode(node); +}; + +const DiagramVisualizer = memo( + ({ + modelJson, + manager, + treeStructure, + }: { + modelJson: any; + manager: any; + treeStructure: any; + }) => { + // Initialize the model and the engine + const model = useRef(new DiagramModel()); + const engine = useRef(createEngine()); + + // There is no need to use an effect as the editor will re render when the model json changes + // Configure the engine + configureEngine(engine); + + // Deserialize and load the model + console.log("Diagram Visualizer"); + model.current.deserializeModel(modelJson, engine.current); + model.current.setLocked(true); + engine.current.setModel(model.current); + + const updateExecState = (msg: any) => { + if (msg && msg.command === "update" && msg.data.update !== "") { + const updateStatus = JSON.parse(msg.data.update); + console.log("Repaint"); + const updateTree = updateStatus.tree; + const updateBlackboard = updateStatus.blackboard; + + setTreeStatus(model, updateTree, treeStructure); + engine.current.repaintCanvas(); + } + }; + + manager.subscribe("update", updateExecState); + + return ( +
+ +
+ ); + }, +); + +const DiagramVisualizerStatus = ({ + model, + engine, + manager, + treeStructure, +}: { + model: any; + engine: any; + manager: any; + treeStructure: any; +}) => { + const [s, st] = useState(""); + + const updateExecState = (msg: any) => { + if (msg && msg.command === "update" && msg.data.update !== "") { + const updateStatus = JSON.parse(msg.data.update); + console.log("Repaint"); + const updateTree = updateStatus.tree; + const updateBlackboard = updateStatus.blackboard; + + setTreeStatus(model, updateTree, treeStructure); + engine.current.repaintCanvas(); + } + }; + + manager.subscribe("update", updateExecState); + + return ( +
+ +
+ ); +}; + +export default DiagramVisualizer; diff --git a/frontend/src/components/bt_status_visualizer/DiagramVisualizerContainer.tsx b/frontend/src/components/bt_status_visualizer/DiagramVisualizerContainer.tsx new file mode 100644 index 000000000..505bbf6b5 --- /dev/null +++ b/frontend/src/components/bt_status_visualizer/DiagramVisualizerContainer.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import axios from "axios"; +import { useState, useEffect } from "react"; +import DiagramVisualizer from "./DiagramVisualizer"; + +const DiagramVisualizerContainer = ({ + projectName, + manager, +}: { + projectName: string; + manager: any; +}) => { + const [initialJson, setInitialJson] = useState(""); + const [treeStructure, setTreeStructure] = useState(""); + + const getGraph = async (project_name: any) => { + try { + const response = await axios.get("/tree_api/get_project_graph/", { + params: { + project_name: project_name, + }, + }); + if (response.data.success) { + setInitialJson(response.data.graph_json); + } + } catch (error) { + console.error("Error fetching graph:", error); + } + }; + + const getBTTree = async (project_name: any) => { + try { + const response = await axios.get("/tree_api/get_tree_structure/", { + params: { + project_name: project_name, + }, + }); + if (response.data.success) { + setTreeStructure(response.data.tree_structure); + } + } catch (error) { + console.error("Error fetching graph:", error); + } + }; + + useEffect(() => { + // Fetch graph when component mounts< + getGraph(projectName); + getBTTree(projectName); + console.log("Getting graph!"); + }, [projectName]); + + return ( +
+ {initialJson ? ( + + ) : ( +

Loading...

// Display a loading message until the graph is fetched + )} +
+ ); +}; + +export default DiagramVisualizerContainer; diff --git a/frontend/src/components/diagram_editor/DiagramEditor.tsx b/frontend/src/components/diagram_editor/DiagramEditor.tsx index e7eed026e..ffc9c9554 100644 --- a/frontend/src/components/diagram_editor/DiagramEditor.tsx +++ b/frontend/src/components/diagram_editor/DiagramEditor.tsx @@ -5,6 +5,10 @@ import createEngine, { DefaultLinkModel, DefaultNodeModel, DiagramModel, + DiagramModelGenerics, + LinkModel, + NodeModel, + ZoomCanvasAction, } from "@projectstorm/react-diagrams"; import { CanvasWidget } from "@projectstorm/react-canvas-core"; @@ -22,6 +26,7 @@ import { TagOutputPortModel } from "./nodes/tag_node/ports/output_port/TagOutput import { TagInputPortModel } from "./nodes/tag_node/ports/input_port/TagInputPortModel"; import NodeMenu from "./NodeMenu"; +import EditActionModal from "./modals/EditActionModal"; // MODAL MANAGEMENT const testFunction = () => { @@ -74,16 +79,77 @@ const configureEngine = (engine: any) => { // Disable loose links const state: any = engine.current.getStateMachine().getCurrentState(); state.dragNewLink.config.allowLooseLinks = false; + + engine.current + .getActionEventBus() + .registerAction(new ZoomCanvasAction({ inverseZoom: true })); }; // Add the nodes default ports -const addDefaultPorts = (node: any) => { +const addDefaultPorts = (node: any, model: any) => { console.log("Adding default ports"); var nodeName = node.getName(); if (nodeName === "RetryUntilSuccessful") node.addInputPort("num_attempts"); else if (nodeName === "Repeat") node.addInputPort("num_cycles"); else if (nodeName === "Delay") node.addInputPort("delay_ms"); + + model.current.getNodes().forEach((oldNode: NodeModel) => { + //TODO: for the tags, this will never be called. Maybe have a common type + if (oldNode instanceof BasicNodeModel) { + var convNode = oldNode as BasicNodeModel; + if (convNode.getName() === node.getName() && node !== convNode) { + node.setColor(convNode.getColor()); + Object.values(convNode.getPorts()).forEach((element) => { + if (element instanceof InputPortModel) { + node.addInputPort(element.getName()); + } else if (element instanceof OutputPortModel) { + node.addOutputPort(element.getName()); + } + }); + } + } + }); +}; + +const deletePortLink = (model: any, portName: string, node: BasicNodeModel) => { + var link: LinkModel | undefined; + const nodePort = node.getPort(portName); + + if (nodePort) { + link = Object.values(nodePort.links)[0]; + if (link) { + model.current.removeLink(link); + } + } +}; + +const isActionNode = (node: any) => { + var name = node.getName(); + + if (node.getOptions().type === "tag") { + return false; + } + + // Check if the node is a user written action + return ![ + "Sequence", + "ReactiveSequence", + "SequenceWithMemory", + "Fallback", + "ReactiveFallback", + "RetryUntilSuccessful", + "Inverter", + "ForceSuccess", + "ForceFailure", + "KeepRunningUntilFailure", + "Repeat", + "RunOnce", + "Delay", + "Input port value", + "Output port value", + "Tree Root", + ].includes(name); }; const DiagramEditor = memo( @@ -97,18 +163,113 @@ const DiagramEditor = memo( setResultJson: Function; projectName: string; setDiagramEdited: Function; + }) => { + // Initialize the model and the engine + const model = useRef(new DiagramModel()); + const engine = useRef(createEngine()); + + configureEngine(engine); + + // Deserialize and load the model + console.log("Repaint"); + model.current.deserializeModel(modelJson, engine.current); + setResultJson(modelJson); + engine.current.setModel(model.current); + + return ( + + + + ); + }, +); + +const DiagramEditorModalsWrapper = memo( + ({ + engine, + model, + projectName, + setResultJson, + setDiagramEdited, + children, + }: { + engine: any; + model: React.MutableRefObject>; + projectName: string; + setResultJson: Function; + setDiagramEdited: Function; + children: any; }) => { // VARS + const [isEditActionModalOpen, setEditActionModalOpen] = + useState(false); + const [currentNode, setCurrentNode] = useState(null); // Initialize position and the last clicked node var lastMovedNodePosition = { x: 100, y: 100 }; var lastClickedNodeId = ""; - // REFS + // Zooms to fit the nodes + const zoomToFit = () => { + engine.current.zoomToFitNodes({ margin: 50 }); + }; - // Initialize the model and the engine - const model = useRef(new DiagramModel()); - const engine = useRef(createEngine()); + const openActionEditor = () => { + if (lastClickedNodeId !== "") { + const node = model.current.getNode(lastClickedNodeId) as BasicNodeModel; + if (isActionNode(node)) { + setCurrentNode( + model.current.getNode(lastClickedNodeId) as BasicNodeModel, + ); + setEditActionModalOpen(true); + } + } + }; + + const closeActionEditor = () => { + setEditActionModalOpen(false); + setCurrentNode(null); + lastClickedNodeId = ""; + }; + + const setColorActionNode = (r: number, g: number, b: number) => { + if (currentNode === null) { + return; + } + + currentNode.setColor( + "rgb(" + + Math.round(r) + + "," + + Math.round(g) + + "," + + Math.round(b) + + ")", + ); + + model.current.getNodes().forEach((node: NodeModel) => { + //TODO: for the tags, this will never be called. Maybe have a common type + if (currentNode instanceof BasicNodeModel) { + var convNode = node as BasicNodeModel; + if ( + convNode.getName() === currentNode.getName() && + currentNode !== convNode + ) { + convNode.setColor(currentNode.getColor()); + } + } + }); + + setDiagramEdited(true); + updateJsonState(); + engine.current.repaintCanvas(); + }; // HELPERS const updateJsonState = () => { @@ -129,15 +290,6 @@ const DiagramEditor = memo( } }; - // Zooms to fit the nodes - const zoomToFit = () => { - engine.current.zoomToFitNodes({ margin: 50 }); - }; - - const actionEditor = () => { - console.log("Editing the action!"); - }; - // LISTENERS // Position listener @@ -269,7 +421,7 @@ const DiagramEditor = memo( // Add ports newNode.addParentPort("Parent Port"); if (!isAction) newNode.addChildrenPort("Children Port"); - addDefaultPorts(newNode); + addDefaultPorts(newNode, model); // Add the node to the model if (model.current) { @@ -322,15 +474,83 @@ const DiagramEditor = memo( else addBasicNode(nodeName); }; - // There is no need to use an effect as the editor will re render when the model json changes - // Configure the engine - configureEngine(engine); + const addPort = (portName: string, node: any, type: number) => { + //TODO: type should be an enum + // Check that the user didn't cancel + if (!node || !portName) { + return; + } + + if (type === 0) { + node.addInputPort(portName); + } else { + node.addOutputPort(portName); + } + + model.current.getNodes().forEach((oldNode: NodeModel) => { + //TODO: for the tags, this will never be called. Maybe have a common type + if (isActionNode(oldNode)) { + var convNode = oldNode as BasicNodeModel; + if (convNode.getName() === node.getName() && node !== convNode) { + if (type === 0) { + convNode.addInputPort(portName); + } else { + convNode.addOutputPort(portName); + } + } + } + }); + + setDiagramEdited(true); + updateJsonState(); + engine.current.repaintCanvas(); + }; + + const removePort = (port: any, node: any, type: number) => { + //TODO: type should be an enum + // Check that the user didn't cancel + if (!node || !port) { + return; + } + + deletePortLink(model, port.options.name, node); + + if (type === 0) { + node.removeInputPort(port); + } else { + node.removeOutputPort(port); + } + + // FIX: this should be with some and other stuff + model.current.getNodes().forEach((oldNode: NodeModel) => { + //TODO: for the tags, this will never be called. Maybe have a common type + if (isActionNode(oldNode)) { + var convNode = oldNode as BasicNodeModel; + if ( + convNode.getName() === node.getName() && + node.getID() !== convNode.getID() + ) { + deletePortLink(model, port.options.name, convNode); + + if (type === 0) { + convNode.removeInputPort(port); + } else { + convNode.removeOutputPort(port); + } + } + } + }); + + setDiagramEdited(true); + updateJsonState(); + engine.current.repaintCanvas(); + }; - // Deserialize and load the model - model.current.deserializeModel(modelJson, engine.current); - setResultJson(modelJson); attachLinkListener(model.current); - engine.current.setModel(model.current); + + engine.current + .getNodeFactories() + .registerFactory(new BasicNodeFactory(openActionEditor)); // After deserialization, attach listeners to each node const nodes = model.current.getNodes(); // Assuming getNodes() method exists to retrieve all nodes @@ -347,9 +567,17 @@ const DiagramEditor = memo( onAddNode={nodeTypeSelector} onDeleteNode={deleteLastClickedNode} onZoomToFit={zoomToFit} - onEditAction={actionEditor} + onEditAction={openActionEditor} + /> + {children} + -
); }, diff --git a/frontend/src/components/diagram_editor/NodeMenu.tsx b/frontend/src/components/diagram_editor/NodeMenu.tsx index ac596d8e9..61d8756ae 100644 --- a/frontend/src/components/diagram_editor/NodeMenu.tsx +++ b/frontend/src/components/diagram_editor/NodeMenu.tsx @@ -30,9 +30,9 @@ var NODE_MENU_ITEMS: Record = { const fetchActionList = async (project_name: string) => { try { const response = await axios.get( - `/tree_api/get_file_list?project_name=${project_name}`, + `/tree_api/get_actions_list?project_name=${project_name}`, ); - const files = response.data.file_list; + const files = response.data.actions_list; if (Array.isArray(files)) { const actions = files.map((file) => file.replace(".py", "")); NODE_MENU_ITEMS["Actions"] = actions; diff --git a/frontend/src/components/diagram_editor/modals/EditActionModal.jsx b/frontend/src/components/diagram_editor/modals/EditActionModal.jsx index e717eba0b..ff00d802d 100644 --- a/frontend/src/components/diagram_editor/modals/EditActionModal.jsx +++ b/frontend/src/components/diagram_editor/modals/EditActionModal.jsx @@ -63,10 +63,8 @@ const EditActionModal = ({ onClose, currentActionNode, setColorActionNode, - addInputPort, - addOutputPort, - deleteInputPort, - deleteOutputPort, + addPort, + removePort, }) => { const focusInputRef = useRef(null); const [color, setColor] = useColor("rgb(128 0 128)"); @@ -172,7 +170,7 @@ const EditActionModal = ({ const addInput = () => { //TODO: Maybe display some error message when the name is invalid if (isInputNameValid(formState["newInputName"])) { - addInputPort(formState["newInputName"]); + addPort(formState["newInputName"], currentActionNode, 0); } setInputName(false); reRender(); @@ -181,7 +179,7 @@ const EditActionModal = ({ const addOutput = () => { //TODO: Maybe display some error message when the name is invalid if (isOutputNameValid(formState["newOutputName"])) { - addOutputPort(formState["newOutputName"]); + addPort(formState["newOutputName"], currentActionNode, 1); } setOutputName(false); reRender(); @@ -265,7 +263,7 @@ const EditActionModal = ({ }} title="Delete" onClick={() => { - deleteInputPort(port[1], port[0]); + removePort(port[1], currentActionNode, 0); reRender(); }} > @@ -387,7 +385,7 @@ const EditActionModal = ({ }} title="Delete" onClick={() => { - deleteOutputPort(port[1], port[0]); + removePort(port[1], currentActionNode, 1); reRender(); }} > diff --git a/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeModel.ts b/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeModel.ts index f3661b1d9..55e296cc5 100644 --- a/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeModel.ts +++ b/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeModel.ts @@ -15,36 +15,35 @@ export interface BasicNodeModelGenerics { PORT: ParentPortModel | ChildrenPortModel | InputPortModel | OutputPortModel; } +export type BTExecutionStatus = + | "RUNNING" + | "SUCCESS" + | "FAILURE" + | "INVALID" + | "NONE"; + export class BasicNodeModel extends NodeModel< NodeModelGenerics & BasicNodeModelGenerics > { private name: string; private color: string; private is_selected: boolean; - private is_subtree: boolean; + private exec_status: BTExecutionStatus; - constructor( - name: string = "Basic Node", - color: string = "rgb(0,192,255)", - is_subtree: boolean = false, - ) { + constructor(name: string = "Basic Node", color: string = "rgb(0,192,255)") { super({ type: "basic", }); this.name = name; this.color = color; this.is_selected = false; - this.is_subtree = is_subtree; + this.exec_status = "NONE"; } getName(): string { return this.name; } - getIsSubtree(): boolean { - return this.is_subtree; - } - setColor(color: string): void { this.color = color; } @@ -53,6 +52,14 @@ export class BasicNodeModel extends NodeModel< return this.color; } + setExecStatus(status: BTExecutionStatus): void { + this.exec_status = status; + } + + getExecStatus(): BTExecutionStatus { + return this.exec_status; + } + isSelected(): boolean { return this.is_selected; } @@ -105,7 +112,6 @@ export class BasicNodeModel extends NodeModel< name: this.name, color: this.color, is_selected: this.is_selected, - is_subtree: this.is_subtree, }; } @@ -114,6 +120,5 @@ export class BasicNodeModel extends NodeModel< this.name = event.data.name; this.color = event.data.color; this.is_selected = event.data.is_selected; - this.is_subtree = event.data.is_subtree; } } diff --git a/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeWidget.tsx b/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeWidget.tsx index 2ba01daad..549c20ab5 100644 --- a/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeWidget.tsx +++ b/frontend/src/components/diagram_editor/nodes/basic_node/BasicNodeWidget.tsx @@ -29,6 +29,23 @@ export const BasicNodeWidget = ({ }), }; + switch (node.getExecStatus()) { + case "RUNNING": + nodeStyle["background"] = "var(--bt-status-running)"; + break; + case "SUCCESS": + nodeStyle["background"] = "var(--bt-status-success)"; + break; + case "FAILURE": + nodeStyle["background"] = "var(--bt-status-failure)"; + break; + case "INVALID": + nodeStyle["background"] = "var(--bt-status-invalid)"; + break; + default: + break; + } + // Ports list const parentPorts: JSX.Element[] = []; const childrenPorts: JSX.Element[] = []; diff --git a/frontend/src/components/header_menu/HeaderMenu.tsx b/frontend/src/components/header_menu/HeaderMenu.tsx index 0378da839..a42776549 100644 --- a/frontend/src/components/header_menu/HeaderMenu.tsx +++ b/frontend/src/components/header_menu/HeaderMenu.tsx @@ -21,6 +21,8 @@ import { ReactComponent as DownloadIcon } from "./img/download.svg"; import { ReactComponent as RunIcon } from "./img/run.svg"; import { ReactComponent as StopIcon } from "./img/stop.svg"; import { ReactComponent as ResetIcon } from "./img/reset.svg"; +import { ReactComponent as EyeOpenIcon } from "./img/eye_open.svg"; +import { ReactComponent as EyeClosedIcon } from "./img/eye_closed.svg"; import ProjectModal from "./modals/ProjectModal"; import UniversesModal from "./modals/UniverseModal"; import SettingsModal from "../settings_popup/SettingsModal"; @@ -36,6 +38,7 @@ const HeaderMenu = ({ settingsProps, gazeboEnabled, setGazeboEnabled, + // onSetShowExecStatus, manager, }: { currentProjectname: string; @@ -48,6 +51,7 @@ const HeaderMenu = ({ settingsProps: Object; gazeboEnabled: boolean; setGazeboEnabled: Function; + // onSetShowExecStatus: Function; manager: CommsManager; }) => { // Project state @@ -80,7 +84,7 @@ const HeaderMenu = ({ name: configJson.name, launch_file_path: configJson.config.launch_file_path, ros_version: "ROS2", - visualization: "gazebo_rae", + visualization: "bt_studio", world: "gazebo", exercise_id: configJson.config.id, }; @@ -97,6 +101,25 @@ const HeaderMenu = ({ } }; + // const launchUniverse = async (universe_name) => { + // const apiUrl = `/tree_api/get_universe_configuration?project_name=${encodeURIComponent( + // currentProjectname, + // )}&universe_name=${encodeURIComponent(universe_name)}`; + + // try { + // const response = await axios.get(apiUrl); + // console.log("Stored universe config: " + response.data); + // const stored_cfg = JSON.parse(response.data); + // if (stored_cfg.type === "robotics_backend") { + // await launchBackendUniverse(stored_cfg); + // } else { + // launchCustomUniverse(stored_cfg); + // } + // } catch (error) { + // console.error("Error launching universe:", error); + // } + // }; + // Project handling const onCreateProject = async (projectName: string) => { @@ -171,6 +194,19 @@ const HeaderMenu = ({ } }; + const sendOnLoad = async (reader: FileReader) => { + // Get the zip in base64 + var base64data = reader.result; + + // Send the zip + await manager.run({ + type: "bt-studio", + code: base64data, + }); + setAppRunning(true); + console.log("App started successfully"); + }; + const onAppStateChange = async () => { if (!gazeboEnabled) { console.error("Simulation is not ready!"); @@ -183,16 +219,12 @@ const HeaderMenu = ({ modelJson, currentProjectname, "bottom-to-top", + true, ); - const base64data = await app_blob.text(); - - // Send the zip - await manager.run({ - type: "bt-studio", - code: base64data, - }); - setAppRunning(true); - console.log("App started successfully"); + const reader = new FileReader(); + + reader.onloadend = () => sendOnLoad(reader); // Fix: pass the function reference + reader.readAsDataURL(app_blob); } catch (error: unknown) { if (error instanceof Error) { console.error("Error running app: " + error.message); @@ -385,6 +417,17 @@ const HeaderMenu = ({ > + {/* */} diff --git a/frontend/src/components/header_menu/img/eye_closed.svg b/frontend/src/components/header_menu/img/eye_closed.svg new file mode 100644 index 000000000..4002cb19e --- /dev/null +++ b/frontend/src/components/header_menu/img/eye_closed.svg @@ -0,0 +1,3 @@ + diff --git a/frontend/src/components/header_menu/img/eye_open.svg b/frontend/src/components/header_menu/img/eye_open.svg new file mode 100644 index 000000000..dd72f1402 --- /dev/null +++ b/frontend/src/components/header_menu/img/eye_open.svg @@ -0,0 +1,4 @@ +