# -*- coding: utf-8 -*- """TNC Workshop transfer client: Python 2.7 / 3.4+ standard library only. Run this module to open Tkinter, or import WorkshopClient into an existing app. The server runs on a modern PC. No NC file is executed on a CNC by this client. """ from __future__ import print_function, unicode_literals import base64 import hashlib import json import os import threading from email.message import Message try: text_type = unicode except NameError: text_type = str try: from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError from urllib.parse import urlencode, urlsplit import queue except ImportError: from urllib2 import Request, build_opener, HTTPRedirectHandler, HTTPError from urllib import urlencode from urlparse import urlsplit import Queue as queue class APIError(Exception): def __init__(self, status, message): self.status = status Exception.__init__(self, message) class NoRedirects(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None class WorkshopClient(object): def __init__(self, server_url, api_key, timeout=60): self.server_url = server_url.rstrip('/') parts = urlsplit(self.server_url) if parts.scheme not in ('http', 'https') or not parts.netloc or parts.query or parts.fragment or parts.username or parts.path not in ('', '/'): raise ValueError('Server URL must be http(s)://HOST:PORT without a path') self.api_key = api_key self.timeout = timeout self.opener = build_opener(NoRedirects()) def _request(self, path, method='GET', payload=None, binary=False): data = json.dumps(payload, ensure_ascii=True).encode('utf-8') if payload is not None else None request = Request(self.server_url + path, data=data, headers={'X-API-Key': self.api_key, 'Content-Type': 'application/json', 'Accept': 'application/json'}) request.get_method = lambda: method try: response = self.opener.open(request, timeout=self.timeout) try: body = response.read(32 * 1024 * 1024 + 1) headers = response.info() finally: response.close() if len(body) > 32 * 1024 * 1024: raise APIError(0, 'Response too large') return (body, headers) if binary else json.loads(body.decode('utf-8')) except HTTPError as exc: try: message = json.loads(exc.read().decode('utf-8')).get('error', str(exc)) except (ValueError, UnicodeError): message = 'HTTP {0}'.format(exc.code) raise APIError(exc.code, message) def capabilities(self): return self._request('/api/v1/capabilities/') def list_files(self, offset=0, limit=50): return self._request('/api/v1/files/?' + urlencode({'offset': offset, 'limit': limit})) def upload_file(self, path, options=None): with open(path, 'rb') as handle: content = handle.read(1048577) if not content or len(content) > 1048576: raise ValueError('File must contain 1 byte to 1 MiB') result = self._request('/api/v1/files/', 'POST', { 'filename': os.path.basename(path), 'content_base64': base64.b64encode(content).decode('ascii'), 'options': options or {}}) if result['sha256'] != hashlib.sha256(content).hexdigest(): raise APIError(0, 'Uploaded checksum does not match') return result def get_job(self, file_id): return self._request(self._file_path(file_id) + 'job/')['job'] def update_job(self, file_id, job): return self._request(self._file_path(file_id) + 'job/', 'PUT', job) def preview(self, file_id): return self._request(self._file_path(file_id) + 'preview/') def fetch_download(self, file_id, mode='original', controller=None, reviewed=False): if mode not in ('original', 'generated'): raise ValueError('mode must be original or generated') params = {'mode': mode} if controller: params['controller'] = controller if mode == 'generated': if not reviewed: raise ValueError('Review the NC setup before downloading generated code') params['reviewed'] = 'true' content, headers = self._request(self._file_path(file_id) + 'download/?' + urlencode(params), binary=True) expected = headers.get('X-Content-SHA256') if expected and hashlib.sha256(content).hexdigest() != expected: raise APIError(0, 'Downloaded checksum does not match') message = Message() message['Content-Disposition'] = headers.get('Content-Disposition', '') filename = message.get_filename() or ('program.zip' if 'zip' in headers.get('Content-Type', '') else 'program.nc') filename = os.path.basename(filename.replace('\\', '/')) return {'content': content, 'filename': filename, 'content_type': headers.get('Content-Type', '')} def download_file(self, file_id, destination, mode='original', controller=None, reviewed=False): downloaded = self.fetch_download(file_id, mode, controller, reviewed) # Refuse accidental overwrite. The GUI asks for a new destination. if os.path.exists(destination): raise ValueError('Destination already exists; choose a new filename') with open(destination, 'wb') as handle: handle.write(downloaded['content']) return {'path': destination, 'size': len(downloaded['content']), 'content_type': downloaded['content_type']} @staticmethod def _file_path(file_id): import uuid return '/api/v1/files/{0}/'.format(uuid.UUID(str(file_id))) def run_gui(): try: import tkinter as tk from tkinter import ttk, filedialog, messagebox except ImportError: import Tkinter as tk import ttk import tkFileDialog as filedialog import tkMessageBox as messagebox root = tk.Tk() root.title('TNC Workshop - Desktop Transfer') root.geometry('780x560') frame = ttk.Frame(root, padding=12) frame.pack(fill='both', expand=True) server = tk.StringVar(value='http://192.168.1.10:8080') key = tk.StringVar() controller = tk.StringVar(value='auto') reviewed = tk.BooleanVar(value=False) status = tk.StringVar(value='Enter server address and API key. The server runs on a modern PC.') events = queue.Queue() rows = [] buttons = [] for label, variable, hidden in [('Server URL', server, False), ('API key', key, True)]: ttk.Label(frame, text=label).pack(anchor='w') entry = ttk.Entry(frame, textvariable=variable, show='*' if hidden else '') entry.pack(fill='x', pady=(2, 10)) ttk.Label(frame, text='Source controller / generated download target').pack(anchor='w') ttk.Combobox(frame, textvariable=controller, state='readonly', values=('auto', 'heidenhain', 'fanuc', 'fanuc3m', 'mach3', 'grbl', 'linuxcnc')).pack(fill='x') listing = tk.Listbox(frame, height=12) listing.pack(fill='both', expand=True, pady=10) ttk.Checkbutton(frame, text='I reviewed stock, tools, offsets, feeds and the generated NC setup', variable=reviewed).pack(anchor='w') bar = ttk.Frame(frame) bar.pack(fill='x', pady=10) ttk.Label(frame, textvariable=status, wraplength=730).pack(anchor='w') def client(): return WorkshopClient(server.get().strip(), key.get().strip()) def background(work, done): for button in buttons: button.configure(state='disabled') status.set('Working...') def worker(): try: events.put((True, work(), done)) except Exception as exc: events.put((False, exc, done)) thread = threading.Thread(target=worker) thread.daemon = True thread.start() def poll(): try: success, result, done = events.get_nowait() for button in buttons: button.configure(state='normal') if success: done(result) else: status.set(text_type(result)) messagebox.showerror('Transfer failed', text_type(result)) except queue.Empty: pass root.after(100, poll) def fill_files(data): rows[:] = data['files'] listing.delete(0, 'end') for item in rows: listing.insert('end', '{0} | {1} | {2}'.format(item['filename'], item['status'], item['id'])) status.set('{0} recent files shown of {1}.'.format(len(rows), data['total'])) def refresh(): api = client() background(lambda: api.list_files(limit=100), fill_files) def upload(): if controller.get() == 'fanuc3m': messagebox.showinfo('Export profile only', 'fanuc3m is an export profile. Select the actual source controller for upload; legacy implied-decimal input is not supported.') return path = filedialog.askopenfilename(title='Select NC file', filetypes=[('NC programs', '*.nc *.hnc *.h *.ngc *.tap *.gcode *.cnc *.iso *.txt *.mpf *.min'), ('All files', '*.*')]) if not path: return api, selected_controller = client(), controller.get() def done(data): message = 'Uploaded: {0}\nID: {1}\nStatus: {2}'.format(data['filename'], data['id'], data['status']) if data['diagnostics']: message += '\n' + '\n'.join(d['message'] for d in data['diagnostics']) status.set(message) messagebox.showinfo('Upload complete', message) refresh() background(lambda: api.upload_file(path, {'controller': selected_controller}), done) def download(mode): selection = listing.curselection() if not selection: messagebox.showinfo('Select file', 'Select a file first.') return item = rows[int(selection[0])] target_controller = controller.get() if controller.get() != 'auto' else None if mode == 'generated' and not reviewed.get(): messagebox.showinfo('Review required', 'Review the job in the web application, then check the review box.') return api, is_reviewed = client(), bool(reviewed.get()) def save_download(downloaded): destination = filedialog.asksaveasfilename(initialfile=downloaded['filename'], title='Save to a NEW filename') if not destination: status.set('Download cancelled') return if os.path.exists(destination): messagebox.showerror('Choose a new file', 'Destination already exists; no file was overwritten.') return try: with open(destination, 'wb') as handle: handle.write(downloaded['content']) status.set('Saved {0} bytes to {1} ({2})'.format(len(downloaded['content']), destination, downloaded['content_type'])) except IOError as exc: messagebox.showerror('Save failed', str(exc)) background(lambda: api.fetch_download(item['id'], mode, target_controller, is_reviewed), save_download) for label, action in [('Refresh', refresh), ('Upload NC', upload), ('Download original', lambda: download('original')), ('Download generated', lambda: download('generated'))]: button = ttk.Button(bar, text=label, command=action) button.pack(side='left', padx=(0, 7)) buttons.append(button) poll() root.mainloop() if __name__ == '__main__': run_gui()