241 lines
7.7 KiB
Python
241 lines
7.7 KiB
Python
"""
|
|
Flask web UI for HPGL plotter.
|
|
Run from project root so Command, Program, Plotter, hpgl_svg, config can be imported.
|
|
"""
|
|
import os
|
|
import sys
|
|
import uuid
|
|
from flask import Flask, request, jsonify, session, send_from_directory, render_template
|
|
|
|
# Run from project root (parent of webui)
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
from Command import Command
|
|
from Program import Program
|
|
from Plotter import Plotter
|
|
from hpgl_svg import program_to_svg
|
|
|
|
try:
|
|
from config import webui_secret_key, webui_max_upload_mb
|
|
except ImportError:
|
|
webui_secret_key = lambda: os.environ.get('SECRET_KEY', 'change-me-in-production')
|
|
webui_max_upload_mb = lambda: 16
|
|
|
|
app = Flask(__name__, static_folder='static', template_folder='templates')
|
|
app.secret_key = webui_secret_key()
|
|
app.config['MAX_CONTENT_LENGTH'] = webui_max_upload_mb() * 1024 * 1024
|
|
UPLOAD_DIR = os.path.join(ROOT, 'webui', 'uploads')
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
|
|
def _get_program():
|
|
path = session.get('upload_path')
|
|
if not path or not os.path.isfile(path):
|
|
return None
|
|
try:
|
|
return Program.parsefile(path)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _save_program(program, path=None):
|
|
path = path or session.get('upload_path')
|
|
if not path:
|
|
return None
|
|
with open(path, 'w') as f:
|
|
f.write(str(program))
|
|
return path
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return render_template('index.html')
|
|
|
|
|
|
@app.route('/api/upload', methods=['POST'])
|
|
def upload():
|
|
if 'file' not in request.files:
|
|
return jsonify({'error': 'No file'}), 400
|
|
f = request.files['file']
|
|
if not f.filename or not (f.filename.lower().endswith('.hpgl') or f.filename.lower().endswith('.plt')):
|
|
return jsonify({'error': 'Need an .hpgl or .plt file'}), 400
|
|
try:
|
|
content = f.read().decode('utf-8', errors='replace')
|
|
Program.parse(content) # validate
|
|
except Exception as e:
|
|
return jsonify({'error': 'Invalid HPGL: {}'.format(str(e))}), 400
|
|
name = str(uuid.uuid4()) + '.hpgl'
|
|
path = os.path.join(UPLOAD_DIR, name)
|
|
with open(path, 'w') as out:
|
|
out.write(content)
|
|
session['upload_path'] = path
|
|
session['upload_filename'] = f.filename
|
|
return jsonify({
|
|
'ok': True,
|
|
'filename': f.filename,
|
|
'preview_url': '/api/svg'
|
|
})
|
|
|
|
|
|
@app.route('/api/dimensions')
|
|
def dimensions():
|
|
"""Return current program size in plotter units (e.g. 0.025 mm per unit)."""
|
|
p = _get_program()
|
|
if p is None:
|
|
return jsonify({'error': 'No file loaded'}), 404
|
|
w, h = p.winsize
|
|
return jsonify({
|
|
'width': round(w, 1),
|
|
'height': round(h, 1),
|
|
'width_mm': round(w * 0.025, 2),
|
|
'height_mm': round(h * 0.025, 2),
|
|
})
|
|
|
|
|
|
@app.route('/api/svg')
|
|
def get_svg():
|
|
p = _get_program()
|
|
if p is None:
|
|
return jsonify({'error': 'No file loaded'}), 404
|
|
width = request.args.get('width', 800, type=int)
|
|
height = request.args.get('height', 600, type=int)
|
|
show_scale = request.args.get('scale', '1').lower() in ('1', 'true', 'yes')
|
|
svg = program_to_svg(p, width=width, height=height, show_scale_bar=show_scale)
|
|
return svg, 200, {'Content-Type': 'image/svg+xml; charset=utf-8'}
|
|
|
|
|
|
@app.route('/api/transform', methods=['POST'])
|
|
def transform():
|
|
p = _get_program()
|
|
if p is None:
|
|
return jsonify({'error': 'No file loaded'}), 404
|
|
data = request.get_json() or {}
|
|
action = data.get('action')
|
|
if action == 'flip':
|
|
p = p.flip()
|
|
elif action == 'rotate':
|
|
angle = data.get('angle', 90)
|
|
p = p.rotate(angle)
|
|
elif action == 'scale':
|
|
factor = data.get('factor', 1.5)
|
|
p = p * factor
|
|
elif action == 'centralize':
|
|
p = p - p.center
|
|
elif action == 'scale_to_bounds':
|
|
xmin = data.get('xmin')
|
|
ymin = data.get('ymin')
|
|
xmax = data.get('xmax')
|
|
ymax = data.get('ymax')
|
|
if xmin is not None and ymin is not None and xmax is not None and ymax is not None:
|
|
if xmax <= xmin or ymax <= ymin:
|
|
return jsonify({'error': 'Invalid bounds: xmax > xmin and ymax > ymin required.'}), 400
|
|
p = p.fitin((xmin, ymin, xmax, ymax), (0.5, 0.5))
|
|
else:
|
|
try:
|
|
plt = Plotter()
|
|
if getattr(plt, 'boundaries', None) is None:
|
|
return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400
|
|
p = plt.full(p)
|
|
except (OSError, AttributeError, TypeError):
|
|
return jsonify({'error': 'Plotter not connected. Connect plotter or send bounds (xmin, ymin, xmax, ymax).'}), 400
|
|
else:
|
|
return jsonify({'error': 'Unknown action'}), 400
|
|
_save_program(p)
|
|
return jsonify({'ok': True, 'preview_url': '/api/svg'})
|
|
|
|
|
|
@app.route('/api/print', methods=['POST'])
|
|
def print_to_plotter():
|
|
p = _get_program()
|
|
if p is None:
|
|
return jsonify({'error': 'No file loaded'}), 404
|
|
try:
|
|
plt = Plotter()
|
|
if getattr(plt, 'ser', None) is None:
|
|
return jsonify({'error': 'Plotter not connected. Connect plotter and try again.'}), 503
|
|
p = plt.full(p)
|
|
p = plt.centralize(p)
|
|
plt.write(p)
|
|
return jsonify({'ok': True})
|
|
except OSError as e:
|
|
return jsonify({'error': 'Plotter not connected: {}'.format(str(e))}), 503
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/status')
|
|
def status():
|
|
has_file = bool(session.get('upload_path')) and os.path.isfile(session.get('upload_path'))
|
|
plotter_ready = False
|
|
try:
|
|
plt = Plotter()
|
|
if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None:
|
|
plotter_ready = plt.ready
|
|
except (OSError, AttributeError, TypeError):
|
|
pass
|
|
return jsonify({
|
|
'has_file': has_file,
|
|
'filename': session.get('upload_filename'),
|
|
'plotter_ready': plotter_ready
|
|
})
|
|
|
|
|
|
@app.route('/api/plotter_info')
|
|
def plotter_info():
|
|
"""Return plotter connection and media dimensions for display under the print button."""
|
|
try:
|
|
from config import plotter_port, plotter_baudrate
|
|
except ImportError:
|
|
plotter_port = lambda: '/dev/ttyUSB0'
|
|
plotter_baudrate = lambda: 9600
|
|
port = plotter_port()
|
|
baudrate = plotter_baudrate()
|
|
connected = False
|
|
ready = False
|
|
boundaries = None
|
|
winsize_units = None
|
|
winsize_mm = None
|
|
try:
|
|
plt = Plotter()
|
|
if getattr(plt, 'ser', None) is not None and getattr(plt, 'boundaries', None) is not None:
|
|
connected = True
|
|
ready = plt.ready
|
|
try:
|
|
boundaries = {
|
|
'xmin': plt.xmin,
|
|
'ymin': plt.ymin,
|
|
'xmax': plt.xmax,
|
|
'ymax': plt.ymax,
|
|
}
|
|
w, h = plt.winsize
|
|
winsize_units = {'width': round(w, 1), 'height': round(h, 1)}
|
|
winsize_mm = {'width': round(w * 0.025, 1), 'height': round(h * 0.025, 1)}
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
except (OSError, AttributeError, TypeError):
|
|
pass
|
|
return jsonify({
|
|
'port': port,
|
|
'baudrate': baudrate,
|
|
'connected': connected,
|
|
'ready': ready,
|
|
'boundaries': boundaries,
|
|
'winsize_units': winsize_units,
|
|
'winsize_mm': winsize_mm,
|
|
})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
from config import webui_host, webui_port
|
|
except ImportError:
|
|
webui_host = lambda: '0.0.0.0'
|
|
webui_port = lambda: 5000
|
|
app.run(
|
|
host=webui_host(),
|
|
port=webui_port(),
|
|
debug=os.environ.get('FLASK_DEBUG', '0') == '1'
|
|
)
|