From 1de35f21d73e4e9e6043b57415626fa31ec50d1b Mon Sep 17 00:00:00 2001 From: lhye200 Date: Mon, 13 Apr 2026 16:32:30 +0800 Subject: [PATCH] tmp --- app/__init__.py | 2 + app/octoprint_client.py | 152 ++++++++++++ app/printer_routes.py | 193 +++++++++++++++ app/routes.py | 38 ++- app/templates/base.html | 56 ++++- app/templates/gcode_preview.html | 4 +- app/templates/plater.html | 309 ++++++++++++++++++++----- app/templates/printer/control.html | 87 +++++++ app/templates/printer/octo_config.html | 42 ++++ app/templates/printer/octo_embed.html | 28 +++ app/templates/printer/prepare.html | 62 +++++ app/templates/printer/status.html | 105 +++++++++ requirements.txt | 2 + stl_simplifier.py | 64 +++++ 14 files changed, 1081 insertions(+), 63 deletions(-) create mode 100644 app/octoprint_client.py create mode 100644 app/printer_routes.py create mode 100644 app/templates/printer/control.html create mode 100644 app/templates/printer/octo_config.html create mode 100644 app/templates/printer/octo_embed.html create mode 100644 app/templates/printer/prepare.html create mode 100644 app/templates/printer/status.html diff --git a/app/__init__.py b/app/__init__.py index 31a7780..d6f8b82 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -75,8 +75,10 @@ def create_app(): db.create_all() from .routes import main_bp, auth_bp, admin_bp + from .printer_routes import printer_bp app.register_blueprint(main_bp) app.register_blueprint(auth_bp) app.register_blueprint(admin_bp) + app.register_blueprint(printer_bp) return app diff --git a/app/octoprint_client.py b/app/octoprint_client.py new file mode 100644 index 0000000..75737ef --- /dev/null +++ b/app/octoprint_client.py @@ -0,0 +1,152 @@ +import requests +from urllib.parse import urljoin + +class OctoPrintClient: + """ + Client for interacting with the OctoPrint API using Application Keys or standard API Keys. + Designed to be easily extensible. + """ + def __init__(self, base_url, api_key=None): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.session = requests.Session() + if self.api_key: + self.session.headers.update({"X-Api-Key": self.api_key}) + + def _request(self, method, endpoint, **kwargs): + """Internal method to handle API requests and standard parsing.""" + url = urljoin(self.base_url, endpoint) + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + + # Octoprint often returns 204 No Content for successful commands + if response.status_code == 204: + return True + + try: + return response.json() + except ValueError: + return response.text + + # ------------------------------------------------------------------------- + # Application Key Workflow + # ------------------------------------------------------------------------- + def request_app_key(self, app_name="My OctoPrint App"): + """ + Step 1: Start the Application Key authorization flow. + Returns: (app_token, client_token) + You should poll verify_app_key(client_token) until the user allows it in OctoPrint UI. + """ + data = self._request("POST", "/plugin/appkeys/request", json={"app": app_name}) + return data.get("app_token"), data.get("client_token") + + def verify_app_key(self, client_token): + """ + Step 2: Check if the requested app key is approved. + Returns True if authorized, False if still pending. + Raises an exception if denied or timed out. + """ + url = urljoin(self.base_url, "/plugin/appkeys/probe") + # App Key probe requires passing the client_token as a Bearer token + # Don't use self._request since it uses the established session/api_key + response = requests.get(url, headers={"Authorization": f"Bearer {client_token}"}) + + if response.status_code == 204: + return True + elif response.status_code == 202: + return False # Pending approval + else: + raise Exception(f"App key request denied or expired (HTTP {response.status_code})") + + # ------------------------------------------------------------------------- + # Files + # ------------------------------------------------------------------------- + def get_files(self, location="local"): + """ + Retrieve all files available on OctoPrint. + location: 'local' (internal storage) or 'sdcard' (SD card on printer) + """ + return self._request("GET", f"/api/files/{location}") + + def select_file(self, location, path, print_after_select=False): + """Select a file, and optionally start printing it immediately.""" + payload = {"command": "select", "print": print_after_select} + return self._request("POST", f"/api/files/{location}/{path}", json=payload) + + # ------------------------------------------------------------------------- + # Printer Status + # ------------------------------------------------------------------------- + def get_printer_status(self): + """ + Get the current printer state (e.g., temperatures, operational state). + Note: If printer is disconnected, this may return an HTTP error. + """ + try: + return self._request("GET", "/api/printer") + except requests.HTTPError as e: + if e.response.status_code == 409: + return {"state": {"text": "Offline/Disconnected"}} + raise + + def get_job_info(self): + """Get information about the current print job and progress.""" + return self._request("GET", "/api/job") + + # ------------------------------------------------------------------------- + # Printer Control + # ------------------------------------------------------------------------- + def start_print(self): + """Start the print job (Requires a file to be selected first).""" + return self._request("POST", "/api/job", json={"command": "start"}) + + def pause_print(self, action="pause"): + """ + Pause, resume, or toggle the print job. + action: 'pause', 'resume', or 'toggle' + """ + return self._request("POST", "/api/job", json={"command": "pause", "action": action}) + + def cancel_print(self): + """Cancel the current print job.""" + return self._request("POST", "/api/job", json={"command": "cancel"}) + + def send_gcode(self, commands): + """ + Send a string or list of G-Code commands directly to the printer. + Example: send_gcode("G28") or send_gcode(["G28", "G29"]) + """ + if isinstance(commands, str): + commands = [commands] + return self._request("POST", "/api/printer/command", json={"commands": commands}) + + def home_axes(self, axes=["x", "y", "z"]): + """Convenience method to home the printer axes.""" + return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes}) + + # ------------------------------------------------------------------------- + # Webcam / Video + # ------------------------------------------------------------------------- + def get_webcam_stream_url(self): + """ + Attempts to fetch the configured webcam stream URL from OctoPrint settings. + Provides a fallback if settings are inaccessible. + """ + try: + settings = self._request("GET", "/api/settings") + stream_url = settings.get("webcam", {}).get("streamUrl", "/webcam/?action=stream") + if stream_url.startswith("/"): + return urljoin(self.base_url, stream_url) + return stream_url + except requests.HTTPError: + # Fallback standard URL + return urljoin(self.base_url, "/webcam/?action=stream") + +# --- Example Usage / Extensibility test --- +if __name__ == "__main__": + # Example snippet of how to use the client: + OCTOPRINT_URL = "http://octopi.local" + # OCTOPRINT_KEY = "YOUR_APP_KEY_HERE" + + # client = OctoPrintClient(OCTOPRINT_URL, OCTOPRINT_KEY) + # print(client.get_printer_status()) + pass diff --git a/app/printer_routes.py b/app/printer_routes.py new file mode 100644 index 0000000..6c10a6d --- /dev/null +++ b/app/printer_routes.py @@ -0,0 +1,193 @@ +from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response +from flask_login import login_required, current_user +import requests +from .models import SystemConfig, db +from .octoprint_client import OctoPrintClient + +printer_bp = Blueprint('printer', __name__, url_prefix='/printer') + +def get_octo_client(): + url = SystemConfig.query.filter_by(key='octoprint_url').first() + apikey = SystemConfig.query.filter_by(key='octoprint_apikey').first() + if url and url.value and apikey and apikey.value: + return OctoPrintClient(url.value, apikey.value) + return None + +@printer_bp.route('/status') +@login_required +def status(): + client = get_octo_client() + status_data = None + job_data = None + error = None + if client: + try: + status_data = client.get_printer_status() + job_data = client.get_job_info() + except Exception as e: + error = str(e) + else: + error = "OctoPrint is not configured." + + return render_template('printer/status.html', status=status_data, job=job_data, error=error) + +@printer_bp.route('/prepare') +@login_required +def prepare(): + client = get_octo_client() + files = [] + error = None + if client: + try: + res = client.get_files() + files = res.get('files', []) + except Exception as e: + error = str(e) + else: + error = "OctoPrint is not configured." + return render_template('printer/prepare.html', files=files, error=error) + +@printer_bp.route('/api/print_file', methods=['POST']) +@login_required +def api_print_file(): + path = request.json.get('path') + location = request.json.get('origin', 'local') + client = get_octo_client() + if client and path: + try: + client.select_file(location, path, print_after_select=True) + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + return jsonify({"success": False, "error": "Not configured or missing path"}) + +@printer_bp.route('/control') +@login_required +def control(): + client = get_octo_client() + webcam_url = None + error = None + if client: + try: + webcam_url = client.get_webcam_stream_url() + except Exception as e: + error = str(e) + else: + error = "OctoPrint is not configured." + return render_template('printer/control.html', webcam_url=webcam_url, error=error) + +@printer_bp.route('/api/command', methods=['POST']) +@login_required +def api_command(): + cmd = request.json.get('command') + client = get_octo_client() + if client and cmd: + try: + if cmd == 'home': + client.home_axes() + elif cmd == 'pause': + client.pause_print() + elif cmd == 'cancel': + client.cancel_print() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + return jsonify({"success": False, "error": "Invalid client or command"}) + +@printer_bp.route('/octo_config', methods=['GET', 'POST']) +@login_required +def octo_config(): + if not current_user.is_admin: + flash("Admin access required", "danger") + return redirect(url_for('printer.status')) + + if request.method == 'POST': + url = request.form.get('octoprint_url', '') + apikey = request.form.get('octoprint_apikey', '') + + conf_url = SystemConfig.query.filter_by(key='octoprint_url').first() + if not conf_url: + conf_url = SystemConfig(key='octoprint_url') + db.session.add(conf_url) + conf_url.value = url.rstrip('/') + + conf_key = SystemConfig.query.filter_by(key='octoprint_apikey').first() + if not conf_key: + conf_key = SystemConfig(key='octoprint_apikey') + db.session.add(conf_key) + conf_key.value = apikey + + db.session.commit() + flash("OctoPrint settings updated", "success") + return redirect(url_for('printer.octo_config')) + + configs = {c.key: c.value for c in SystemConfig.query.all()} + return render_template('printer/octo_config.html', configs=configs) + +@printer_bp.route('/octo_embed') +@login_required +def octo_embed(): + if not current_user.is_admin: + flash("Admin access required", "danger") + return redirect(url_for('printer.status')) + + url = SystemConfig.query.filter_by(key='octoprint_url').first() + embed_url = url_for('printer.octo_proxy') if url and url.value else None + return render_template('printer/octo_embed.html', embed_url=embed_url) + +@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) +@printer_bp.route('/proxy/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) +@login_required +def octo_proxy(path): + if not current_user.is_admin: + return "Unauthorized", 403 + + url_config = SystemConfig.query.filter_by(key='octoprint_url').first() + if not url_config or not url_config.value: + return "OctoPrint URL not configured", 404 + + from urllib.parse import urlparse + base_url = url_config.value.rstrip('/') + target_url = f"{base_url}/{path}" + + if request.query_string: + target_url = f"{target_url}?{request.query_string.decode('utf-8')}" + + # Build headers for reverse proxy, masking origin/referer to avoid CSRF + parsed_base = urlparse(base_url) + headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']} + headers['Host'] = parsed_base.netloc + + if 'Origin' in headers: + headers['Origin'] = base_url + if 'Referer' in headers: + headers['Referer'] = f"{base_url}/{path}" + + headers['X-Forwarded-For'] = request.remote_addr + headers['X-Forwarded-Host'] = request.host + headers['X-Forwarded-Proto'] = request.scheme + headers['X-Script-Name'] = '/printer/proxy' + + try: + resp = requests.request( + method=request.method, + url=target_url, + headers=headers, + data=request.get_data(), + cookies=request.cookies, + allow_redirects=False, + stream=True + ) + except requests.exceptions.RequestException as e: + return f"Proxy connection error: {str(e)}", 502 + + # Strip headers that might break the iframe or framing + excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy'] + response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers] + + def generate(): + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + yield chunk + + return Response(generate(), resp.status_code, response_headers) diff --git a/app/routes.py b/app/routes.py index b6c8c38..caf57a9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -415,7 +415,16 @@ def merge_and_slice(): inputs.append((path, p['matrix'])) # 只有在单一编辑模式才修改原模型的矩阵 (如果多模型/新建模式,我们不修改原模型,而是后续记录到新的包含实体上) if 'raw_matrix' in p and is_edit and len(pieces) == 1: - f.transform_matrix = json.dumps(p['raw_matrix']) + f.transform_matrix = json.dumps({ + "is_composite": False, + "matrix": p['raw_matrix'], + "settings": { + "quality": quality, + "infill": infill_density, + "support": support_enable, + "support_pattern": support_pattern + } + }) db.session.add(f) db.session.commit() @@ -431,7 +440,13 @@ def merge_and_slice(): if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix: composite_data = { "is_composite": True, - "parts": [] + "parts": [], + "settings": { + "quality": quality, + "infill": infill_density, + "support": support_enable, + "support_pattern": support_pattern + } } for p in pieces: pf = PrintFile.query.get(p['file_id']) @@ -444,7 +459,16 @@ def merge_and_slice(): }) print_file.transform_matrix = json.dumps(composite_data) elif len(pieces) == 1: - print_file.transform_matrix = json.dumps(pieces[0].get('raw_matrix', pieces[0]['matrix'])) + print_file.transform_matrix = json.dumps({ + "is_composite": False, + "matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']), + "settings": { + "quality": quality, + "infill": infill_density, + "support": support_enable, + "support_pattern": support_pattern + } + }) db.session.commit() @@ -474,7 +498,13 @@ def merge_and_slice(): # 构建组合文件元数据树 (is_composite: true) composite_data = { "is_composite": True, - "parts": [] + "parts": [], + "settings": { + "quality": quality, + "infill": infill_density, + "support": support_enable, + "support_pattern": support_pattern + } } for p in pieces: pf = PrintFile.query.get(p['file_id']) diff --git a/app/templates/base.html b/app/templates/base.html index ea4d5bf..84a155a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -27,9 +27,17 @@