From 64254edcf722a72d31802b73ddc011ceba6e1260 Mon Sep 17 00:00:00 2001 From: Richard Sauer Date: Wed, 8 Apr 2026 11:58:50 +1000 Subject: [PATCH] Initial commit: BFA Banana Dryer HMI Design Collaboration Tool Split-screen web app: HMI layout editor + machine simulation. - Drag/drop card layout with SortableJS - Add/remove/rename controls and pages - Comments system with user picker (Richard, Rob, Guido + add more) - SVG schematic with animated overlays (fan spin, conveyor scroll, burner glow) - Simulated PLC state in preview mode - Built-in help manual - Docker deployment behind Caddy at sauer.com.au/bfa-design Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 + Dockerfile | 8 + app.py | 86 ++++++ docker-compose.yml | 12 + layouts/default.json | 43 +++ requirements.txt | 1 + static/photos/.gitkeep | 0 static/sortable.min.js | 2 + templates/editor.html | 687 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 844 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 layouts/default.json create mode 100644 requirements.txt create mode 100644 static/photos/.gitkeep create mode 100644 static/sortable.min.js create mode 100644 templates/editor.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efc9d7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +static/photos/* +!static/photos/.gitkeep +layouts/current.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e456873 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p layouts static/photos +EXPOSE 5001 +CMD ["python", "app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..94c7c1c --- /dev/null +++ b/app.py @@ -0,0 +1,86 @@ +"""BFA Banana Dryer — HMI Design Collaboration Tool""" +import os +import json +import time +import uuid +from flask import Flask, render_template, request, jsonify, send_from_directory + +app = Flask(__name__) +LAYOUT_DIR = os.path.join(os.path.dirname(__file__), "layouts") +PHOTO_DIR = os.path.join(os.path.dirname(__file__), "static", "photos") +os.makedirs(LAYOUT_DIR, exist_ok=True) +os.makedirs(PHOTO_DIR, exist_ok=True) + +CURRENT_LAYOUT = os.path.join(LAYOUT_DIR, "current.json") + +def get_layout(): + if os.path.exists(CURRENT_LAYOUT): + with open(CURRENT_LAYOUT) as f: + return json.load(f) + # First run — copy default + default = os.path.join(LAYOUT_DIR, "default.json") + with open(default) as f: + layout = json.load(f) + save_layout(layout) + return layout + +def save_layout(layout): + with open(CURRENT_LAYOUT, "w") as f: + json.dump(layout, f, indent=2) + +@app.route("/") +def index(): + return render_template("editor.html") + +@app.route("/api/layout", methods=["GET"]) +def api_get_layout(): + return jsonify(get_layout()) + +@app.route("/api/layout", methods=["POST"]) +def api_save_layout(): + layout = request.json + save_layout(layout) + return jsonify({"ok": True}) + +@app.route("/api/comment", methods=["POST"]) +def api_add_comment(): + data = request.json + layout = get_layout() + comment = { + "id": str(uuid.uuid4())[:8], + "target": data["target"], + "user": data["user"], + "time": time.strftime("%Y-%m-%dT%H:%M:%S"), + "text": data["text"] + } + layout.setdefault("comments", []).append(comment) + save_layout(layout) + return jsonify(comment) + +@app.route("/api/users", methods=["POST"]) +def api_add_user(): + name = request.json.get("name", "").strip() + if not name: + return jsonify({"error": "empty name"}), 400 + layout = get_layout() + if name not in layout.get("users", []): + layout.setdefault("users", []).append(name) + save_layout(layout) + return jsonify({"ok": True, "users": layout["users"]}) + +@app.route("/api/photo", methods=["POST"]) +def api_upload_photo(): + if "file" not in request.files: + return jsonify({"error": "no file"}), 400 + f = request.files["file"] + fname = f"{int(time.time())}_{f.filename}" + f.save(os.path.join(PHOTO_DIR, fname)) + return jsonify({"filename": fname, "url": f"/static/photos/{fname}"}) + +@app.route("/api/photos", methods=["GET"]) +def api_list_photos(): + photos = sorted(os.listdir(PHOTO_DIR)) if os.path.exists(PHOTO_DIR) else [] + return jsonify([{"filename": p, "url": f"/static/photos/{p}"} for p in photos if not p.startswith(".")]) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c1f73e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + bfa-design: + build: . + container_name: bfa-design + ports: + - "5001:5001" + volumes: + - ./layouts:/app/layouts + - ./static/photos:/app/static/photos + restart: unless-stopped + environment: + - FLASK_ENV=production diff --git a/layouts/default.json b/layouts/default.json new file mode 100644 index 0000000..519e1cc --- /dev/null +++ b/layouts/default.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "project": "BFA Banana Dryer", + "users": ["Richard", "Rob", "Guido"], + "pages": [ + { + "id": "p1", + "name": "TEMPS / FAN", + "cards": [ + {"id": "temp_0", "type": "temp", "label": "Heat Input", "color": "#ff4444", "width": "half", "sp_default": 130}, + {"id": "temp_1", "type": "temp", "label": "Product 1", "color": "#ff8844", "width": "half", "sp_default": 70}, + {"id": "temp_2", "type": "temp", "label": "Product 2", "color": "#ff8844", "width": "half", "sp_default": 60}, + {"id": "temp_3", "type": "temp", "label": "Exhaust", "color": "#44aaff", "width": "half", "sp_default": 50}, + {"id": "burner_0", "type": "burner", "label": "Burner", "width": "half"}, + {"id": "motor_0", "type": "motor", "label": "Hot Fan", "width": "half", "sp_default": 50} + ] + }, + { + "id": "p2", + "name": "MOTORS", + "cards": [ + {"id": "motor_1", "type": "motor", "label": "Conveyor", "width": "half", "sp_default": 40}, + {"id": "motor_2", "type": "motor", "label": "Spinner", "width": "half", "sp_default": 70}, + {"id": "motor_3", "type": "motor", "label": "Agitator 1", "width": "half", "sp_default": 55}, + {"id": "motor_4", "type": "motor", "label": "Agitator 2", "width": "half", "sp_default": 55} + ] + }, + { + "id": "p3", + "name": "OUTPUTS", + "cards": [ + {"id": "output_0", "type": "output", "label": "Discharge Agitator", "width": "half"}, + {"id": "output_1", "type": "output", "label": "Brush", "width": "half"}, + {"id": "output_2", "type": "output", "label": "Loading Conveyor", "width": "half"}, + {"id": "output_3", "type": "output", "label": "Discharge Conveyor", "width": "half"}, + {"id": "output_4", "type": "output", "label": "Mill", "width": "half"}, + {"id": "output_5", "type": "output", "label": "Shaker Separator", "width": "half"} + ] + } + ], + "comments": [], + "machine_annotations": [] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e75abb --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask==3.1.* diff --git a/static/photos/.gitkeep b/static/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/sortable.min.js b/static/sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/static/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY + + + + +BFA Banana Dryer — HMI Design Tool + + + + + + +
+
+ BFA Banana Dryer + HMI Design Tool +
+
+ + + | + + + | + + + +
+
+ + +
+ +
+
+ HMI LAYOUT + EDIT MODE + +
+
+
+
+ + + + +
+
+ + +
+
MACHINE SIMULATION
+
+ +
+
+
+ + +
+
+

Comments

+ +
+
+
+ + +
+
+ + +
+ +

How to Use This Tool

+

Switching Modes

+

Use the EDIT and PREVIEW buttons in the toolbar. In Edit mode you can rearrange and modify controls. In Preview mode the HMI works like the real device.

+

Rearranging Controls

+

In Edit mode, drag any card by its handle (top-left corner) to move it within a page or between pages (drag to the tab name).

+

Adding Controls

+

In Edit mode, use the + Temperature, + Motor, + Output, or + Burner buttons at the bottom to add a new control to the current page.

+

Renaming

+

In Edit mode, click on any card's name to edit it. Press Enter to confirm.

+

Deleting

+

In Edit mode, click the button on the top-right of a card to remove it.

+

Pages / Tabs

+

Click the + button after the last tab to add a new page. Right-click a tab name to rename or delete it.

+

Comments

+

Click the 💬 button on any card to open the comment panel. Type your comment and click Post. Everyone can see all comments.

+

Saving

+

Click Save to save the current layout to the server. Click Export JSON to download the layout file.

+

Machine Simulation

+

The right panel shows a schematic of the dryer. When you toggle controls in Preview mode, the equipment animations respond (fan spins, conveyor moves, burner glows, etc.).

+

Choosing Your Name

+

Use the dropdown in the toolbar to select your name. Click + Add Person if you're not in the list.

+
+ + + + + + +