@@ -81,9 +81,11 @@ def create_app():
|
|||||||
from .routes.auth_routes import auth_bp
|
from .routes.auth_routes import auth_bp
|
||||||
from .routes.admin_routes import admin_bp
|
from .routes.admin_routes import admin_bp
|
||||||
from .routes.printer_routes import printer_bp
|
from .routes.printer_routes import printer_bp
|
||||||
|
from .utils.api_handle import api_bp
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(printer_bp)
|
app.register_blueprint(printer_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -237,5 +237,28 @@
|
|||||||
"Bridge infill": "Brückefüllung",
|
"Bridge infill": "Brückefüllung",
|
||||||
"Top solid infill": "Oberste solide Füllung",
|
"Top solid infill": "Oberste solide Füllung",
|
||||||
"Others": "Andere",
|
"Others": "Andere",
|
||||||
"Are you sure you want to clear the board?": "Sind Sie sicher, dass Sie das Brett leeren möchten?"
|
"Are you sure you want to clear the board?": "Sind Sie sicher, dass Sie das Brett leeren möchten?",
|
||||||
|
"d": "t",
|
||||||
|
"h": "std",
|
||||||
|
"m": "m",
|
||||||
|
"s": "s",
|
||||||
|
"Auto Leveling": "Auto-Nivellierung",
|
||||||
|
"Account Management": "Kontoverwaltung",
|
||||||
|
"Change Password": "Passwort ändern",
|
||||||
|
"Current Password": "Aktuelles Passwort",
|
||||||
|
"Confirm New Password": "Neues Passwort bestätigen",
|
||||||
|
"Update Password": "Passwort aktualisieren",
|
||||||
|
"Active Sessions": "Aktive Sitzungen",
|
||||||
|
"Device": "Gerät",
|
||||||
|
"IP Address": "IP-Adresse",
|
||||||
|
"Last Active": "Zuletzt aktiv",
|
||||||
|
"Action": "Aktion",
|
||||||
|
"This Device": "Dieses Gerät",
|
||||||
|
"Unknown Device": "Unbekanntes Gerät",
|
||||||
|
"Are you sure you want to terminate this session?": "Sind Sie sicher, dass Sie diese Sitzung beenden möchten?",
|
||||||
|
"Logout from this device?": "Von diesem Gerät abmelden?",
|
||||||
|
"No active sessions found.": "Keine aktiven Sitzungen gefunden.",
|
||||||
|
"Please login to view the webcam stream.": "Bitte melden Sie sich an, um die Live-Kamera zu sehen.",
|
||||||
|
"Remember Me": "Erinnere dich an mich",
|
||||||
|
"Merge Guest Data": "Gästendaten zusammenführen"
|
||||||
}
|
}
|
||||||
@@ -237,5 +237,28 @@
|
|||||||
"Bridge infill": "Bridge infill",
|
"Bridge infill": "Bridge infill",
|
||||||
"Top solid infill": "Top solid infill",
|
"Top solid infill": "Top solid infill",
|
||||||
"Others": "Others",
|
"Others": "Others",
|
||||||
"Are you sure you want to clear the board?": "Are you sure you want to clear the board?"
|
"Are you sure you want to clear the board?": "Are you sure you want to clear the board?",
|
||||||
|
"d": "d",
|
||||||
|
"h": "h",
|
||||||
|
"m": "m",
|
||||||
|
"s": "s",
|
||||||
|
"Auto Leveling": "Auto Leveling",
|
||||||
|
"Account Management": "Account Management",
|
||||||
|
"Change Password": "Change Password",
|
||||||
|
"Current Password": "Current Password",
|
||||||
|
"Confirm New Password": "Confirm New Password",
|
||||||
|
"Update Password": "Update Password",
|
||||||
|
"Active Sessions": "Active Sessions",
|
||||||
|
"Device": "Device",
|
||||||
|
"IP Address": "IP Address",
|
||||||
|
"Last Active": "Last Active",
|
||||||
|
"Action": "Action",
|
||||||
|
"This Device": "This Device",
|
||||||
|
"Unknown Device": "Unknown Device",
|
||||||
|
"Are you sure you want to terminate this session?": "Are you sure you want to terminate this session?",
|
||||||
|
"Logout from this device?": "Logout from this device?",
|
||||||
|
"No active sessions found.": "No active sessions found.",
|
||||||
|
"Please login to view the webcam stream.": "Please login to view the webcam stream.",
|
||||||
|
"Remember Me": "Remember Me",
|
||||||
|
"Merge Guest Data": "Merge Guest Data"
|
||||||
}
|
}
|
||||||
@@ -237,5 +237,28 @@
|
|||||||
"Bridge infill": "桥接填充",
|
"Bridge infill": "桥接填充",
|
||||||
"Top solid infill": "顶部实体填充",
|
"Top solid infill": "顶部实体填充",
|
||||||
"Others": "其他",
|
"Others": "其他",
|
||||||
"Are you sure you want to clear the board?": "您确定要清空构建板吗?"
|
"Are you sure you want to clear the board?": "您确定要清空构建板吗?",
|
||||||
|
"d": "天",
|
||||||
|
"h": "时",
|
||||||
|
"m": "分",
|
||||||
|
"s": "秒",
|
||||||
|
"Auto Leveling": "自动调平",
|
||||||
|
"Account Management": "账号管理",
|
||||||
|
"Change Password": "修改密码",
|
||||||
|
"Current Password": "当前密码",
|
||||||
|
"Confirm New Password": "确认新密码",
|
||||||
|
"Update Password": "更新密码",
|
||||||
|
"Active Sessions": "活跃会话",
|
||||||
|
"Device": "设备",
|
||||||
|
"IP Address": "IP 地址",
|
||||||
|
"Last Active": "最后活跃",
|
||||||
|
"Action": "操作",
|
||||||
|
"This Device": "当前设备",
|
||||||
|
"Unknown Device": "未知设备",
|
||||||
|
"Are you sure you want to terminate this session?": "您确定要终止此会话吗?",
|
||||||
|
"Logout from this device?": "从此设备注销?",
|
||||||
|
"No active sessions found.": "未找到活跃的会话。",
|
||||||
|
"Please login to view the webcam stream.": "请登录以查看实时摄像头。",
|
||||||
|
"Remember Me": "记住我",
|
||||||
|
"Merge Guest Data": "合并访客数据"
|
||||||
}
|
}
|
||||||
@@ -39,3 +39,9 @@ class SystemConfig(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
key = db.Column(db.String(50), unique=True, nullable=False)
|
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
value = db.Column(db.String(255), nullable=False)
|
value = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
class ApiKey(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -191,3 +191,34 @@ def delete_user(user_id):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api_keys')
|
||||||
|
def api_keys():
|
||||||
|
from app.models import ApiKey
|
||||||
|
keys = ApiKey.query.order_by(ApiKey.created_at.desc()).all()
|
||||||
|
return render_template('admin/api_keys.html', keys=keys)
|
||||||
|
|
||||||
|
@admin_bp.route('/api_key/add', methods=['POST'])
|
||||||
|
def add_api_key():
|
||||||
|
from app.models import ApiKey
|
||||||
|
import secrets
|
||||||
|
name = request.form.get('name')
|
||||||
|
if not name:
|
||||||
|
flash("Name is required", "danger")
|
||||||
|
return redirect(url_for('admin.api_keys'))
|
||||||
|
|
||||||
|
key_value = secrets.token_urlsafe(32)
|
||||||
|
new_key = ApiKey(name=name, key=key_value)
|
||||||
|
db.session.add(new_key)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"API Key '{name}' created.", "success")
|
||||||
|
return redirect(url_for('admin.api_keys'))
|
||||||
|
|
||||||
|
@admin_bp.route('/api_key/<int:key_id>/delete', methods=['POST'])
|
||||||
|
def delete_api_key(key_id):
|
||||||
|
from app.models import ApiKey
|
||||||
|
key = ApiKey.query.get_or_404(key_id)
|
||||||
|
db.session.delete(key)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'API Key {key.name} deleted.', 'success')
|
||||||
|
return redirect(url_for('admin.api_keys'))
|
||||||
|
|||||||
@@ -298,7 +298,18 @@ def preview_gcode(file_id):
|
|||||||
|
|
||||||
content = "File not found or not ready."
|
content = "File not found or not ready."
|
||||||
line_count = 0
|
line_count = 0
|
||||||
|
|
||||||
|
time_info = "-"
|
||||||
|
layer1_time = "-"
|
||||||
|
filament_used = "-"
|
||||||
|
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
|
from app.utils.gcode_parser import get_gcode_metadata
|
||||||
|
metadata = get_gcode_metadata(filepath)
|
||||||
|
time_info = metadata.get('print_time', '-')
|
||||||
|
layer1_time = metadata.get('first_layer_time', '-')
|
||||||
|
filament_used = metadata.get('filament_used', '-')
|
||||||
|
|
||||||
with open(filepath, 'r') as f:
|
with open(filepath, 'r') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
line_count = len(lines)
|
line_count = len(lines)
|
||||||
@@ -313,7 +324,8 @@ def preview_gcode(file_id):
|
|||||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||||
offset_x = float(configs.get('offset_x', '0.0'))
|
offset_x = float(configs.get('offset_x', '0.0'))
|
||||||
offset_y = float(configs.get('offset_y', '0.0'))
|
offset_y = float(configs.get('offset_y', '0.0'))
|
||||||
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y)
|
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y,
|
||||||
|
time_info=time_info, layer1_time=layer1_time, filament_used=filament_used)
|
||||||
|
|
||||||
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
|
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ def _enrich_job_data(job_data):
|
|||||||
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
|
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
|
||||||
from app.models import PrintFile
|
from app.models import PrintFile
|
||||||
internal_name = job_data['job']['file']['name']
|
internal_name = job_data['job']['file']['name']
|
||||||
print_file = PrintFile.query.filter_by(filename=internal_name).first()
|
internal_stl_name = str(internal_name)[:-5]+"stl"
|
||||||
if print_file and print_file.original_filename:
|
user_files = {}
|
||||||
job_data['job']['file']['display_name'] = print_file.original_filename
|
print_file = None
|
||||||
|
for f in PrintFile.query.filter_by(user_id=current_user.id, status='sliced').all():
|
||||||
|
user_files[f.filename] = f.original_filename
|
||||||
|
if internal_stl_name in user_files.keys():
|
||||||
|
print_file = user_files[str(internal_name)[:-5]+"stl"]
|
||||||
|
if print_file:
|
||||||
|
job_data['job']['file']['display_name'] = print_file
|
||||||
else:
|
else:
|
||||||
job_data['job']['file']['display_name'] = internal_name
|
job_data['job']['file']['display_name'] = internal_name
|
||||||
return job_data
|
return job_data
|
||||||
@@ -40,9 +46,13 @@ def status():
|
|||||||
if client:
|
if client:
|
||||||
try:
|
try:
|
||||||
status_data = client.get_printer_status()
|
status_data = client.get_printer_status()
|
||||||
|
print(status_data)
|
||||||
|
print(client.get_job_info())
|
||||||
job_data = _enrich_job_data(client.get_job_info())
|
job_data = _enrich_job_data(client.get_job_info())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
print(error)
|
||||||
else:
|
else:
|
||||||
error = "OctoPrint is not configured."
|
error = "OctoPrint is not configured."
|
||||||
|
|
||||||
@@ -73,6 +83,7 @@ def get_gcode_dir():
|
|||||||
def prepare():
|
def prepare():
|
||||||
from app.models import PrintFile
|
from app.models import PrintFile
|
||||||
import os
|
import os
|
||||||
|
from app.utils.gcode_parser import get_gcode_metadata
|
||||||
|
|
||||||
# Query only the sliced GCode files belonging to the current user
|
# Query only the sliced GCode files belonging to the current user
|
||||||
user_files = PrintFile.query.filter_by(user_id=current_user.id, status='sliced').order_by(PrintFile.created_at.desc()).all()
|
user_files = PrintFile.query.filter_by(user_id=current_user.id, status='sliced').order_by(PrintFile.created_at.desc()).all()
|
||||||
@@ -95,8 +106,16 @@ def prepare():
|
|||||||
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
||||||
|
|
||||||
size = 0
|
size = 0
|
||||||
|
f.meta_print_time = '-'
|
||||||
|
f.meta_first_layer_time = '-'
|
||||||
|
f.meta_filament_used = '-'
|
||||||
|
|
||||||
if os.path.exists(gcode_path):
|
if os.path.exists(gcode_path):
|
||||||
size = os.path.getsize(gcode_path)
|
size = os.path.getsize(gcode_path)
|
||||||
|
metadata = get_gcode_metadata(gcode_path)
|
||||||
|
f.meta_print_time = metadata.get('print_time', '-')
|
||||||
|
f.meta_first_layer_time = metadata.get('first_layer_time', '-')
|
||||||
|
f.meta_filament_used = metadata.get('filament_used', '-')
|
||||||
|
|
||||||
# Upload to OctoPrint if not found but exists locally
|
# Upload to OctoPrint if not found but exists locally
|
||||||
if client and gcode_filename not in octo_files_dict and size > 0:
|
if client and gcode_filename not in octo_files_dict and size > 0:
|
||||||
@@ -118,7 +137,10 @@ def prepare():
|
|||||||
'size': size,
|
'size': size,
|
||||||
'origin': 'local',
|
'origin': 'local',
|
||||||
'path': gcode_filename,
|
'path': gcode_filename,
|
||||||
'gcodeAnalysis': analysis
|
'gcodeAnalysis': analysis,
|
||||||
|
'meta_print_time': f.meta_print_time,
|
||||||
|
'meta_first_layer_time': f.meta_first_layer_time,
|
||||||
|
'meta_filament_used': f.meta_filament_used
|
||||||
})
|
})
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
@@ -220,6 +242,8 @@ def api_command():
|
|||||||
try:
|
try:
|
||||||
if cmd == 'home':
|
if cmd == 'home':
|
||||||
client.home_axes()
|
client.home_axes()
|
||||||
|
elif cmd == 'auto_level':
|
||||||
|
client.auto_leveling()
|
||||||
elif cmd == 'pause':
|
elif cmd == 'pause':
|
||||||
client.pause_print()
|
client.pause_print()
|
||||||
elif cmd == 'cancel':
|
elif cmd == 'cancel':
|
||||||
|
|||||||
48
app/templates/admin/api_keys.html
Normal file
48
app/templates/admin/api_keys.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>API Keys Management</h2>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">Create New API Key</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('admin.add_api_key') }}" method="POST" class="form-inline">
|
||||||
|
<input type="text" name="name" class="form-control mb-2 mr-sm-2" placeholder="Key Name" required>
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Generate Key</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key in keys %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key.id }}</td>
|
||||||
|
<td>{{ key.name }}</td>
|
||||||
|
<td><code>{{ key.key }}</code></td>
|
||||||
|
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ url_for('admin.delete_api_key', key_id=key.id) }}" method="POST" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete this API Key?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">No API keys found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -43,10 +43,22 @@
|
|||||||
|
|
||||||
/* 提升 Accordion 折叠栏动画更平滑 */
|
/* 提升 Accordion 折叠栏动画更平滑 */
|
||||||
.collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; }
|
.collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; }
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
body { padding-top: 126px !important; }
|
||||||
|
.sidebar { top: 126px; width: 100%; border-bottom: 1px solid #ddd; box-shadow: 0 4px 6px rgba(0,0,0,.1); }
|
||||||
|
.sidebar-sticky { height: calc(100vh - 126px); }
|
||||||
|
.mobile-subnav { display: flex !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 454.98px) {
|
||||||
|
.navbar-brand { display: none !important; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
|
<div class="fixed-top">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
|
||||||
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
|
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
|
||||||
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
|
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
|
||||||
|
|
||||||
@@ -82,6 +94,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- 移动端专属二级导航栏 -->
|
||||||
|
<div class="d-none mobile-subnav align-items-center bg-white border-bottom px-3 py-2 shadow-sm d-md-none" style="justify-content: space-between;">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-expanded="false" aria-controls="sidebarMenu">
|
||||||
|
<i class="bi bi-list fs-4"></i>
|
||||||
|
</button>
|
||||||
|
<div class="btn-group border border-secondary shadow-sm rounded-pill p-1 bg-dark" role="group" style="background-color: #1a1e21 !important;">
|
||||||
|
<a href="{{ url_for('main.files') }}" class="btn btn-sm rounded-pill {% if not request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-3{% else %}btn-transparent text-secondary border-0 px-2{% endif %}" style="transition: all 0.2s;"><i class="bi bi-layers me-1"></i>{{ _('Slicer') }}</a>
|
||||||
|
<a href="{{ url_for('printer.status') }}" class="btn btn-sm rounded-pill {% if request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-3{% else %}btn-transparent text-secondary border-0 px-2{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
|
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
|
||||||
@@ -178,6 +202,11 @@
|
|||||||
<i class="bi bi-people me-2"></i>{{ _('User Management') }}
|
<i class="bi bi-people me-2"></i>{{ _('User Management') }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-dark {% if request.endpoint == 'admin.api_keys' %}active text-white shadow-sm{% endif %}" href="{{ url_for('admin.api_keys') }}">
|
||||||
|
<i class="bi bi-key me-2"></i>API Keys
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -37,11 +37,23 @@
|
|||||||
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
|
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
|
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
|
||||||
|
<!-- Motion Controls -->
|
||||||
|
<div class="d-flex gap-4 justify-content-center mb-4 w-100">
|
||||||
<!-- Home button -->
|
<!-- Home button -->
|
||||||
<button class="btn btn-lg btn-primary rounded-circle mb-4 shadow" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}">
|
<div>
|
||||||
|
<button class="btn btn-lg btn-primary rounded-circle shadow mb-2" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}">
|
||||||
<i class="bi bi-house-door fs-2"></i>
|
<i class="bi bi-house-door fs-2"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="text-muted mb-4">{{ _('Home All Axes') }} (G28)</div>
|
<div class="text-muted small">{{ _('Home All Axes') }}<br>(G28)</div>
|
||||||
|
</div>
|
||||||
|
<!-- Auto Level button -->
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-lg btn-info rounded-circle shadow mb-2 text-white" style="width: 80px; height: 80px;" onclick="sendCommand('auto_level')" title="{{ _('Auto Leveling') }}">
|
||||||
|
<i class="bi bi-grid-3x3 fs-2"></i>
|
||||||
|
</button>
|
||||||
|
<div class="text-muted small">{{ _('Auto Leveling') }}<br>(G29)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick macros -->
|
<!-- Quick macros -->
|
||||||
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
|
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
|
||||||
@@ -59,7 +71,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function sendCommand(cmdName) {
|
function sendCommand(cmdName) {
|
||||||
if (cmdName === 'cancel' || cmdName === 'home') {
|
if (cmdName === 'cancel' || cmdName === 'home' || cmdName === 'auto_level') {
|
||||||
window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName));
|
window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName));
|
||||||
} else {
|
} else {
|
||||||
doSendCommand(cmdName);
|
doSendCommand(cmdName);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div id="file-{{ f.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 transition-style">
|
<div id="file-{{ f.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 transition-style">
|
||||||
<div class="me-auto text-truncate" style="max-width: 80%;">
|
<div class="me-auto text-truncate" style="max-width: 80%;">
|
||||||
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
|
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
|
||||||
<small class="text-muted d-block">{{ _('Size:') }} {{ f.size }} bytes, {{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s</small>
|
<small class="text-muted d-block pb-1">{{ _('Size:') }} {{ f.size | filesizeformat }}, {% if f.meta_print_time and f.meta_print_time != '-' %}{{ _('Estimated Time:') }} {{ f.meta_print_time }}{% else %}{{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s{% endif %}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('main.preview_gcode', file_id=f.id) }}" class="btn btn-sm btn-outline-info rounded-pill px-3 shadow-sm me-2"><i class="bi bi-eye me-1"></i>{{ _('Preview') }}</a>
|
<a href="{{ url_for('main.preview_gcode', file_id=f.id) }}" class="btn btn-sm btn-outline-info rounded-pill px-3 shadow-sm me-2"><i class="bi bi-eye me-1"></i>{{ _('Preview') }}</a>
|
||||||
|
|||||||
@@ -67,8 +67,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between text-muted small mt-2">
|
<div class="d-flex justify-content-between text-muted small mt-2">
|
||||||
<span><strong>{{ _('Print Time:') }}</strong> <span id="job-print-time">{{ job.get('progress', {}).get('printTime', 0) if job else 0 }}</span>s</span>
|
<span><strong>{{ _('Print Time:') }}</strong> <span id="job-print-time"></span></span>
|
||||||
<span><strong>{{ _('Time Left:') }}</strong> <span id="job-time-left">{{ job.get('progress', {}).get('printTimeLeft', 0) if job else 0 }}</span>s</span>
|
<span><strong>{{ _('Time Left:') }}</strong> <span id="job-time-left"></span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 gap-2 d-flex">
|
<div class="mt-4 gap-2 d-flex">
|
||||||
@@ -81,6 +81,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds && seconds !== 0) return '0{{ _("s") }}';
|
||||||
|
seconds = Math.round(seconds);
|
||||||
|
let d = Math.floor(seconds / 86400);
|
||||||
|
let h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
let m = Math.floor((seconds % 3600) / 60);
|
||||||
|
let s = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
let res = [];
|
||||||
|
if (d > 0) res.push(d + '{{ _("d") }}');
|
||||||
|
if (h > 0 || d > 0) res.push(h + '{{ _("h") }}');
|
||||||
|
if (m > 0 || h > 0 || d > 0) res.push(m + '{{ _("m") }}');
|
||||||
|
if (s > 0 || res.length === 0) res.push(s + '{{ _("s") }}');
|
||||||
|
return res.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function updateStatus() {
|
function updateStatus() {
|
||||||
fetch('{{ url_for("printer.api_status_data") }}')
|
fetch('{{ url_for("printer.api_status_data") }}')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -105,8 +121,8 @@ function updateStatus() {
|
|||||||
document.getElementById('job-progress-bar').style.width = progress + '%';
|
document.getElementById('job-progress-bar').style.width = progress + '%';
|
||||||
document.getElementById('job-progress-bar').setAttribute('aria-valuenow', progress);
|
document.getElementById('job-progress-bar').setAttribute('aria-valuenow', progress);
|
||||||
document.getElementById('job-progress-text').innerText = progress.toFixed(1) + '%';
|
document.getElementById('job-progress-text').innerText = progress.toFixed(1) + '%';
|
||||||
document.getElementById('job-print-time').innerText = data.job.progress ? (data.job.progress.printTime || 0) : 0;
|
document.getElementById('job-print-time').innerText = formatTime(data.job.progress ? data.job.progress.printTime : 0);
|
||||||
document.getElementById('job-time-left').innerText = data.job.progress ? (data.job.progress.printTimeLeft || 0) : 0;
|
document.getElementById('job-time-left').innerText = formatTime(data.job.progress ? data.job.progress.printTimeLeft : 0);
|
||||||
} else {
|
} else {
|
||||||
jobCard.style.display = 'none';
|
jobCard.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -115,6 +131,8 @@ function updateStatus() {
|
|||||||
.catch(err => console.error("Error fetching status:", err));
|
.catch(err => console.error("Error fetching status:", err));
|
||||||
}
|
}
|
||||||
{% if not error %}
|
{% if not error %}
|
||||||
|
// Run once immediately to populate initial data consistently
|
||||||
|
updateStatus();
|
||||||
setInterval(updateStatus, 1000);
|
setInterval(updateStatus, 1000);
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,22 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<i class="bi bi-display me-2 text-secondary fs-5"></i>
|
<i class="bi bi-display me-2 text-secondary fs-5"></i>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-truncate" style="max-width: 150px" title="{{ s.user_agent }}">{{ s.user_agent.split(' ')[0] if s.user_agent else _('Unknown Device') }}</div>
|
{% set device_name = _('Unknown Device') %}
|
||||||
|
{% if s.user_agent %}
|
||||||
|
{% set ua = s.user_agent|lower %}
|
||||||
|
{% if 'windows' in ua %}
|
||||||
|
{% set device_name = 'Windows' %}
|
||||||
|
{% elif 'android' in ua %}
|
||||||
|
{% set device_name = 'Android' %}
|
||||||
|
{% elif 'iphone' in ua or 'ipad' in ua %}
|
||||||
|
{% set device_name = 'iOS' %}
|
||||||
|
{% elif 'mac' in ua or 'darwin' in ua %}
|
||||||
|
{% set device_name = 'macOS' %}
|
||||||
|
{% elif 'linux' in ua %}
|
||||||
|
{% set device_name = 'Linux' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-truncate" style="max-width: 150px; cursor: help;" title="{{ s.user_agent or _('Unknown Device') }}">{{ device_name }}</div>
|
||||||
{% if s.session_token == current_token %}
|
{% if s.session_token == current_token %}
|
||||||
<span class="badge bg-primary mt-1">{{ _('This Device') }}</span>
|
<span class="badge bg-primary mt-1">{{ _('This Device') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-2 border-bottom">
|
||||||
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
|
||||||
<div>
|
<div>
|
||||||
|
<h1 class="h2 mb-1"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<span class="me-3"><i class="bi bi-clock-history me-1"></i>{{ _('Estimated Time:') }} <span class="fw-bold">{{ time_info }}</span></span>
|
||||||
|
<span class="me-3"><i class="bi bi-layers me-1"></i>{{ _('First Layer Time:') }} <span class="fw-bold">{{ layer1_time }}</span></span>
|
||||||
|
<span><i class="bi bi-rulers me-1"></i>{{ _('Filament Used [mm]:') }} <span class="fw-bold">{{ filament_used }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 mt-md-0">
|
||||||
<a href="{{ url_for('printer.prepare') }}#file-{{ file.id }}" class="btn btn-warning btn-sm rounded shadow-sm fw-bold"><i class="bi bi-printer"></i> {{ _('Go to Print') }}</a>
|
<a href="{{ url_for('printer.prepare') }}#file-{{ file.id }}" class="btn btn-warning btn-sm rounded shadow-sm fw-bold"><i class="bi bi-printer"></i> {{ _('Go to Print') }}</a>
|
||||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm ms-2"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
|
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm ms-2"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
|
||||||
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
|
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
|
||||||
|
|||||||
77
app/utils/api_handle.py
Normal file
77
app/utils/api_handle.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import functools
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.models import ApiKey
|
||||||
|
from app.utils.octoprint_client import OctoPrintClient
|
||||||
|
from app.models import SystemConfig
|
||||||
|
|
||||||
|
api_bp = Blueprint('api_handle', __name__, url_prefix='/api/v1')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def require_api_key(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
api_key_header = request.headers.get('X-Api-Key')
|
||||||
|
if not api_key_header:
|
||||||
|
return jsonify({'error': 'Missing API Key in headers (X-Api-Key)'}), 401
|
||||||
|
|
||||||
|
key_record = ApiKey.query.filter_by(key=api_key_header).first()
|
||||||
|
if not key_record:
|
||||||
|
return jsonify({'error': 'Invalid API Key'}), 401
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
@api_bp.route('/status', methods=['GET'])
|
||||||
|
@require_api_key
|
||||||
|
def get_status():
|
||||||
|
client = get_octo_client()
|
||||||
|
if not client:
|
||||||
|
return jsonify({'error': 'Printer not configured'}), 503
|
||||||
|
try:
|
||||||
|
status_data = client.get_printer_status()
|
||||||
|
job_data = client.get_job_info()
|
||||||
|
return jsonify({'status': status_data, 'job': job_data})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@api_bp.route('/octoprint_client', methods=['POST'])
|
||||||
|
@require_api_key
|
||||||
|
def invoke_octoprint_client():
|
||||||
|
"""
|
||||||
|
Expects JSON payload like:
|
||||||
|
{
|
||||||
|
"method": "pause_print",
|
||||||
|
"kwargs": {"action": "pause"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
client = get_octo_client()
|
||||||
|
if not client:
|
||||||
|
return jsonify({'error': 'Printer not configured'}), 503
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'method' not in data:
|
||||||
|
return jsonify({'error': 'Missing method in JSON payload'}), 400
|
||||||
|
|
||||||
|
method_name = data['method']
|
||||||
|
kwargs = data.get('kwargs', {})
|
||||||
|
args = data.get('args', [])
|
||||||
|
|
||||||
|
if not hasattr(client, method_name):
|
||||||
|
return jsonify({'error': f'Method {method_name} not found on OctoPrintClient'}), 400
|
||||||
|
|
||||||
|
func = getattr(client, method_name)
|
||||||
|
if not callable(func) or method_name.startswith('_'):
|
||||||
|
return jsonify({'error': f'Method {method_name} is not allowed'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
return jsonify({'success': True, 'result': result})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
32
app/utils/gcode_parser.py
Normal file
32
app/utils/gcode_parser.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
def get_gcode_metadata(filepath):
|
||||||
|
metadata = {
|
||||||
|
'print_time': '-',
|
||||||
|
'first_layer_time': '-',
|
||||||
|
'filament_used': '-'
|
||||||
|
}
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read the last few KB to find estimated time and filament used
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
file_size = f.tell()
|
||||||
|
chunk_size = min(65536, file_size) # read last 64KB
|
||||||
|
f.seek(file_size - chunk_size)
|
||||||
|
chunk = f.read().decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
lines = chunk.splitlines()
|
||||||
|
for line in reversed(lines):
|
||||||
|
if line.startswith('; estimated printing time (normal mode) ='):
|
||||||
|
metadata['print_time'] = line.split('=')[1].strip()
|
||||||
|
elif line.startswith('; estimated first layer printing time (normal mode) ='):
|
||||||
|
metadata['first_layer_time'] = line.split('=')[1].strip()
|
||||||
|
elif line.startswith('; filament used [mm] ='):
|
||||||
|
metadata['filament_used'] = line.split('=')[1].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return metadata
|
||||||
@@ -141,6 +141,9 @@ class OctoPrintClient:
|
|||||||
"""Convenience method to home the printer axes."""
|
"""Convenience method to home the printer axes."""
|
||||||
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
|
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
|
||||||
|
|
||||||
|
def auto_leveling(self):
|
||||||
|
return self.send_gcode("G29")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Webcam / Video
|
# Webcam / Video
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ single_extruder_multi_material = 1
|
|||||||
start_filament_gcode = ;Do nothing
|
start_filament_gcode = ;Do nothing
|
||||||
wipe = 1
|
wipe = 1
|
||||||
before_layer_gcode = ;BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n
|
before_layer_gcode = ;BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n
|
||||||
start_gcode = M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM140 S{first_layer_bed_temperature[0]} ;Set final bed temp \nG28 ;Home \n \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nM104 S{first_layer_temperature[0]} ;Set final nozzle temp \nG1 X-13 Y20 Z3 F5000.0 ;Move to out position \nM190 S{first_layer_bed_temperature[0]} ;Wait for bed temp to stabilize \nM109 S{temperature[0]} ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \n G1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800
|
start_gcode = M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM140 S{first_layer_bed_temperature[0]} ;Set final bed temp \nG28 ;Home \n \nG92 E0 ;Reset Extruder \nG1 Z20.0 F3000 ;Move Z Axis up \nM104 S{first_layer_temperature[0]} ;Set final nozzle temp \nG1 X-13 Y20 Z3 F5000.0 ;Move to out position \nM190 S{first_layer_bed_temperature[0]} ;Wait for bed temp to stabilize \nM109 S{first_layer_temperature[0]} ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800
|
||||||
end_gcode = G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z
|
end_gcode = G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z
|
||||||
machine_max_acceleration_travel = 2000,2000
|
machine_max_acceleration_travel = 2000,2000
|
||||||
pause_print_gcode = M25
|
pause_print_gcode = M25
|
||||||
|
|||||||
Reference in New Issue
Block a user