From 74e1709c4c94f702ea486e716e8c887ca4117a13 Mon Sep 17 00:00:00 2001
From: Jan Grewe <jan@faked.org>
Date: Mon, 7 Oct 2024 03:48:54 +0200
Subject: [PATCH] implement file upload

---
 README.md        |   5 ++
 TODO.txt         |   5 ++
 main.py          | 108 ++++++++++++++++++++++++++++++++++++++--
 requirements.txt |   1 +
 web/index.html   |  31 ++++++++++++
 web/js/chitui.js | 127 ++++++++++++++++++++++++++++++++++++++---------
 web/js/sdcp.js   |   1 +
 7 files changed, 250 insertions(+), 28 deletions(-)
 create mode 100644 TODO.txt

diff --git a/README.md b/README.md
index 6c3cc2c..187e7c5 100644
--- a/README.md
+++ b/README.md
@@ -22,3 +22,8 @@ As ChitUI needs to broadcast UDP messages on your network segment, running ChitU
 docker build -t chitui:latest .
 docker run --rm --name chitui --net=host chitui:latest
 ```
+
+## Configuration
+Configuration is done via environment variables:
+* `PORT` to set the HTTP port of the web interface (default: `54780`)
+* `DEBUG` to enable debug logging, log colorization and code reloading (default: `False`)
diff --git a/TODO.txt b/TODO.txt
new file mode 100644
index 0000000..7dd7e55
--- /dev/null
+++ b/TODO.txt
@@ -0,0 +1,5 @@
+* upload progress should use to-printer upload
+* file deletion
+* manual adding of printers
+* camera frame/stream
+* print job control
diff --git a/main.py b/main.py
index 7372e72..09d34ca 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
-from flask import Flask, send_file
+from flask import Flask, Response, request, jsonify
+from werkzeug.utils import secure_filename
 from flask_socketio import SocketIO
 from threading import Thread
 from loguru import logger
@@ -8,16 +9,23 @@ import os
 import websocket
 import time
 import sys
+import requests
+import hashlib
+import uuid
 
 debug = False
 log_level = "INFO"
-if os.environ.get("DEBUG") is not None and os.environ.get("DEBUG"):
+if os.environ.get("DEBUG") is not None:
     debug = True
     log_level = "DEBUG"
 
+logger.remove()
 logger.add(sys.stdout, colorize=debug, level=log_level)
 
 port = 54780
+if os.environ.get("PORT") is not None:
+    port = os.environ.get("PORT")
+
 discovery_timeout = 1
 app = Flask(__name__,
             static_url_path='',
@@ -26,12 +34,105 @@ socketio = SocketIO(app)
 websockets = {}
 printers = {}
 
+UPLOAD_FOLDER = '/tmp'
+ALLOWED_EXTENSIONS = {'ctb', 'goo'}
+app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
+
 
 @app.route("/")
 def web_index():
     return app.send_static_file('index.html')
 
 
+@app.route('/upload', methods=['GET', 'POST'])
+def upload_file():
+    if request.method == 'POST':
+        if 'file' not in request.files:
+            logger.error("No 'file' parameter in request.")
+            return Response('{"upload": "error", "msg": "Malformed request - no file."}', status=400, mimetype="application/json")
+        file = request.files['file']
+        if file.filename == '':
+            logger.error('No file selected to be uploaded.')
+            return Response('{"upload": "error", "msg": "No file selected."}', status=400, mimetype="application/json")
+        form_data = request.form.to_dict()
+        if 'printer' not in form_data or form_data['printer'] == "":
+            logger.error("No 'printer' parameter in request.")
+            return Response('{"upload": "error", "msg": "Malformed request - no printer."}', status=400, mimetype="application/json")
+        printer = printers[form_data['printer']]
+        if file and not allowed_file(file.filename):
+            logger.error("Invalid filetype.")
+            return Response('{"upload": "error", "msg": "Invalid filetype."}', status=400, mimetype="application/json")
+
+        filename = secure_filename(file.filename)
+        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+        file.save(filepath)
+        logger.debug(
+            "File '{f}' received, uploading to printer '{p}'...", f=filename, p=printer['name'])
+        upload_file(printer['ip'], filepath)
+        return Response('{"upload": "success", "msg": "File uploaded"}', status=200, mimetype="application/json")
+    else:
+        return Response("u r doin it rong", status=405, mimetype='text/plain')
+
+
+def allowed_file(filename):
+    return '.' in filename and \
+           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
+
+
+def upload_file(printer_ip, filepath):
+    part_size = 1048576
+    filename = os.path.basename(filepath)
+    md5_hash = hashlib.md5()
+    with open(filepath, "rb") as f:
+        for byte_block in iter(lambda: f.read(4096), b""):
+            md5_hash.update(byte_block)
+    file_stats = os.stat(filepath)
+    post_data = {
+        'S-File-MD5': md5_hash.hexdigest(),
+        'Check': 1,
+        'Offset': 0,
+        'Uuid': uuid.uuid4(),
+        'TotalSize': file_stats.st_size,
+    }
+    url = 'http://{ip}:3030/uploadFile/upload'.format(ip=printer_ip)
+    num_parts = (int)(file_stats.st_size / part_size)
+    logger.debug("Uploaded file will be split into {} parts", num_parts)
+    i = 0
+    while i <= num_parts:
+        offset = i * part_size
+        with open(filepath, 'rb') as f:
+            f.seek(offset)
+            file_part = f.read(part_size)
+            logger.debug("Uploading part {}/{} (offset: {})", i, num_parts, offset)
+            if not upload_file_part(url, post_data, filename, file_part, offset):
+                logger.error("Uploading file to printer failed.")
+                break
+            logger.debug("Part {}/{} uploaded.", i, num_parts, offset)
+        i += 1
+    os.remove(filepath)
+    return True
+
+
+def upload_file_part(url, post_data, file_name, file_part, offset):
+    post_data['Offset'] = offset
+    post_files = {'File': (file_name, file_part)}
+    #post_files = {'File': file_part}
+    response = requests.post(url, data=post_data, files=post_files)
+    status = json.loads(response.text)
+    if status['success']:
+        return True
+    logger.error(json.loads(response.text))
+    return False
+
+
+def read_in_chunks(file, chunk_size=126976):
+    while True:
+        data = file.read(chunk_size)
+        if not data:
+            break
+        yield data
+
+
 @socketio.on('connect')
 def sio_handle_connect(auth):
     logger.info('Client connected')
@@ -178,9 +279,8 @@ def ws_msg_handler(ws, msg):
 
 def main():
     printers = discover_printers()
-    if len(printers) > 0:
+    if printers:
         connect_printers(printers)
-        logger.info("Setting up connections: done.")
         socketio.emit('printers', printers)
     else:
         logger.error("No printers discovered.")
diff --git a/requirements.txt b/requirements.txt
index 2b66ca5..c0c13ee 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 flask==3.0.3
 flask-socketio==5.4.1
 websocket-client==1.8.0 
+requests==2.32.3
 loguru==0.7.2
diff --git a/web/index.html b/web/index.html
index a788f29..4512270 100644
--- a/web/index.html
+++ b/web/index.html
@@ -105,7 +105,38 @@
           <div class="tab-content" id="navPanes"></div>
         </div>
 
+        <div class="card mt-3">
+          <div class="card-header">
+            File Upload
+          </div>
+          <div class="card-body">
+            <div class="">
+              <form enctype="multipart/form-data" method="post" id="formUpload">
+                <!-- <label for="formFile" class="form-label">Default file input example</label> -->
+                <input id="uploadFile" class="form-control" type="file" name="file">
+                <input id="uploadPrinter" type="hidden" name="printer" value="">
+                <div class="progress mt-3" role="progressbar" aria-label="Example with label" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100">
+                  <div id="progressUpload" class="progress-bar" style="width: 0%">0%</div>
+                </div>
+                <button id="btnUpload" type="button" class="btn btn-secondary mt-3">Upload</button>
+              </form>
+            </div>
+          </div>        
+        </div>        
+        
+        <div id="toastUpload" class="toast position-fixed top-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
+          <div class="toast-header bg-body-secondary">
+            <i class="bi bi-file-earmark-arrow-up"></i>&nbsp;
+            <strong class="me-auto">File Upload</strong>
+            <small>11 mins ago</small>
+            <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
+          </div>
+          <div class="toast-body bg-body-tertiary">Success!</div>
+        </div>
+          
+
       </div>
+
     </main>
 
     <template id="tmplPrintersListItem">
diff --git a/web/js/chitui.js b/web/js/chitui.js
index 95f3814..07e9090 100644
--- a/web/js/chitui.js
+++ b/web/js/chitui.js
@@ -2,6 +2,7 @@ const socket = io();
 var websockets = []
 var printers = {}
 
+
 socket.on("connect", () => {
   console.log('socket.io connected: ' + socket.id);
   setServerStatus(true)
@@ -48,45 +49,61 @@ socket.on("printer_notice", (data) => {
 
 socket.on("printer_status", (data) => {
   //console.log(JSON.stringify(data))
-  var fields = {}
+  if (!printers[data.MainboardID].hasOwnProperty('status')) {
+    printers[data.MainboardID]['status'] = {}
+  }
   var filter = ['CurrentStatus', 'PrintScreen', 'ReleaseFilm', 'TempOfUVLED', 'TimeLapseStatus', 'PrintInfo']
   $.each(data.Status, function (key, val) {
     if (filter.includes(key)) {
-      fields[key] = val
+      if (val.length == 1) {
+        val = val[0]
+      }
+      printers[data.MainboardID]['status'][key] = val
     }
   })
-  printers[data.MainboardID]['status'] = fields
+  printer_status = printers[data.MainboardID]['status']
+  // update file list on status change from UNKNOWN_8 to Idle
+  if (typeof printer_status['PreviousStatus'] !== undefined
+    && printer_status['PreviousStatus'] == SDCP_MACHINE_STATUS_UNKNOWN_8
+    && printer_status['CurrentStatus'] == SDCP_MACHINE_STATUS_IDLE) {
+    socket.emit("printer_files", { id: data.MainboardID, url: '/local' })
+  }
+  printers[data.MainboardID]['status']['PreviousStatus'] = printer_status['CurrentStatus']
   updatePrinterStatus(data)
 });
 
 socket.on("printer_attributes", (data) => {
   //console.log(JSON.stringify(data))
-  var fields = {}
+  if (!printers[data.MainboardID].hasOwnProperty('attributes')) {
+    printers[data.MainboardID]['attributes'] = {}
+  }
   var filter = ['Resolution', 'XYZsize', 'NumberOfVideoStreamConnected', 'MaximumVideoStreamAllowed', 'UsbDiskStatus', 'Capabilities', 'SupportFileType', 'DevicesStatus', 'ReleaseFilmMax', 'CameraStatus', 'RemainingMemory', 'TLPNoCapPos', 'TLPStartCapPos', 'TLPInterLayers']
   $.each(data.Attributes, function (key, val) {
     if (filter.includes(key)) {
-      fields[key] = val
+      printers[data.MainboardID]['attributes'][key] = val
     }
   })
-  printers[data.MainboardID]['attributes'] = fields
   //updatePrinterAttributes(data)
 });
 
 function handle_printer_files(id, data) {
-  if (printers[id]['files'] == undefined) {
-    printers[id]['files'] = []
+  files = []
+  if (printers[id]['files'] !== undefined) {
+    files = printers[id]['files']
   }
   $.each(data, function (i, f) {
     if (f.type === 0) {
       getPrinterFiles(id, f.name)
     } else {
-      printers[id]['files'].push(f.name)
-      createTable('Files', [f.name])
+      if (!files.includes(f.name)) {
+        files.push(f.name)
+      }
     }
   })
+  printers[id]['files'] = files
+  createTable('Files', files)
 }
 
-
 function addPrinters(printers) {
   $.each(printers, function (id, printer) {
     var template = $("#tmplPrintersListItem").html()
@@ -121,7 +138,7 @@ function showPrinter(id) {
   createTable('Status', p.status, true)
   createTable('Attributes', p.attributes)
   createTable('Print', p.status.PrintInfo)
-  
+
   // only get files once
   if (printers[id]['files'] == undefined) {
     getPrinterFiles(id, '/local')
@@ -129,27 +146,29 @@ function showPrinter(id) {
       getPrinterFiles(id, '/usb')
     }
   }
+
+  $('#uploadPrinter').val(id)
 }
 
 function createTable(name, data, active = false) {
-  if ($('#tab-'+name).length == 0) {
+  if ($('#tab-' + name).length == 0) {
     var tTab = $("#tmplNavTab").html()
     var tab = $(tTab)
-    tab.find('button').attr('id', 'tab-'+name)
-    tab.find('button').attr('data-bs-target', '#tab'+name)
+    tab.find('button').attr('id', 'tab-' + name)
+    tab.find('button').attr('data-bs-target', '#tab' + name)
     tab.find('button').text(name)
-    if(active) {
+    if (active) {
       tab.find('button').addClass('active')
     }
     $('#navTabs').append(tab)
   }
 
-  if ($('#tab'+name).length == 0) {
+  if ($('#tab' + name).length == 0) {
     var tPane = $("#tmplNavPane").html()
     var pane = $(tPane)
-    pane.attr('id', 'tab'+name)
-    pane.find('tbody').attr('id', 'table'+name)
-    if(active) {
+    pane.attr('id', 'tab' + name)
+    pane.find('tbody').attr('id', 'table' + name)
+    if (active) {
       pane.addClass('active')
     }
     $('#navPanes').append(pane)
@@ -158,7 +177,8 @@ function createTable(name, data, active = false) {
 }
 
 function fillTable(table, data) {
-  var t = $('#table'+table)
+  var t = $('#table' + table)
+  t.empty()
   $.each(data, function (key, val) {
     if (typeof val === 'object') {
       val = JSON.stringify(val)
@@ -192,9 +212,13 @@ function updatePrinterStatus(data) {
       updatePrinterStatusIcon(data.MainboardID, "info", true)
       break
     case SDCP_MACHINE_STATUS_DEVICES_TESTING:
-      info.text("Device Self-Test")
+      info.text("Devices Self-Test")
       updatePrinterStatusIcon(data.MainboardID, "warning", true)
       break
+    case SDCP_MACHINE_STATUS_UNKNOWN_8:
+      info.text("UNKNOWN STATUS")
+      updatePrinterStatusIcon(data.MainboardID, "info", true)
+      break
     default:
       break
   }
@@ -226,18 +250,73 @@ function setServerStatus(online) {
   }
 }
 
-$('.serverStatus').on("mouseenter", function (e) {
+$('#btnUpload').on('click', function () {
+  uploadFile()
+});
+
+function uploadFile() {
+  var req = $.ajax({
+    url: '/upload',
+    type: 'POST',
+    data: new FormData($('#formUpload')[0]),
+    // Tell jQuery not to process data or worry about content-type
+    // You *must* include these options!
+    cache: false,
+    contentType: false,
+    processData: false,
+    // Custom XMLHttpRequest
+    xhr: function () {
+      var myXhr = $.ajaxSettings.xhr();
+      if (myXhr.upload) {
+        // For handling the progress of the upload
+        myXhr.upload.addEventListener('progress', function (e) {
+          if (e.lengthComputable) {
+            var percent = Math.floor(e.loaded / e.total * 100);
+            $('#progressUpload').text(percent + '%').css('width', percent + '%');
+            if (percent == 100) {
+              $('#progressUpload').addClass('progress-bar-striped progress-bar-animated')
+            }
+          }
+        }, false);
+      }
+      return myXhr;
+    }
+  })
+  req.done(function (data) {
+    $('#uploadFile').val('')
+    $("#toastUpload").show()
+    setTimeout(function () {
+      $("#toastUpload").hide()
+    }, 3000)
+  })
+  req.fail(function (data) {
+    alert(data.responseJSON.msg)
+  })
+  req.always(function () {
+    setTimeout(function () {
+      $('#progressUpload').text('0%').css('width', '0%').removeClass('progress-bar-striped progress-bar-animated');
+    }, 1000)
+  })
+}
+
+$('.serverStatus').on('mouseenter', function (e) {
   if ($(this).hasClass('bi-cloud-check-fill')) {
     $(this).removeClass('bi-cloud-check-fill').addClass('bi-cloud-plus text-primary')
   }
 });
-$('.serverStatus').on("mouseleave", function (e) {
+
+$('.serverStatus').on('mouseleave', function (e) {
   $(this).removeClass('bi-cloud-plus text-primary').addClass('bi-cloud-check-fill')
 });
+
 $('.serverStatus').on('click', function (e) {
   socket.emit("printers", "{}")
 });
 
+$('#toastUpload .btn-close').on('click', function (e) {
+  $("#toastUpload").hide()
+});
+
 /* global bootstrap: false */
 (() => {
   'use strict'
diff --git a/web/js/sdcp.js b/web/js/sdcp.js
index e4a2802..e07d675 100644
--- a/web/js/sdcp.js
+++ b/web/js/sdcp.js
@@ -4,6 +4,7 @@ const SDCP_MACHINE_STATUS_PRINTING = 1  // Executing print task
 const SDCP_MACHINE_STATUS_FILE_TRANSFERRING = 2  // File transfer in progress
 const SDCP_MACHINE_STATUS_EXPOSURE_TESTING = 3  // Exposure test in progress
 const SDCP_MACHINE_STATUS_DEVICES_TESTING = 4  //Device self-check in progress
+const SDCP_MACHINE_STATUS_UNKNOWN_8 = 8  // UNKNOWN, is sent after file transfer
 // PRINT_STATUS
 const SDCP_PRINT_STATUS_IDLE = 0  // Idle
 const SDCP_PRINT_STATUS_HOMING = 1  // Resetting
-- 
GitLab