@@ -81,9 +81,11 @@ def create_app():
|
||||
from .routes.auth_routes import auth_bp
|
||||
from .routes.admin_routes import admin_bp
|
||||
from .routes.printer_routes import printer_bp
|
||||
from .utils.api_handle import api_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(printer_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
return app
|
||||
|
||||
@@ -237,5 +237,28 @@
|
||||
"Bridge infill": "Brückefüllung",
|
||||
"Top solid infill": "Oberste solide Füllung",
|
||||
"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",
|
||||
"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?"
|
||||
"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": "桥接填充",
|
||||
"Top solid infill": "顶部实体填充",
|
||||
"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)
|
||||
key = db.Column(db.String(50), unique=True, 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."
|
||||
line_count = 0
|
||||
|
||||
time_info = "-"
|
||||
layer1_time = "-"
|
||||
filament_used = "-"
|
||||
|
||||
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:
|
||||
lines = f.readlines()
|
||||
line_count = len(lines)
|
||||
@@ -313,7 +324,8 @@ def preview_gcode(file_id):
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
offset_x = float(configs.get('offset_x', '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'])
|
||||
@login_required
|
||||
|
||||
@@ -23,9 +23,15 @@ def _enrich_job_data(job_data):
|
||||
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
|
||||
from app.models import PrintFile
|
||||
internal_name = job_data['job']['file']['name']
|
||||
print_file = PrintFile.query.filter_by(filename=internal_name).first()
|
||||
if print_file and print_file.original_filename:
|
||||
job_data['job']['file']['display_name'] = print_file.original_filename
|
||||
internal_stl_name = str(internal_name)[:-5]+"stl"
|
||||
user_files = {}
|
||||
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:
|
||||
job_data['job']['file']['display_name'] = internal_name
|
||||
return job_data
|
||||
@@ -40,9 +46,13 @@ def status():
|
||||
if client:
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
print(status_data)
|
||||
print(client.get_job_info())
|
||||
job_data = _enrich_job_data(client.get_job_info())
|
||||
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
print(error)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
|
||||
@@ -73,6 +83,7 @@ def get_gcode_dir():
|
||||
def prepare():
|
||||
from app.models import PrintFile
|
||||
import os
|
||||
from app.utils.gcode_parser import get_gcode_metadata
|
||||
|
||||
# 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()
|
||||
@@ -95,8 +106,16 @@ def prepare():
|
||||
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
||||
|
||||
size = 0
|
||||
f.meta_print_time = '-'
|
||||
f.meta_first_layer_time = '-'
|
||||
f.meta_filament_used = '-'
|
||||
|
||||
if os.path.exists(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
|
||||
if client and gcode_filename not in octo_files_dict and size > 0:
|
||||
@@ -118,7 +137,10 @@ def prepare():
|
||||
'size': size,
|
||||
'origin': 'local',
|
||||
'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
|
||||
@@ -220,6 +242,8 @@ def api_command():
|
||||
try:
|
||||
if cmd == 'home':
|
||||
client.home_axes()
|
||||
elif cmd == 'auto_level':
|
||||
client.auto_leveling()
|
||||
elif cmd == 'pause':
|
||||
client.pause_print()
|
||||
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,11 +43,23 @@
|
||||
|
||||
/* 提升 Accordion 折叠栏动画更平滑 */
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
|
||||
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
|
||||
<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">
|
||||
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
|
||||
|
||||
<div class="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);">
|
||||
@@ -82,6 +94,18 @@
|
||||
</div>
|
||||
</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="row">
|
||||
<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') }}
|
||||
</a>
|
||||
</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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -37,11 +37,23 @@
|
||||
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
|
||||
</div>
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
|
||||
<!-- 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') }}">
|
||||
<i class="bi bi-house-door fs-2"></i>
|
||||
</button>
|
||||
<div class="text-muted mb-4">{{ _('Home All Axes') }} (G28)</div>
|
||||
<!-- Motion Controls -->
|
||||
<div class="d-flex gap-4 justify-content-center mb-4 w-100">
|
||||
<!-- Home button -->
|
||||
<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>
|
||||
</button>
|
||||
<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 -->
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
|
||||
@@ -59,7 +71,7 @@
|
||||
|
||||
<script>
|
||||
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));
|
||||
} else {
|
||||
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 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>
|
||||
<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>
|
||||
<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 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>{{ _('Time Left:') }}</strong> <span id="job-time-left">{{ job.get('progress', {}).get('printTimeLeft', 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"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 gap-2 d-flex">
|
||||
@@ -81,6 +81,22 @@
|
||||
{% endif %}
|
||||
|
||||
<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() {
|
||||
fetch('{{ url_for("printer.api_status_data") }}')
|
||||
.then(res => res.json())
|
||||
@@ -105,8 +121,8 @@ function updateStatus() {
|
||||
document.getElementById('job-progress-bar').style.width = progress + '%';
|
||||
document.getElementById('job-progress-bar').setAttribute('aria-valuenow', progress);
|
||||
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-time-left').innerText = data.job.progress ? (data.job.progress.printTimeLeft || 0) : 0;
|
||||
document.getElementById('job-print-time').innerText = formatTime(data.job.progress ? data.job.progress.printTime : 0);
|
||||
document.getElementById('job-time-left').innerText = formatTime(data.job.progress ? data.job.progress.printTimeLeft : 0);
|
||||
} else {
|
||||
jobCard.style.display = 'none';
|
||||
}
|
||||
@@ -115,6 +131,8 @@ function updateStatus() {
|
||||
.catch(err => console.error("Error fetching status:", err));
|
||||
}
|
||||
{% if not error %}
|
||||
// Run once immediately to populate initial data consistently
|
||||
updateStatus();
|
||||
setInterval(updateStatus, 1000);
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -58,7 +58,22 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-display me-2 text-secondary fs-5"></i>
|
||||
<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 %}
|
||||
<span class="badge bg-primary mt-1">{{ _('This Device') }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% 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">
|
||||
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-2 border-bottom">
|
||||
<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('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>
|
||||
|
||||
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."""
|
||||
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
|
||||
|
||||
def auto_leveling(self):
|
||||
return self.send_gcode("G29")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Webcam / Video
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -48,7 +48,7 @@ single_extruder_multi_material = 1
|
||||
start_filament_gcode = ;Do nothing
|
||||
wipe = 1
|
||||
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
|
||||
machine_max_acceleration_travel = 2000,2000
|
||||
pause_print_gcode = M25
|
||||
|
||||
Reference in New Issue
Block a user