diff --git a/.env.template b/.env.template index e9062a93..4c7f5bb6 100644 --- a/.env.template +++ b/.env.template @@ -2,8 +2,12 @@ # The .env file is used to load environmental variables that are not necessary in a production environment (like FLASK_ENV=development) # Check the wiki for the available environmental variables -# feeder logger level: 5 -> acks received from the device (and above), level 6 -> lines sent to the device (and above), other standard logging levels of python -FEEDER_LEVEL=5 +# feeder logger level: +# * 4 -> log low level commands (used to manage the board status) +# * 5 -> acks received from the device (and above) about the drawing process +# * 6 -> lines sent to the device (and above) +# * other standard logging levels of python +FEEDER_LEVEL=30 # flask logger level: uses standard python loggin levels (10-debug, 20-info, 30-warning, 40-error, 50-critical). Can set to warning to hide standard http requests FLASK_LEVEL=30 diff --git a/.flaskenv b/.flaskenv index 7f72b383..d9b487d5 100644 --- a/.flaskenv +++ b/.flaskenv @@ -2,7 +2,11 @@ # for additional custom variables check the ".env.template" file FLASK_APP=server -# feeder logger level: 5 -> acks received from the device (and above), level 6 -> lines sent to the device (and above), other standard logging levels of python +# feeder logger level: +# * 4 -> log low level commands (used to manage the board status) +# * 5 -> acks received from the device (and above) about the drawing process +# * 6 -> lines sent to the device (and above) +# * other standard logging levels of python FEEDER_LEVEL=30 # flask logger level: uses standard python loggin levels (10-debug, 20-info, 30-warning, 40-error, 50-critical). Can set to warning to hide standard http requests diff --git a/dev_tools/update_frontend_version_hash.py b/dev_tools/update_frontend_version_hash.py new file mode 100644 index 00000000..fcc5dd98 --- /dev/null +++ b/dev_tools/update_frontend_version_hash.py @@ -0,0 +1,25 @@ +import subprocess + +def get_commit_shash(): + result = subprocess.check_output(['git', 'log', '--pretty=format:"%h"', "-n", "1"]) + return result.decode(encoding="UTF-8").replace('"', '') + + + +# This script save the current git hash in a .env file for react. +# The script load the last commit hash as version (used by the frontend to check if it is necessary to clear the local storage) +# Additional variables can be added directly in the list of lines below +# run it like: (env)$> python dev_tools/update_frontend_version_hash.py + + +file_path = "./frontend/.env" + +lines = [ + "/* THIS FILE IS GENERATED WITH THE FOLLOWING UTIL: */\n", + "/* dev_tools/update_frontend_version.py*/\n", + "/* Have a look there if you need to put additional variables */\n\n", + "REACT_APP_VERSION = {}".format(get_commit_shash())] # start the server + + +with open(file_path, "w") as f: + f.writelines(lines) \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 00000000..6e2a990e --- /dev/null +++ b/frontend/.env @@ -0,0 +1,5 @@ +/* THIS FILE IS GENERATED WITH THE FOLLOWING UTIL: */ +/* dev_tools/update_frontend_version.py*/ +/* Have a look there if you need to put additional variables */ + +REACT_APP_VERSION = e23dde3 \ No newline at end of file diff --git a/frontend/src/components/DrawingDataDownloader.js b/frontend/src/components/DrawingDataDownloader.js index ac8d6996..8820b81f 100644 --- a/frontend/src/components/DrawingDataDownloader.js +++ b/frontend/src/components/DrawingDataDownloader.js @@ -4,8 +4,8 @@ import { connect } from 'react-redux'; import { getRefreshDrawings } from '../structure/tabs/drawings/selector'; import { setDrawings, setRefreshDrawing } from '../structure/tabs/drawings/Drawings.slice'; -import { drawings_request } from '../sockets/SAE'; -import { drawings_refresh_response} from '../sockets/SAC'; +import { drawings_request } from '../sockets/sEmits'; +import { drawings_refresh_response} from '../sockets/sCallbacks'; const mapStateToProps = (state) => { return { must_refresh: getRefreshDrawings(state) } diff --git a/frontend/src/components/PlaylistDataDownloader.js b/frontend/src/components/PlaylistDataDownloader.js index caea8c9f..56b13c37 100644 --- a/frontend/src/components/PlaylistDataDownloader.js +++ b/frontend/src/components/PlaylistDataDownloader.js @@ -4,8 +4,8 @@ import { connect } from 'react-redux'; import { getRefreshPlaylists } from '../structure/tabs/playlists/selector'; import { setPlaylists, setRefreshPlaylists } from '../structure/tabs/playlists/Playlists.slice'; -import { playlists_request } from '../sockets/SAE'; -import { playlists_refresh_response } from '../sockets/SAC'; +import { playlists_request } from '../sockets/sEmits'; +import { playlists_refresh_response } from '../sockets/sCallbacks'; const mapStateToProps = (state) => { diff --git a/frontend/src/components/SortableElements.js b/frontend/src/components/SortableElements.js index 532144b3..50753e7b 100644 --- a/frontend/src/components/SortableElements.js +++ b/frontend/src/components/SortableElements.js @@ -72,10 +72,8 @@ class SortableElements extends Component{ return this.removeElement(el.id)} - showCross={this.state.show_child_cross} - onClick={()=>console.log("click2")}> + showCross={this.state.show_child_cross}> console.log("click")} onOptionsChange={(el) => this.props.onElementOptionsChange(el)} hideOptions={this.props.hideOptions}/> diff --git a/frontend/src/sockets/SAC.js b/frontend/src/sockets/sCallbacks.js similarity index 100% rename from frontend/src/sockets/SAC.js rename to frontend/src/sockets/sCallbacks.js diff --git a/frontend/src/sockets/SAE.js b/frontend/src/sockets/sEmits.js similarity index 87% rename from frontend/src/sockets/SAE.js rename to frontend/src/sockets/sEmits.js index fd089c33..a7a64f94 100644 --- a/frontend/src/sockets/SAE.js +++ b/frontend/src/sockets/sEmits.js @@ -1,4 +1,4 @@ -import {socket} from './SAC'; +import {socket} from './sCallbacks'; // sends a gcode command to the feeder function send_command(command){ @@ -82,11 +82,20 @@ function queue_set_order(list){ function queue_stop_drawing(){ socket.emit("queue_stop_drawing"); + window.show_toast(
The drawing is being stopped.
The device will still run until the buffer is empty.
) +} + + +// ---- MANUAL CONTROL ---- + +function control_emergency_stop(){ + socket.emit("control_emergency_stop") } export { send_command, + control_emergency_stop, drawing_delete, drawings_request, drawing_queue, diff --git a/frontend/src/store.js b/frontend/src/store.js index 47c36e75..23b65d24 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -2,7 +2,6 @@ import { createStore } from 'redux'; import { combineReducers } from '@reduxjs/toolkit'; import settingsReducer from './structure/tabs/settings/Settings.slice'; -import manualControlReducer from './structure/tabs/manual/ManualControl.slice'; import queueReducer from './structure/tabs/queue/Queue.slice'; import tabsReducer from './structure/tabs/Tabs.slice'; import drawingsReducer from './structure/tabs/drawings/Drawings.slice'; @@ -14,6 +13,7 @@ function saveToLocalStorage(state) { try { const serialisedState = JSON.stringify(state); localStorage.setItem("persistantState", serialisedState); + localStorage.setItem("version", process.env.REACT_APP_VERSION) } catch (e) { console.warn(e); } @@ -21,6 +21,19 @@ function saveToLocalStorage(state) { // will create the storage with the values saved in local storage function loadFromLocalStorage() { + // if is loading a new version from the server, clear the local storage (to avoid compatibility issues between different frontend versions) + try{ + const version = localStorage.getItem("version"); + if (version !== process.env.REACT_APP_VERSION){ + console.warn("New version detected. Clearing local storage"); + localStorage.clear(); + } + } catch (e) { + console.warn(e); + localStorage.clear(); + } + + // loads state from local storage if available try { const serialisedState = localStorage.getItem("persistantState"); if (serialisedState === null) return undefined; @@ -29,11 +42,10 @@ function loadFromLocalStorage() { console.warn(e); return undefined; } - } +} const store = createStore(combineReducers({ settings: settingsReducer, - manualControl: manualControlReducer, queue: queueReducer, tabs: tabsReducer, drawings: drawingsReducer, diff --git a/frontend/src/structure/SWUpdates.js b/frontend/src/structure/SWUpdates.js index 71004af7..8da0f9db 100644 --- a/frontend/src/structure/SWUpdates.js +++ b/frontend/src/structure/SWUpdates.js @@ -1,7 +1,7 @@ import { Component } from 'react'; import { connect } from 'react-redux'; -import {socket} from "../sockets/SAC"; +import {socket} from "../sockets/sCallbacks"; import { shouldCheckUpdate } from './tabs/settings/selector'; import { updateCheckTime } from './tabs/settings/Settings.slice'; diff --git a/frontend/src/structure/Toasts.js b/frontend/src/structure/Toasts.js index ee724733..4ca0c045 100644 --- a/frontend/src/structure/Toasts.js +++ b/frontend/src/structure/Toasts.js @@ -3,7 +3,7 @@ import { Spinner } from 'react-bootstrap'; import Toast from 'react-bootstrap/Toast'; -import { connection_status_callback, show_toast } from "../sockets/SAC"; +import { connection_status_callback, show_toast } from "../sockets/sCallbacks"; import { cloneDict } from '../utils/dictUtils'; class CustomToast extends Component{ diff --git a/frontend/src/structure/TopBar.js b/frontend/src/structure/TopBar.js index 7033f8f9..1d045a8a 100644 --- a/frontend/src/structure/TopBar.js +++ b/frontend/src/structure/TopBar.js @@ -9,7 +9,7 @@ import QueuePreview from './tabs/queue/QueuePreview'; import { showBack } from './tabs/selector'; import { setTab, tabBack } from './tabs/Tabs.slice'; import { systemIsLinux } from './tabs/settings/selector'; -import { settings_reboot_system, settings_shutdown_system } from '../sockets/SAE'; +import { settings_reboot_system, settings_shutdown_system } from '../sockets/sEmits'; const mapStateToProps = (state) => { return { diff --git a/frontend/src/structure/tabs/drawings/SingleDrawing.js b/frontend/src/structure/tabs/drawings/SingleDrawing.js index 1c6eeda0..2109811f 100644 --- a/frontend/src/structure/tabs/drawings/SingleDrawing.js +++ b/frontend/src/structure/tabs/drawings/SingleDrawing.js @@ -3,7 +3,7 @@ import { Container, Form, Modal } from 'react-bootstrap'; import { FileEarmarkX, Play, Plus, PlusSquare, X } from 'react-bootstrap-icons'; import { connect } from 'react-redux'; -import { drawing_delete, drawing_queue } from '../../../sockets/SAE'; +import { drawing_delete, drawing_queue } from '../../../sockets/sEmits'; import ConfirmButton from '../../../components/ConfirmButton'; import IconButton from '../../../components/IconButton'; diff --git a/frontend/src/structure/tabs/drawings/UploadDrawing.js b/frontend/src/structure/tabs/drawings/UploadDrawing.js index d56160da..9b0dcb49 100644 --- a/frontend/src/structure/tabs/drawings/UploadDrawing.js +++ b/frontend/src/structure/tabs/drawings/UploadDrawing.js @@ -71,6 +71,7 @@ class UploadDrawingsModal extends Component{ } render(){ + // TODO add thr files upload if necessary... Need somebody to try it out first return Upload new drawing @@ -79,11 +80,11 @@ class UploadDrawingsModal extends Component{
{({getRootProps, getInputProps, isDragActive}) => (
-
Drag and drop the .gcode/.nc file here
or click to open the file explorer +
Drag and drop the .gcode file here
or click to open the file explorer
)} diff --git a/frontend/src/structure/tabs/leds/Leds.js b/frontend/src/structure/tabs/leds/Leds.js index 9f0d8ad6..77623a3e 100644 --- a/frontend/src/structure/tabs/leds/Leds.js +++ b/frontend/src/structure/tabs/leds/Leds.js @@ -3,7 +3,7 @@ import { Container } from 'react-bootstrap'; import { ChromePicker } from 'react-color'; import { Section } from '../../../components/Section'; -import { leds_set_color } from '../../../sockets/SAE'; +import { leds_set_color } from '../../../sockets/sEmits'; class LedsController extends Component{ diff --git a/frontend/src/structure/tabs/manual/CommandLine.js b/frontend/src/structure/tabs/manual/CommandLine.js index f2bd93ca..b53b0c64 100644 --- a/frontend/src/structure/tabs/manual/CommandLine.js +++ b/frontend/src/structure/tabs/manual/CommandLine.js @@ -1,8 +1,8 @@ import React, { Component } from 'react'; import {Form, Row, Col, Button } from 'react-bootstrap'; -import { send_command } from '../../../sockets/SAE'; -import { device_command_line_return, device_new_position} from '../../../sockets/SAC'; +import { send_command } from '../../../sockets/sEmits'; +import { device_command_line_return, device_new_position} from '../../../sockets/sCallbacks'; import CommandViewer from './CommandViewer'; class CommandLine extends Component{ @@ -93,10 +93,12 @@ class CommandLine extends Component{ - {this.setState({show_acks: event.target.checked})}} - checked={this.state.show_acks}/> + {this.setState({show_acks: event.target.checked})}} + checked={this.state.show_acks}/> diff --git a/frontend/src/structure/tabs/manual/ManualControl.js b/frontend/src/structure/tabs/manual/ManualControl.js index d5adce12..7201e567 100644 --- a/frontend/src/structure/tabs/manual/ManualControl.js +++ b/frontend/src/structure/tabs/manual/ManualControl.js @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { Container, Row, Col, Button } from 'react-bootstrap'; -import { connect } from 'react-redux'; import "./ManualControl.scss"; @@ -8,14 +7,7 @@ import { Section } from '../../../components/Section'; import CommandLine from './CommandLine'; import Preview from './Preview'; -import { send_command } from '../../../sockets/SAE'; -import { getDimensions } from './selector.js'; - -const mapStateToProps = (state) => { - return { - dimensions: getDimensions(state) - } -} +import { control_emergency_stop, send_command } from '../../../sockets/sEmits'; class ManualControl extends Component{ @@ -29,12 +21,12 @@ class ManualControl extends Component{ - + - + @@ -45,4 +37,4 @@ class ManualControl extends Component{ } } -export default connect(mapStateToProps)(ManualControl); \ No newline at end of file +export default ManualControl; \ No newline at end of file diff --git a/frontend/src/structure/tabs/manual/ManualControl.scss b/frontend/src/structure/tabs/manual/ManualControl.scss index f03c3656..f8a7c2c3 100644 --- a/frontend/src/structure/tabs/manual/ManualControl.scss +++ b/frontend/src/structure/tabs/manual/ManualControl.scss @@ -4,7 +4,7 @@ @import '~bootstrap/scss/variables'; @import '~bootstrap/scss/mixins'; .preview-style{ - background-color: #eeeeee; + background-color: #000000; width: 100%; } diff --git a/frontend/src/structure/tabs/manual/ManualControl.slice.js b/frontend/src/structure/tabs/manual/ManualControl.slice.js deleted file mode 100644 index 25701ae3..00000000 --- a/frontend/src/structure/tabs/manual/ManualControl.slice.js +++ /dev/null @@ -1,22 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -const manualControlSlice = createSlice({ - name: "manualControl", - initialState: { - device: { - width: 100, - height: 100 - } - }, - reducers: { - setDeviceSize(state, action){ - return {device: action.payload} - } - } -}); - -export const{ - setDeviceSize -} = manualControlSlice.actions; - -export default manualControlSlice.reducer; \ No newline at end of file diff --git a/frontend/src/structure/tabs/manual/Preview.js b/frontend/src/structure/tabs/manual/Preview.js index 34eee858..b7bf9f7c 100644 --- a/frontend/src/structure/tabs/manual/Preview.js +++ b/frontend/src/structure/tabs/manual/Preview.js @@ -1,19 +1,31 @@ import "./ManualControl.scss"; import React, { Component } from 'react'; +import { connect } from "react-redux"; -import { device_new_position } from '../../../sockets/SAC'; +import { device_new_position } from '../../../sockets/sCallbacks'; +import { getDevice, getIsFastMode } from "../settings/selector"; +import { dictsAreEqual } from "../../../utils/dictUtils"; +import { isManualControl } from "../selector"; const ANIMATION_FRAMES_MAX = 10; const ANIMATION_DURATION = 1000; +const mapStateToProps = (state) =>{ + return { + device: getDevice(state), + isManualControl: isManualControl(state), + isFastMode: getIsFastMode(state) + } +} + class Preview extends Component{ constructor(props){ super(props); this.canvas_ref = React.createRef(); this.image_ref = React.createRef(); - this.primary_color = "#eeeeee"; - this.dark_color = "#333333"; + this.bg_color = "#000000"; + this.line_color = "#ffffff"; this.multiplier = 5; // multiply the pixels to get a better resolution with small tables this.is_mounted = false; this.force_image_render = false; @@ -24,6 +36,10 @@ class Preview extends Component{ x: 0, y: 0 } + + this.width = 100; + this.height = 100; + setInterval(this.updateImage.bind(this), 1000); // update the image in an interval callback because updating every command is too heavy } componentDidMount(){ @@ -31,6 +47,9 @@ class Preview extends Component{ this.is_mounted = true; this.canvas = this.canvas_ref.current; this.ctx = this.canvas.getContext("2d"); + this.ctx.strokeStyle = this.line_color; + this.ctx.fillStyle = this.bg_color; + this.ctx.lineWidth = this.multiplier; this.clearCanvas(); this.forceUpdate(); device_new_position(this.newLineFromDevice.bind(this)); @@ -45,15 +64,15 @@ class Preview extends Component{ } shouldComponentUpdate(nextProps){ - if (nextProps.width !== this.props.width || nextProps.height !== this.props.width){ + if (!dictsAreEqual(nextProps.device, this.props.device) || this.props.isManualControl) this.force_image_render = true; - } return true; } updateImage(){ - if (this.canvas !== undefined) + if (this.canvas !== undefined && this.props.isManualControl){ this.image_ref.current.src = this.canvas.toDataURL(); + } } limitValue(value, min, max){ @@ -64,41 +83,103 @@ class Preview extends Component{ } drawLine(line){ + // prepare the line + line = line.replace("\n", ""); let l = line.split(" "); + // parsing line for fast mode (will not have spaces thus split(" ") will not work) + if (this.props.isFastMode){ + l = []; + let tmp = "" + for (let c in line){ + if (line.charAt(c).match(/[A-Z]/)){ + if (tmp.length>0){ + l.push(tmp); + tmp = ""; + } + } + tmp += line.charAt(c); + } + l.push(tmp); + } + + // parse the command let x = this.pp.x; let y = this.pp.y; for(const i in l){ if(l[i].includes("X")){ - x = l[i].replace(/[^\d.-]/g, ''); + x = this.roundFloat(parseFloat(l[i].replace(/[^\d.-]/g, ''))); } if(l[i].includes("Y")){ - y = l[i].replace(/[^\d.-]/g, ''); + y = this.roundFloat(parseFloat(l[i].replace(/[^\d.-]/g, ''))); } } - x = this.limitValue(x, 0, this.props.width); - y = this.limitValue(y, 0, this.props.height); - this.ctx.lineTo(x * this.multiplier, this.props.height * this.multiplier - y * this.multiplier); - this.ctx.stroke(); - this.ctx.lineWidth = this.multiplier; + this.pp.x = x; this.pp.y = y; - this.updateImage(); + + let res; + if (this.props.device.type === "Cartesian") + res = this.convertCartesian(x, y) + else if (this.props.device.type === "Scara") + res = this.convertScara(x, y) + else res = this.convertPolar(x, y) + + x = this.limitValue(res.x, 0, this.width); + y = this.limitValue(res.y, 0, this.height); + this.ctx.lineTo(x * this.multiplier, (this.height - y) * this.multiplier); + this.ctx.stroke(); + } + + roundFloat(val){ + return Math.round(val*1000)/1000; + } + + convertCartesian(x, y){ + return { + x: x, + y: y + } + } + + convertPolar(x, y){ + return { + x: this.roundFloat((Math.cos(x*2*Math.PI/this.props.device.angle_conversion_factor)*y + 1)*this.props.device.radius), // +1 to center with respect to the image (shifts by one radius) + y: this.roundFloat((Math.sin(x*2*Math.PI/this.props.device.angle_conversion_factor)*y + 1)*this.props.device.radius) // +1 to center with respect to the image (shifts by one radius) + } + } + + convertScara(x, y){ + // For more info about the conversion check the server/utils/gcode_converter.py file + let theta = (x + y + this.props.device.offset_angle_1 * 2) * Math.PI/this.props.device.angle_conversion_factor; + let rho = Math.cos((x - y + this.props.device.offset_angle_2*2) * Math.PI/this.props.device.angle_conversion_factor) * this.radius; + return { + x: this.roundFloat(Math.cos(theta) * rho + this.radius), // +radius to center with respect to the preview + y: this.roundFloat(Math.sin(theta) * rho + this.radius) + } } clearCanvas(){ - this.pp.x = 0; - this.pp.y = 0; + if (this.props.device.type==="Scara"){ + let res = this.convertScara(0,0); + this.pp.x = res.x + this.radius; + this.pp.y = res.y + this.radius; + }else if (this.props.device.type==="Polar"){ + this.pp.x = this.radius; + this.pp.y = this.radius; + } + else{ // Cartesian and Polar + this.pp.x = 0; + this.pp.y = 0; + } this.ctx.beginPath(); - this.ctx.moveTo(this.pp.x, this.props.height * this.multiplier - this.pp.x); + this.ctx.moveTo(this.pp.x, this.height * this.multiplier - this.pp.x); this.animation_frames = 0; this.animateClear(); } animateClear(){ this.ctx.globalAlpha = 0.7; - this.ctx.fillStyle = this.primary_color; - this.ctx.fillRect(0,0, this.props.width * this.multiplier, this.props.height * this.multiplier); - this.ctx.fillStyle = this.dark_color; + this.ctx.fillRect(0,0, this.width * this.multiplier, this.height * this.multiplier); this.ctx.globalAlpha = 1; this.updateImage(); if (this.animation_frames++ < ANIMATION_FRAMES_MAX){ @@ -116,8 +197,17 @@ class Preview extends Component{ } render(){ + if (this.props.device.type==="Cartesian"){ + this.width = parseInt(this.props.device.width); + this.height = parseInt(this.props.device.height); + }else{ // For polar and scara should be square with height and width = diameter or 2*radius + this.width = parseInt(this.props.device.radius)*2; + this.height = this.width; + this.radius = parseInt(this.props.device.radius); + } + return
- + { return { showSinglePlaylist: (id) => { - dispatch(setSinglePlaylistId(id)); - dispatch(showSinglePlaylist(id)); + // using promises to dispatch in the correct order (otherwise the playlist page may not load in the correct order) + Promise.resolve(dispatch(setSinglePlaylistId(id))).then( + () => dispatch(showSinglePlaylist(id))); }}; } diff --git a/frontend/src/structure/tabs/playlists/Playlists.slice.js b/frontend/src/structure/tabs/playlists/Playlists.slice.js index 94d046dc..0a9b4f67 100644 --- a/frontend/src/structure/tabs/playlists/Playlists.slice.js +++ b/frontend/src/structure/tabs/playlists/Playlists.slice.js @@ -1,6 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; -import { playlist_save } from '../../../sockets/SAE'; +import { playlist_save } from '../../../sockets/sEmits'; import { cloneDict, listsAreEqual } from '../../../utils/dictUtils'; import { getSinglePlaylist } from './selector'; diff --git a/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js b/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js index c2b2904f..e154579d 100644 --- a/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js +++ b/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js @@ -9,7 +9,7 @@ import ConfirmButton from '../../../../components/ConfirmButton'; import SortableElements from '../../../../components/SortableElements'; import IconButton from '../../../../components/IconButton'; -import { playlist_delete, playlist_queue, playlist_save } from '../../../../sockets/SAE'; +import { playlist_delete, playlist_queue, playlist_save } from '../../../../sockets/sEmits'; import { listsAreEqual } from '../../../../utils/dictUtils'; import { resetShowSaveBeforeBack, setSaveBeforeBack, tabBack } from '../../Tabs.slice'; diff --git a/frontend/src/structure/tabs/queue/Queue.js b/frontend/src/structure/tabs/queue/Queue.js index 615432be..4cac3737 100644 --- a/frontend/src/structure/tabs/queue/Queue.js +++ b/frontend/src/structure/tabs/queue/Queue.js @@ -6,8 +6,8 @@ import { connect } from 'react-redux'; import { Section, Subsection } from '../../../components/Section'; import SortableElements from '../../../components/SortableElements'; -import { queue_status } from '../../../sockets/SAC'; -import { queue_get_status, queue_set_order, queue_stop_drawing } from '../../../sockets/SAE'; +import { queue_status } from '../../../sockets/sCallbacks'; +import { queue_get_status, queue_set_order, queue_stop_drawing } from '../../../sockets/sEmits'; import { listsAreEqual } from '../../../utils/dictUtils'; import { getElementClass } from '../playlists/SinglePlaylist/Elements'; import { isViewQueue } from '../selector'; diff --git a/frontend/src/structure/tabs/selector.js b/frontend/src/structure/tabs/selector.js index dd36b482..ac76fc9f 100644 --- a/frontend/src/structure/tabs/selector.js +++ b/frontend/src/structure/tabs/selector.js @@ -6,6 +6,10 @@ const getShowSaveBeforeBack = state => { return state.tabs.show_save_before_back && isViewSinglePlaylist(state); } +const isManualControl = state => { + return state.tabs.tab === "manual"; +} + const isViewSinglePlaylist = state => { return state.tabs.tab === "playlist"; } @@ -18,4 +22,4 @@ const showBack = state => { return state.tabs.tab === "drawing" || state.tabs.tab === "playlist"; } -export {getTab, getSingleDrawingId, getShowSaveBeforeBack, isViewSinglePlaylist, isViewQueue, showBack}; \ No newline at end of file +export {getTab, getSingleDrawingId, getShowSaveBeforeBack, isManualControl, isViewSinglePlaylist, isViewQueue, showBack}; \ No newline at end of file diff --git a/frontend/src/structure/tabs/settings/Settings.js b/frontend/src/structure/tabs/settings/Settings.js index 0e658d89..7a4c0c6d 100644 --- a/frontend/src/structure/tabs/settings/Settings.js +++ b/frontend/src/structure/tabs/settings/Settings.js @@ -6,10 +6,9 @@ import { Section, Subsection, SectionGroup } from '../../../components/Section'; import { getSettings } from "./selector.js"; import { updateAllSettings, updateSetting } from "./Settings.slice.js"; -import { setDeviceSize } from "../manual/ManualControl.slice.js"; -import { settings_now } from '../../../sockets/SAC'; -import { settings_save } from '../../../sockets/SAE'; +import { settings_now } from '../../../sockets/sCallbacks'; +import { settings_save } from '../../../sockets/sEmits'; import { cloneDict } from '../../../utils/dictUtils'; const mapStateToProps = (state) => { @@ -21,8 +20,7 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { updateAllSettings: (settings) => dispatch(updateAllSettings(settings)), - updateSetting: (val) => dispatch(updateSetting(val)), - setDeviceSize: (props) => dispatch(setDeviceSize({width: props.settings.device.width, height: props.settings.device.height})) + updateSetting: (val) => dispatch(updateSetting(val)) } } @@ -31,7 +29,6 @@ class Settings extends Component{ componentDidMount(){ settings_now((data) => { this.props.updateAllSettings(JSON.parse(data)); - this.props.setDeviceSize(this.props); }); } @@ -40,12 +37,12 @@ class Settings extends Component{ delete sets.serial.available_baudrates; delete sets.serial.available_ports; settings_save(sets, connect); - this.props.setDeviceSize(this.props); } render(){ let port = this.props.settings.serial.port ? this.props.settings.serial.port : ""; let baud = this.props.settings.serial.baud ? this.props.settings.serial.baud : ""; + let firmware = this.props.settings.serial.firmware ? this.props.settings.serial.firmware : ""; // TODO auto generate layout directly from settings? (include a "setting type", values, etc) return
@@ -79,29 +76,91 @@ class Settings extends Component{ + + + Firmware + this.props.updateSetting(["serial.firmware", e.target.value])}> + { this.props.settings.serial.available_firmwares.map((firm, index) => { + return + })} + + + + + + {this.props.updateSetting(["serial.fast_mode", e.target.checked])}} + checked={this.props.settings.serial.fast_mode}/> + + - + - + - Width + Width (cartesian) this.props.updateSetting(["device.width", e.target.value])}/> - + - Height + Height (cartesian) this.props.updateSetting(["device.height", e.target.value])}/> + + + Radius (scara, polar) + this.props.updateSetting(["device.radius", e.target.value])}/> + + + + + Type + this.props.updateSetting(["device.type", e.target.value])}> + {this.props.settings.device.available_types.map((el, index) => { + return + })} + + + + + + Angle conversion value (the amount of units to send for a complete turn of a motor) (polar and scara only) + this.props.updateSetting(["device.angle_conversion_factor", e.target.value])}/> + + + + + Angle offset (angular homing position for the preview, 0 on top)(polar and scara) + this.props.updateSetting(["device.offset_angle_1", e.target.value])}/> + + + + + Homing offset for the second arm (scara only) + this.props.updateSetting(["device.offset_angle_2", e.target.value])}/> + + diff --git a/frontend/src/structure/tabs/settings/Settings.slice.js b/frontend/src/structure/tabs/settings/Settings.slice.js index 6ece7100..e9d9cc94 100644 --- a/frontend/src/structure/tabs/settings/Settings.slice.js +++ b/frontend/src/structure/tabs/settings/Settings.slice.js @@ -9,11 +9,24 @@ const settingsSlice = createSlice({ port: "FAKE", baud: 115200, available_baudrates: ["2400", "4800", "9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"], - available_ports: ["FAKE"] + available_ports: ["FAKE"], + firmware: "Marlin", + available_firmwares: ["Marlin", "Grbl"], + fast_mode: false }, device: { width: 100, - height:100 + height:100, + radius:100, + type:"Cartesian", + available_types:[ + "Cartesian", + "Polar", + "Scara" + ], + "angle_conversion_factor": 6, + "offset_angle_1": -1.5, + "offset_angle_2": 1.5 }, scripts: { connected: "", diff --git a/frontend/src/structure/tabs/settings/selector.js b/frontend/src/structure/tabs/settings/selector.js index 1005651a..dab895ac 100644 --- a/frontend/src/structure/tabs/settings/selector.js +++ b/frontend/src/structure/tabs/settings/selector.js @@ -1,5 +1,9 @@ const getSettings = state => { return state.settings }; +const getDevice = state => { return state.settings.device }; + +const getIsFastMode = state => { return state.settings.serial.fast_mode} + const systemIsLinux = state => { return state.settings.system.is_linux }; const shouldCheckUpdate = state => { @@ -8,4 +12,4 @@ const shouldCheckUpdate = state => { else return false; }; // check for an update every day once -export { getSettings, systemIsLinux, shouldCheckUpdate }; \ No newline at end of file +export { getSettings, getDevice, getIsFastMode, systemIsLinux, shouldCheckUpdate }; \ No newline at end of file diff --git a/frontend/src/utils/dictUtils.js b/frontend/src/utils/dictUtils.js index 1a9bd782..cb8c06ca 100644 --- a/frontend/src/utils/dictUtils.js +++ b/frontend/src/utils/dictUtils.js @@ -52,4 +52,8 @@ const listsAreEqual = (dictList1, dictList2) => { return JSON.stringify(dictList1) === JSON.stringify(dictList2); } -export {mergeDicts, cloneDict, setSubKey, getSubKey, listsAreEqual}; \ No newline at end of file +const dictsAreEqual = (dict1, dict2) => { + return listsAreEqual(dict1, dict2); +} + +export { mergeDicts, cloneDict, setSubKey, getSubKey, listsAreEqual, dictsAreEqual }; \ No newline at end of file diff --git a/install.bat b/install.bat index 4a7c0f33..5749e81d 100644 --- a/install.bat +++ b/install.bat @@ -1,6 +1,11 @@ echo '----- Installing python dependencies -----' python -m pip install -r requirements.txt +echo '----- Updating sw version ----' +:: Necessary to avoid compatibility issues between old and new settings in the browser local storage +:: The frontend will compare the two verisions and in case of a mismatch will load the new settings from the server instead of loading the old redux state +python dev_tools/update_frontend_version_hash.py + echo '----- Installing js dependencies and building frontend app -----' call npm install -g yarn call yarn --cwd ./frontend install diff --git a/install.sh b/install.sh index 0967bb4a..bd121714 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,9 @@ echo '----- Installing python dependencies -----' python3 -m pip install -r requirements.txt +echo '----- Updating sw version ----' +python3 dev_tools/update_frontend_version_hash.py + echo '\n\n----- Installing js dependencies and building frontend app -----' sudo npm install -g yarn sudo yarn --cwd ./frontend install diff --git a/readme.md b/readme.md index c71e63af..71f6e497 100755 --- a/readme.md +++ b/readme.md @@ -86,7 +86,7 @@ $> source env/bin/activate Now you can install SandyPi (will take a while): ``` -(env)$> sh install.sh +(env)$> sudo sh install.sh ``` ## Running the server @@ -116,6 +116,17 @@ If you find problems during the installation check the [troubleshooting](/docs/t **If you find any bug or problem please feel free to open an [issue](https://github.com/texx00/sandypi/issues) in the dedicated page.** ___ +# Boards and firmwares +At the moment, the sw is tested only with Marlin 2.0 and Grbl 1.1 +Should be compatible with other firmwares as well. If not please open an issue. + +## Marlin 2.0 setup +In the settings select the serial port, the correct baudrate (usually 115200 or 250000) and the correct firmware type. + +## Grbl 1.1 +In the settings select the serial port, the correct baudrate (usually 115200 or 250000) and the correct firmware type. + + # Updates The software will prompt weekly if a new tag version has been added. @@ -127,7 +138,7 @@ To update to the last available version of the software in linux you can run the ``` $> source env/bin/activate (env) $> git pull -(env) $> sh install.sh +(env) $> sudo sh install.sh ``` If you are working on Windows you should use instead: @@ -180,4 +191,4 @@ Todos: * [ ] A lot more stuff... Just ask to know what you can help with ## Versions -Check the latest version infos [here](docs/versions.md) \ No newline at end of file +Check the latest version infos [here](docs/versions.md) diff --git a/requirements.txt b/requirements.txt index 974f2ac0..6960508c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ astroid==2.4.2 bidict==0.21.2 click==7.1.2 colorama==0.4.4 +dotmap==1.3.23 Flask==1.1.2 Flask-Cors==3.0.9 Flask-Migrate==2.5.3 diff --git a/server/__init__.py b/server/__init__.py index b50cb9de..cc046125 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -10,9 +10,10 @@ import os -from time import sleep +from time import sleep, time from dotenv import load_dotenv import logging +from threading import Thread from server.utils import settings_utils, software_updates, migrations @@ -71,7 +72,7 @@ def base_static(filename): # Device controller initialization app.feeder = Feeder(FeederEventManager(app)) -app.feeder.connect() +#app.feeder.connect() app.qmanager = QueueManager(app, socketio) app.leds_controller = LedsController(app) @@ -96,5 +97,15 @@ def versioned_url_for(endpoint, **values): def home(): return send_from_directory(app.static_folder, "index.html") + +# Starting the feeder after the server is ready to avoid problems with the web page not showing up +def run_post(): + sleep(2) + app.feeder.connect() + +th = Thread(target = run_post) +th.name = "feeder_starter" +th.start() + if __name__ == '__main__': socketio.run(app) diff --git a/server/api/drawings.py b/server/api/drawings.py index 554366ae..10d2e746 100644 --- a/server/api/drawings.py +++ b/server/api/drawings.py @@ -1,15 +1,16 @@ +from server.utils import settings_utils from server import app, db from server.database.models import UploadedFiles from flask import request, jsonify from werkzeug.utils import secure_filename -from server.utils.gcode_converter import gcode_to_image +from server.utils.gcode_converter import ImageFactory import traceback import os import shutil -ALLOWED_EXTENSIONS = ["gcode", "nc"] +ALLOWED_EXTENSIONS = ["gcode", "nc", "thr"] def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -21,11 +22,14 @@ def api_upload(): if 'file' in request.files: file = request.files['file'] if file and file.filename!= '' and allowed_file(file.filename): + # TODO add support to thr files + settings = settings_utils.load_settings() # TODO move this into a thread because on the pi0w it is too slow and some drawings are not loaded in time filename = secure_filename(file.filename) new_file = UploadedFiles(filename = filename) db.session.add(new_file) db.session.commit() + factory = ImageFactory(settings["device"]) # create a folder for each drawing. The folder will contain the .gcode file, the preview and additionally some settings for the drawing folder = app.config["UPLOAD_FOLDER"] +"/" + str(new_file.id) +"/" try: @@ -36,7 +40,7 @@ def api_upload(): # create the preview image try: with open(os.path.join(folder, str(new_file.id)+".gcode")) as file: - image = gcode_to_image(file) + image = factory.gcode_to_image(file) image.save(os.path.join(folder, str(new_file.id)+".jpg")) except: app.logger.error("Error during image creation") diff --git a/server/database/playlist_elements.py b/server/database/playlist_elements.py index a8db7d66..fe099483 100644 --- a/server/database/playlist_elements.py +++ b/server/database/playlist_elements.py @@ -114,6 +114,9 @@ def execute(self): for line in f: if line.startswith(";"): # skips commented lines continue + if ";" in line: # remove in line comments + line.split(";") + line = line[0] yield line diff --git a/server/hw_controller/device_serial.py b/server/hw_controller/device_serial.py index 2cc9d3f4..958a1f07 100644 --- a/server/hw_controller/device_serial.py +++ b/server/hw_controller/device_serial.py @@ -1,15 +1,16 @@ +from threading import Thread, Lock import serial.tools.list_ports import serial import time +import traceback import sys import logging from server.hw_controller.emulator import Emulator import glob -# This class connect to a serial device +# This class connects to a serial device # If the serial device request is not available it will create a virtual serial device - class DeviceSerial(): def __init__(self, serialname = None, baudrate = None, logger_name = None): self.logger = logging.getLogger(logger_name) if not logger_name is None else logging.getLogger() @@ -20,6 +21,7 @@ def __init__(self, serialname = None, baudrate = None, logger_name = None): self.echo = "" self._emulator = Emulator() + # opening serial try: args = dict( baudrate = self.baudrate, @@ -31,24 +33,50 @@ def __init__(self, serialname = None, baudrate = None, logger_name = None): self.serial.open() self.logger.info("Serial device connected") except: - #print(traceback.print_exc()) + self.logger.error(traceback.print_exc()) # TODO should add a check to see if the problem is that cannot use the Serial module because it is not installed correctly on raspberry self.is_fake = True - self.logger.error("Serial not available. Will use the fake serial") + self.logger.error("Serial not available. Are you sure the device is connected and is not in use by other softwares? (Will use the fake serial)") + + # empty callback function + def useless(): + pass + + # setting up the read thread + self._th = Thread(target=self._thf, daemon=True) + self._mutex = Lock() + self._th.name = "serial_read" + self._running = False + self.set_onreadline_callback(useless) + self._th.start() + # this method is used to set a callback for the "new line available" event + def set_onreadline_callback(self, callback): + self._on_readline = callback + + # check if the reading thread is working + def is_running(self): + return self._running + + # stops the serial read thread + def stop(self): + self._running = False + + # sends a line to the device def send(self, obj): if self.is_fake: self._emulator.send(obj) else: if self.serial.is_open: try: - while self.readline(): - pass - self.serial.write(str(obj).encode()) + with self._mutex: + self._readline() + self.serial.write(str(obj).encode()) except: self.close() self.logger.error("Error while sending a command") + # return a list of available serial ports def serial_port_list(self): if sys.platform.startswith('win'): plist = serial.tools.list_ports.comports() @@ -60,24 +88,37 @@ def serial_port_list(self): raise EnvironmentError('Unsupported platform') return ports + # check if is connected to a real device def is_connected(self): if(self.is_fake): return False return self.serial.is_open + # close the connection with the serial device def close(self): + self.stop() try: self.serial.close() self.logger.info("Serial port closed") except: self.logger.error("Error: serial already closed or not available") - def readline(self): + # private functions + + # reads a line from the device + def _readline(self): if not self.is_fake: if self.serial.is_open: - while self.serial.inWaiting(): + while self.serial.in_waiting>0: line = self.serial.readline() - return line.decode(encoding='UTF-8') + self._on_readline(line.decode(encoding='UTF-8')) else: return self._emulator.readline() return None + + # thread function + def _thf(self): + self._running = True + while(self.is_running()): + with self._mutex: + self._readline() \ No newline at end of file diff --git a/server/hw_controller/emulator.py b/server/hw_controller/emulator.py index 6741963e..ecbfe5bb 100644 --- a/server/hw_controller/emulator.py +++ b/server/hw_controller/emulator.py @@ -54,7 +54,7 @@ def send(self, command): except: y = self.last_y # calculate time - t = math.sqrt((x-self.last_x)**2 + (y-self.last_y)**2) / self.feedrate * 60.0 + t = max(math.sqrt((x-self.last_x)**2 + (y-self.last_y)**2) / self.feedrate * 60.0, 0.005) # TODO need to use the max 0.005 because cannot simulate anything on the frontend otherwise... May look for a better solution if t == 0.0: self.message_buffer.append(ACK) return diff --git a/server/hw_controller/feeder.py b/server/hw_controller/feeder.py index 975b6830..855a53d0 100644 --- a/server/hw_controller/feeder.py +++ b/server/hw_controller/feeder.py @@ -11,7 +11,7 @@ from server.utils import limited_size_dict, buffered_timeout, settings_utils from server.hw_controller.device_serial import DeviceSerial from server.hw_controller.gcode_rescalers import Fit - +import server.hw_controller.firmware_defaults as firmware """ @@ -50,6 +50,7 @@ def __init__(self, handler = None, **kargvs): self.logger = logging.getLogger(__name__) logging.addLevelName(settings_utils.LINE_SENT, "LINE_SENT") logging.addLevelName(settings_utils.LINE_RECEIVED, "LINE_RECEIVED") + logging.addLevelName(settings_utils.LINE_SERVICE, "LINE_SERVICE") # load logging level from environment variables load_dotenv() level = os.getenv("FEEDER_LEVEL") @@ -87,40 +88,49 @@ def __init__(self, handler = None, **kargvs): self.command_buffer_max_length = 8 self.command_buffer_history = limited_size_dict.LimitedSizeDict(size_limit = self.command_buffer_max_length+10) # keep saved the last n commands self.position_request_difference = 10 # every n lines requires the current position with M114 - self._timeout = buffered_timeout.BufferTimeout(15, self._on_timeout) + self._buffered_line = "" + + self._timeout = buffered_timeout.BufferTimeout(30, self._on_timeout) self._timeout.start() + + # device specific options + self.update_settings(settings_utils.load_settings()) + + + def update_settings(self, settings): + self.settings = settings + self._firmware = settings["serial"]["firmware"] + self._ACK = firmware.get_ACK(self._firmware) + self._timeout.set_timeout_period(firmware.get_buffer_timeout(self._firmware)) + self.is_fast_mode = settings["serial"]["fast_mode"] + if self.is_fast_mode: + if settings["device"]["type"] == "Cartesian": + self.command_resolution = "{:.1f}" # Cartesian do not need extra resolution because already using mm as units. (TODO maybe with inches can have problems? needs to check) + else: self.command_resolution = "{:.3f}" # Polar and scara use smaller numbers, will need also decimals def close(self): self.serial.close() + def get_status(self): + with self.serial_mutex: + return {"is_running":self._isrunning, "progress":[self.command_number, self.total_commands_number], "is_paused":self._ispaused, "is_connected":self.is_connected()} + def connect(self): self.logger.info("Connecting to serial device...") - settings = settings_utils.load_settings() with self.serial_mutex: if not self.serial is None: self.serial.close() try: - self.serial = DeviceSerial(settings['serial']['port'], settings['serial']['baud'], logger_name = __name__) - self._serial_read_thread = Thread(target = self._thsr, daemon=True) - self._serial_read_thread.name = "serial_read" - self._serial_read_thread.start() + self.serial = DeviceSerial(self.settings['serial']['port'], self.settings['serial']['baud'], logger_name = __name__) + self.serial.set_onreadline_callback(self.on_serial_read) + self.logger.info("Connection successfull") except: self.logger.info("Error during device connection") self.logger.info(traceback.print_exc()) self.serial = DeviceSerial(logger_name = __name__) - # wait for the device to be ready - self.wait_device_ready() - - # reset line number when connecting - self.reset_line_number() - self.request_feedrate() - # send the "on connection" script from the settings - self.send_script(settings['scripts']['connected']) + self.device_ready = False # this line is set to true as soon as the board sends a message - def wait_device_ready(self): - time.sleep(1) - # without this function the device may be not ready to receive commands def set_event_handler(self, handler): self.handler = handler @@ -178,30 +188,139 @@ def stop(self): break # waiting command buffer to be clear before calling the "drawing ended" event while True: + # with grbl can ask a status report to see if the buffer is empty + if firmware.is_grbl(self._firmware): + self.send_gcode_command("?", hide_command=True) # will send a command to ask for a status report (won't be shown in the command history) + time.sleep(1) # wait just 1 second to get the time to the board to ansfer + with self.command_buffer_mutex: + # Marlin: wait for the buffer to be empty (rely on the circular buffer. If an ack is lost in the way, the buffer will not clear at the end of the drawing. It will be cleared by the buffer timeout after a while) if len(self.command_buffer) == 0: break # resetting line number between drawings - self.reset_line_number() + self._reset_line_number() # calling "drawing ended" event self.handler.on_element_ended(tmp) - # pause the drawing + + # pauses the drawing # can resume with "resume()" def pause(self): with self.status_mutex: self._ispaused = True - # resume the drawing (only if used with "pause()" and not "stop()") + # resumes the drawing (only if used with "pause()" and not "stop()") def resume(self): with self.status_mutex: self._ispaused = False + # function to prepare the command to be sent. + # * command: command to send + # * hide_command=False (optional): will hide the command from being sent also to the frontend (should be used for SW control commands) + def send_gcode_command(self, command, hide_command=False): + # clean the command a little + command = command.replace("\n", "").replace("\r", "").upper() + if command == " " or command == "": + return + + # some commands require to update the feeder status + # parse the command if necessary + if "M110" in command: + cs = command.split(" ") + for c in cs: + if c[0]=="N": + self.line_number = int(c[1:]) -1 + self.command_buffer.clear() + + # check if the command is in the "BUFFERED_COMMANDS" list and stops if the buffer is full + if any(code in command for code in BUFFERED_COMMANDS): + if "F" in command: + feed = self.feed_regex.findall(command) + self.feedrate = feed[0][0] + + with self.command_send_mutex: # wait until get some "ok" command to remove an element from the buffer + pass + + # send the command after parsing the content + # need to use the mutex here because it is changing also the line number + with self.serial_mutex: + # check if needs to send a "M114" command (actual position request) but not in the first lines + if (self.line_number % self.position_request_difference) == 0 and self.line_number > 5: + #self._generate_line("M114") # does not send the line to trigger the "resend" event and clean the buffer from messages that are already done + pass + + line = self._generate_line(command) + + self.serial.send(line) # send line + self.logger.log(settings_utils.LINE_SENT, line.replace("\n", "")) + + # TODO fix the problem with small geometries may be with the serial port being to slow. For long (straight) segments the problem is not evident. Do not understand why it is happening + + with self.command_buffer_mutex: + if(len(self.command_buffer)>=self.command_buffer_max_length and not self.command_send_mutex.locked()): + self.command_send_mutex.acquire() # if the buffer is full acquire the lock so that cannot send new lines until the reception of an ack. Notice that this will stop only buffered commands. The other commands will be sent anyway + + if not hide_command: + self.handler.on_new_line(line) # uses the handler callback for the new line + + if firmware.is_marlin(self._firmware): # updating the command only for marlin because grbl check periodically the buffer status with the status report command + self._update_timeout() # update the timeout because a new command has been sent + + + + # Send a multiline script + def send_script(self, script): + self.logger.info("Sending script") + script = script.split("\n") + for s in script: + if s != "" and s != " ": + self.send_gcode_command(s) + + def serial_ports_list(self): + result = self.serial.serial_port_list() + return [] if result is None else result + + def is_connected(self): + return self.serial.is_connected() + + # stops immediately the device + def emergency_stop(self): + self.send_gcode_command(firmware.get_emergency_stop_command()) + # TODO add self.close() ? + + # ----- PRIVATE METHODS ----- + + # prepares the board + def _on_device_ready(self): + if firmware.is_marlin(self._firmware): + self._reset_line_number() + + # grbl status report mask setup + # sandypi need to check the buffer to see if the machine has cleaned the buffer + # setup grbl to show the buffer status with the $10 command + # Grbl 1.1 https://github.com/gnea/grbl/wiki/Grbl-v1.1-Configuration + # Grbl 0.9 https://github.com/grbl/grbl/wiki/Configuring-Grbl-v0.9 + # to be compatible with both will send $10=6 (4(for v0.9) + 2(for v1.1)) + # the status will then be prompted with the "?" command when necessary + # the buffer will contain Bf:"usage of the buffer" + if firmware.is_grbl(self._firmware): + self.send_gcode_command("$10=6") + + # send the "on connection" script from the settings + self.send_script(self.settings['scripts']['connected']) + + # run the "_on_device_ready" method with a delay + def _on_device_ready_delay(self): + def delay(): + time.sleep(5) + self._on_device_ready() + th = Thread(target = delay) + th.start() + # thread function - # TODO move this function in a different class + # TODO move this function in a different class? def _thf(self, element): - settings = settings_utils.load_settings() - self.send_script(settings['scripts']['before']) + self.send_script(self.settings['scripts']['before']) self.logger.info("Starting new drawing with code {}".format(element)) with self.serial_mutex: @@ -228,41 +347,36 @@ def _thf(self, element): with self.status_mutex: self._stopped = True if self.is_running(): - self.send_script(settings['scripts']['after']) + self.send_script(self.settings['scripts']['after']) self.stop() # thread that keep reading the serial port - def _thsr(self): - line = "" - l = None - while True: - with self.serial_mutex: - try: - l = self.serial.readline() - except Exception as e: - self.logger.error(e) - self.logger.error("Serial connection lost") - if not l is None: - # readline is not returning the full line but only a buffer - # must break the line on "\n" to correctly parse the result - line += l - if "\n" in line: - line = line.replace("\r", "").split("\n") - if len(line) >1: - for l in line[0:-1]: - self.parse_device_line(l) - line = line[-1] + def on_serial_read(self, l): + if not l is None: + # readline is not returning the full line but only a buffer + # must break the line on "\n" to correctly parse the result + self._buffered_line += l + if "\n" in self._buffered_line: + self._buffered_line = self._buffered_line.replace("\r", "").split("\n") + if len(self._buffered_line) >1: + for l in self._buffered_line[0:-1]: # parse single lines if multiple \n are detected + self._parse_device_line(l) + self._buffered_line = self._buffered_line[-1] def _update_timeout(self): self._timeout_last_line = self.line_number self._timeout.update() + + # function called when the buffer has not been updated for some time (controlled by the buffered timeou) def _on_timeout(self): if (self.command_buffer_mutex.locked and self.line_number == self._timeout_last_line): # self.logger.warning("!Buffer timeout. Trying to clean the buffer!") - # to clean the buffer try to send an M114 message. In this way will trigger the buffer cleaning mechanism - line = self._generate_line("M114", no_buffer=True) # use the no_buffer to clean one position of the buffer after adding the command + # to clean the buffer try to send an M114 (marlin) or ? (Grbl) message. In this way will trigger the buffer cleaning mechanism + command = firmware.get_buffer_command(self._firmware) + line = self._generate_line(command, no_buffer=True) # use the no_buffer to clean one position of the buffer after adding the command + self.logger.log(settings_utils.LINE_SERVICE, line) with self.serial_mutex: self.serial.send(line) else: @@ -289,61 +403,129 @@ def _ack_received(self, safe_line_number=None, append_left_extra=False): self.command_send_mutex.release() # parse a line coming from the device - def parse_device_line(self, line): - if ("start" in line): - self.wait_device_ready() - self.reset_line_number() + def _parse_device_line(self, line): + # setting to avoid sending the message to the frontend in particular situations (i.e. status checking in grbl) + # will still print the status in the command line + hide_line = False - elif "ok" in line: # when an "ack" is received free one place in the buffer + if firmware.get_ACK(self._firmware) in line: # when an "ack" is received free one place in the buffer self._ack_received() - elif "Resend: " in line: - line_found = False - line_number = int(line.replace("Resend: ", "").replace("\r\n", "")) - items = deepcopy(self.command_buffer_history) - for n, c in items.items(): - n_line_number = int(n.strip("N")) - if n_line_number >= line_number: - line_found = True - # checks if the requested line is an M114. In that case do not need to print the error/resend command because its a wanted behaviour - #if line_number == n_line_number and "M114" in c: - # print_line = False - - # All the lines after the required one must be resent. Cannot break the loop now - with self.serial_mutex: - self.serial.send(c) - break - self._ack_received(safe_line_number=line_number-1, append_left_extra=True) - # the resend command is sending an ack. should add an entry to the buffer to keep the right lenght (because the line has been sent 2 times) - if not line_found: - self.logger.error("No line was found for the number required. Restart numeration.") - self.send_gcode_command("M110 N1") - + # check marlin specific messages + if firmware.is_grbl(self._firmware): + # status report + if "Grbl" in line: + self._on_device_ready_delay() + elif line.startswith("<"): + try: + # interested in the "Bf:xx," part where xx is the content of the buffer + # select buffer content lines + res = line.split("Bf:")[1] + res = int(res.split(",")[0]) + if res == 15: # 15 => buffer is empty on the device (should include also 14 to make it more flexible?) + with self.command_buffer_mutex: + self.command_buffer.clear() + if res!= 0: # 0 -> buffer is full + with self.command_buffer_mutex: + if len(self.command_buffer) > 0 and self.is_running(): + self.command_buffer.popleft() + + if (self.is_running() or self.is_paused()): + hide_line = True + self.logger.log(settings_utils.LINE_SERVICE, line) + except: # sometimes may not receive the entire line thus it may throw an error + pass + return + + # errors + elif "error:" in line: + self.logger.error("Grbl error: {}".format(line)) + # TODO check/parse error types and give some hint about the problem? + + + # TODO divide parser between firmwares? + # TODO set firmware type automatically on connection + # TODO add feedrate control with something like a knob on the user interface to make the drawing slower or faster + + # Marlin messages + else: + # if the device is reset manually, will restart the numeration + if ("start" in line): + self._on_device_ready_delay() + + # Marlin resend command if a message is not received correctly + if "Resend: " in line: + line_found = False + line_number = int(line.replace("Resend: ", "").replace("\r\n", "")) + items = deepcopy(self.command_buffer_history) + for n, c in items.items(): + n_line_number = int(n.strip("N")) + if n_line_number >= line_number: + line_found = True + # checks if the requested line is an M114. In that case do not need to print the error/resend command because its a wanted behaviour + #if line_number == n_line_number and "M114" in c: + # print_line = False + + # All the lines after the required one must be resent. Cannot break the loop now + with self.serial_mutex: + self.serial.send(c) + break + self._ack_received(safe_line_number=line_number-1, append_left_extra=True) + # the resend command is sending an ack. should add an entry to the buffer to keep the right lenght (because the line has been sent 2 times) + if not line_found: + self.logger.error("No line was found for the number required. Restart numeration.") + self._reset_line_number() + + # Marlin "unknow command" + elif "echo:Unknown command:" in line: + self.logger.error("Error: command not found. Can also be a communication error") + # TODO check feedrate response for M220 and set feedrate #elif "_______" in line: # must see the real output from marlin # self.feedrate = .... # must see the real output from marlin - elif "echo:Unknown command:" in line: - self.logger.error("Error: command not found. Can also be a communication error") - self.logger.log(settings_utils.LINE_RECEIVED, line) - self.handler.on_message_received(line) - - def get_status(self): - with self.serial_mutex: - return {"is_running":self._isrunning, "progress":[self.command_number, self.total_commands_number], "is_paused":self._ispaused, "is_connected":self.is_connected()} + if not hide_line: + self.handler.on_message_received(line) + # depending on the firmware, generates a correct line to send to the board + # args: + # * command: the gcode command to send + # * no_buffer (def: False): will not save the line in the buffer (used to get an ack to clear the buffer after a timeout if an ack is lost) def _generate_line(self, command, no_buffer=False): - self.line_number += 1 - line = "N{} {} ".format(self.line_number, command) - - # calculate checksum according to the wiki - cs = 0 - for i in line: - cs = cs ^ ord(i) - cs &= 0xff - - line +="*{}\n".format(cs) # add checksum to the line + line = command + # TODO add a "fast mode" remove spaces from commands and reduce number of decimals + # removing spaces is in conflict with the emulator... Need to update the parser there also + # fast mode test + if self.is_fast_mode: + line = command.split(" ") + new_line = [] + for l in line: + if l.startswith("X"): + l = "X" + self.command_resolution.format(float(l[1:])).rstrip("0").rstrip(".") + elif l.startswith("Y"): + l = "Y" + self.command_resolution.format(float(l[1:])).rstrip("0").rstrip(".") + new_line.append(l) + line = "".join(new_line) + + # marlin needs line numbers and checksum (grbl doesn't) + if firmware.is_marlin(self._firmware): + # add line number + self.line_number += 1 + line = "N{} {} ".format(self.line_number, command) + # calculate marlin checksum according to the wiki + cs = 0 + for i in line: + cs = cs ^ ord(i) + cs &= 0xff + + line +="*{}\n".format(cs) # add checksum to the line + + elif firmware.is_grbl(self._firmware): + if line != firmware.GRBL.buffer_command: + line += "\n" + + else: line += "\n" # store the line in the buffer with self.command_buffer_mutex: @@ -354,71 +536,9 @@ def _generate_line(self, command, no_buffer=False): return line - def send_gcode_command(self, command): - # clean the command a little - command = command.replace("\n", "").replace("\r", "").upper() - if command == " " or command == "": - return - - # some commands require to update the feeder status - # parse the command if necessary - if "M110" in command: - cs = command.split(" ") - for c in cs: - if c[0]=="N": - self.line_number = int(c[1:]) -1 - self.command_buffer.clear() - - # check if the command is in the "BUFFERED_COMMANDS" list and stops if the buffer is full - if any(code in command for code in BUFFERED_COMMANDS): - if "F" in command: - feed = self.feed_regex.findall(command) - self.feedrate = feed[0][0] - - with self.command_send_mutex: # wait until get some "ok" command to remove an element from the buffer - pass - - # send the command after parsing the content - # need to use the mutex here because it is changing also the line number - with self.serial_mutex: - # check if needs to send a "M114" command (actual position request) but not in the first lines - if (self.line_number % self.position_request_difference) == 0 and self.line_number > 5: - #self._generate_line("M114") # does not send the line to trigger the "resend" event and clean the buffer from messages that are already done - pass - - line = self._generate_line(command) - - self.serial.send(line) # send line - self.logger.log(settings_utils.LINE_SENT, line.replace("\n", "")) - - # TODO fix the problem with small geometries may be with the serial port being to slow. For long (straight) segments the problem is not evident. Do not understand why it is happening - - self._update_timeout() # update the timeout because a new command has been sent - - with self.command_buffer_mutex: - if(len(self.command_buffer)>=self.command_buffer_max_length and not self.command_send_mutex.locked()): - self.command_send_mutex.acquire() # if the buffer is full acquire the lock so that cannot send new lines until the reception of an ack. Notice that this will stop only buffered commands. The other commands will be sent anyway - - self.handler.on_new_line(line) # uses the handler callback for the new line - - # Send a multiline script - def send_script(self, script): - self.logger.info("Sending script") - script = script.split("\n") - for s in script: - if s != "" and s != " ": - self.send_gcode_command(s) - - def reset_line_number(self, line_number = 2): - self.logger.info("Resetting line number") - self.send_gcode_command("M110 N{}".format(line_number)) - - def request_feedrate(self): - self.send_gcode_command("M220") - - def serial_ports_list(self): - result = self.serial.serial_port_list() - return [] if result is None else result - - def is_connected(self): - return self.serial.is_connected() \ No newline at end of file + def _reset_line_number(self, line_number = 2): + # Marlin may require to reset the line numbers + if firmware.is_marlin(self._firmware): + self.logger.info("Resetting line number") + self.send_gcode_command("M110 N{}".format(line_number)) + # Grbl do not use line numbers diff --git a/server/hw_controller/feeder_event_manager.py b/server/hw_controller/feeder_event_manager.py index 2d9e6f20..1e5f3de7 100644 --- a/server/hw_controller/feeder_event_manager.py +++ b/server/hw_controller/feeder_event_manager.py @@ -1,9 +1,12 @@ from server.hw_controller.feeder import FeederEventHandler +import time class FeederEventManager(FeederEventHandler): def __init__(self, app): super().__init__() self.app = app + self.last_send_time = time.time() + self.command_index = 0 def on_element_ended(self, element): self.app.logger.info("B> Drawing ended") @@ -18,11 +21,19 @@ def on_element_started(self, element): self.app.semits.show_toast_on_UI("Drawing started") self.app.qmanager.set_element(element) self.app.qmanager.send_queue_status() + self.command_index = 0 def on_message_received(self, line): # Send the line to the server self.app.semits.hw_command_line_message(line) def on_new_line(self, line): - # Send the line to the server - self.app.semits.update_hw_preview(line) \ No newline at end of file + # Throttle down the commands sent to the frontend otherwise it will get the frontend stuck on the computation + # Avoid sendind every message, send them every maximum 0.5s (if they are fast, a set difference should not change much in the drawing preview) + if time.time() - self.last_send_time > 0.05 or self.command_index < 16: + # use the command index to track the first commands until the buffer is full + self.command_index += 1 + # update timer + self.last_send_time = time.time() + # Send the line to the server + self.app.semits.update_hw_preview(line) \ No newline at end of file diff --git a/server/hw_controller/firmware_defaults.py b/server/hw_controller/firmware_defaults.py new file mode 100644 index 00000000..1d980e73 --- /dev/null +++ b/server/hw_controller/firmware_defaults.py @@ -0,0 +1,43 @@ +from dotmap import DotMap + +MARLIN = DotMap() +MARLIN.name = "Marlin" +MARLIN.ACK = "ok" +MARLIN.buffer_command = "M114" +MARLIN.emergency_stop = "M112" +MARLIN.buffer_timeout = 30 + +def is_marlin(val): + return val == MARLIN.name + + +GRBL = DotMap() +GRBL.name = "Grbl" +GRBL.ACK = "ok" +GRBL.buffer_command = "?" +GRBL.emergency_stop = "!" +GRBL.buffer_timeout = 5 + +def is_grbl(val): + return val == GRBL.name + + +def get_ACK(firmware): + if firmware == MARLIN.name: + return MARLIN.ACK + else: return GRBL.ACK + +def get_buffer_command(firmware): + if firmware == MARLIN.name: + return MARLIN.buffer_command + else: return GRBL.buffer_command + +def get_buffer_timeout(firmware): + if firmware == MARLIN.name: + return MARLIN.buffer_timeout + else: return GRBL.buffer_timeout + +def get_emergency_stop_command(firmware): + if firmware == MARLIN.name: + return MARLIN.emergency_stop + else: return GRBL.emergency_stop \ No newline at end of file diff --git a/server/hw_controller/leds/leds_controller.py b/server/hw_controller/leds/leds_controller.py index 8edf48a9..f8a91b34 100644 --- a/server/hw_controller/leds/leds_controller.py +++ b/server/hw_controller/leds/leds_controller.py @@ -13,16 +13,17 @@ def __init__(self, app): # may have problems with the leds controller if self.driver.deinit or self.stop is not called on app shutdown def start(self): - self._running = True - self._th = Thread(target = self._thf, daemon=True) - self._th.name = "leds_controller" - self._th.start() + if not self.driver is None: + self._running = True + self._th = Thread(target = self._thf, daemon=True) + self._th.name = "leds_controller" + self._th.start() def stop(self): self._running = False def _thf(self): - self.app.logger.error("Leds controller started") + self.app.logger.info("Leds controller started") while(self._running): with self.mutex: if (self._should_update): @@ -50,6 +51,7 @@ def start_animation(self, animation): # Updates dimensions of the led matrix # Updates the led driver object only if the dimensions are changed def update_settings(self, settings): + self.stop() dims = (settings["leds"]["width"], settings["leds"]["height"]) if self.dimensions != dims: self.dimensions = dims @@ -65,6 +67,8 @@ def update_settings(self, settings): elif self.leds_type == "Dimmable": is_ok = self.driver.use_dimmable(self.pin) if not is_ok: + self.driver = None self.app.semits.show_toast_on_UI("Led driver type not compatible with current HW") self.app.logger.error("Cannot initialize leds controller") + self.start() \ No newline at end of file diff --git a/server/saves/default_settings.json b/server/saves/default_settings.json index 5515740b..8cfff659 100644 --- a/server/saves/default_settings.json +++ b/server/saves/default_settings.json @@ -1 +1,44 @@ -{"serial": {"port": null, "baud": "115200"}, "device": {"width": "100", "height": "100"}, "scripts": {"connected": "", "before": "", "after": ""}, "system": {"is_linux": false}, "leds": {"width": 0, "height":0, "type": "WS2812B", "available_types": ["WS2812B", "Dimmable"], "pin1": 18}} \ No newline at end of file +{ + "serial": { + "port": "COM3", + "baud": "115200", + "firmware": "Marlin", + "available_firmwares": [ + "Marlin", + "Grbl" + ], + "fast_mode": false + }, + "device": { + "width": "450", + "height": "300", + "radius": "200", + "type": "Cartesian", + "available_types": [ + "Cartesian", + "Polar", + "Scara" + ], + "angle_conversion_factor": 6, + "offset_angle_1": -1.5, + "offset_angle_2": 1.5 + }, + "scripts": { + "connected": "G28", + "before": "", + "after": "" + }, + "system": { + "is_linux": false + }, + "leds": { + "width": "30", + "height": "20", + "type": "Dimmable", + "available_types": [ + "WS2812B", + "Dimmable" + ], + "pin1": 18 + } +} \ No newline at end of file diff --git a/server/sockets_interface/socketio_callbacks.py b/server/sockets_interface/socketio_callbacks.py index 4e728565..18a44eff 100644 --- a/server/sockets_interface/socketio_callbacks.py +++ b/server/sockets_interface/socketio_callbacks.py @@ -70,6 +70,7 @@ def settings_save(data, is_connect): settings_utils.save_settings(data) settings = settings_utils.load_settings() app.leds_controller.update_settings(settings) + app.feeder.update_settings(settings) app.semits.show_toast_on_UI("Settings saved") # updating feeder @@ -158,3 +159,10 @@ def queue_stop_drawing(): def leds_set_color(data): color = json.loads(data) app.leds_controller.set_color((color["r"], color["g"], color["b"])) + + +# --------------------------------------------------------- MANUAL CONTROL ------------------------------------------------------------------------------- + +@socketio.on("control_emergency_stop") +def control_emergency_stop(): + app.feeder.emergency_stop() \ No newline at end of file diff --git a/server/utils/.gitignore b/server/utils/.gitignore new file mode 100644 index 00000000..b78090fb --- /dev/null +++ b/server/utils/.gitignore @@ -0,0 +1,3 @@ +test_cartesian.gcode +test_scara.gcode +test.thr \ No newline at end of file diff --git a/server/utils/buffered_timeout.py b/server/utils/buffered_timeout.py index f79ebabd..38f23efd 100644 --- a/server/utils/buffered_timeout.py +++ b/server/utils/buffered_timeout.py @@ -13,6 +13,9 @@ def __init__(self, timeout_delta, function, group=None, target=None, name=None, self.is_running = False self.setDaemon(True) self.update() + + def set_timeout_period(self, val): + self.timeout_delta = val def update(self): with self.mutex: diff --git a/server/utils/gcode_converter.py b/server/utils/gcode_converter.py index c88d1928..68c610af 100644 --- a/server/utils/gcode_converter.py +++ b/server/utils/gcode_converter.py @@ -1,84 +1,205 @@ from PIL import Image, ImageDraw +from math import cos, sin, pi +from dotmap import DotMap -def gcode_to_image(file, final_width=800, final_height=800, bg_color=(0,0,0), line_color=(255,255,255), final_border_px=20, line_width=5, verbose=False): - # -- Extracting coordinates -- - coords = [] - xmin = 100000 - xmax = -100000 - ymin = 100000 - ymax = -100000 - old_x = 0 - old_y = 0 - for line in file: - x = old_x - y = old_y - if line.startswith(";"): # skipping comments - continue - params = line.split(" ") - if not params[0]=="G01": # analyzing only G01. Should add checks for other commands - if(verbose): - print("Skipping line: "+line) - continue - for p in params: - if p[0]=="X": - x=float(p[1:-1]) - if p[0]=="Y": - y=float(p[1:-1]) - if xxmax: - xmax = x - if yymax: - ymax = y - c = [x,y] - coords.append(c) - old_x = x - old_y = y - if verbose: - print("Coordinates:") - print(coords) - print("XMIN:{}, XMAX:{}, YMIN:{}, YMAX:{}".format(xmin, xmax, ymin, ymax)) - - # -- Drawing the image -- - # Make the image larger than needed so can apply antialiasing - factor = 5 - img_width = final_width*factor - img_height = final_height*factor - border_px = final_border_px*factor - image = Image.new('RGB', (img_width, img_height), color=(bg_color[0],bg_color[1],bg_color[2],0)) - d = ImageDraw.Draw(image) - rangex = xmax-xmin - rangey = ymax-ymin - scaleX = (img_width - border_px*2)/rangex - scaleY = (img_height - border_px*2)/rangey - scale = min(scaleX, scaleY) - - def remapx(value): - return (value-xmin)*scale + border_px - - def remapy(value): - return img_height-((value-ymin)*scale + border_px) +class ImageFactory: + # straight lines gcode commands + straight_lines = ["G01", "G1", "G0", "G00"] + + # Args: + # - device: dict with the following values + # * type: device type (values: "Cartesian", "Polar", "Scara") + # * radius: for polar and scara needs the maximum radius of the device + # * offset_angle_1: for polar and scara needs an offset angle to rotate the view of the drawing (homing position angle) in motor units + # * offset_angle_2: for scara only: homing angle of the second part of the arm with respect to the first arm (alpha offset) in motor units + # * angle_conversion_factor (scara and polar): conversion value between motor units and radians (default for polar is pi, for scara is 6) + # - final_width (default: 800): final image width in px + # - final_height (default: 800): final image height in px + # - bg_color (default: (0,0,0)): tuple of the rgb color for the background + # - final_border_px (default: 20): the border to leave around the picture in px + # - line_width (default: 5): line thickness (px) + # - verbose (boolean) (default: False): if True prints the coordinates and other stuff in the command line + def __init__(self, device, final_width=800, final_height=800, bg_color=(0,0,0), line_color=(255,255,255), final_border_px=20, line_width=5, verbose=False): + self.final_width = final_width + self.final_height = final_height + self.bg_color = bg_color if len(bg_color) == 4 else (*bg_color, 0) # color argument requires also alpha value + self.line_color = line_color + self.final_border_px = final_border_px + self.line_width = line_width + self.verbose = verbose + self.update_device(device) + + def update_device(self, device): + device["type"] = device["type"].upper() + self.device = device + if self.is_scara(): + # scara robot conversion factor + # should be 2*pi/6 *1/2 (conversion between radians and motor units * 1/2 coming out of the theta alpha semplification) + self.pi_conversion = pi/float(device["angle_conversion_factor"]) # for scara robots follow https://forum.v1engineering.com/t/sandtrails-a-polar-sand-table/16844/61 + self.device_radius = float(device["radius"]) + self.offset_1 = float(device["offset_angle_1"]) * 2 # *2 for the conversion factor (will spare one operation in the loop) + self.offset_2 = float(device["offset_angle_2"]) * 2 # *2 for the conversion factor (will spare one operation in the loop) + elif self.is_polar(): + self.pi_conversion = 2.0*pi/float(device["angle_conversion_factor"]) + self.device_radius = float(device["radius"]) + self.offset_1 = float(device["offset_angle_1"]) + + def is_cartesian(self): + return self.device["type"] == "CARTESIAN" + + def is_polar(self): + return self.device["type"] == "POLAR" - p_1 = coords[0] - circle(d, (remapx(p_1[0]), remapy(p_1[1])), line_width*factor/2, line_color) # draw a circle to make round corners - for p in coords[1:]: # create the line between two consecutive coordinates - d.line([remapx(p_1[0]), remapy(p_1[1]), remapx(p[0]), remapy(p[1])], \ - fill=line_color, width=line_width*factor) - if verbose: - print("coord: {} _ {}".format(remapx(p_1[0]), remapy(p_1[1]))) - p_1 = p - circle(d, (remapx(p_1[0]), remapy(p_1[1])), line_width*factor/2, line_color) # draw a circle to make round corners - - # Resize the image to the final dimension to use antialiasing - image = image.resize((final_width, final_height), Image.ANTIALIAS) - return image - -def circle(d, c, r, color): - d.ellipse([c[0]-r, c[1]-r, c[0]+r, c[1]+r], fill=color, outline=None) + def is_scara(self): + return self.device["type"] == "SCARA" + + # converts a gcode file to an image + # requires: gcode file (not filepath) + # return the image file + def gcode_to_image(self, file): + coords = [] + xmin = 100000 + xmax = -100000 + ymin = 100000 + ymax = -100000 + old_X = 0 + old_Y = 0 + for line in file: + # skipping comments + if line.startswith(";"): + continue + + # remove inline comments + if ";" in line: + line = line.split(";")[0] + + if len(line) <3: + continue + + # parsing line + params = line.split(" ") + if not (params[0] in self.straight_lines): # TODO include also G2 and other curves command? + if(self.verbose): + print("Skipping line: "+line) + continue + + com_X = old_X # command X value + com_Y = old_Y # command Y value + # selecting values + for p in params: + if p[0]=="X": + com_X = float(p[1:]) + if p[0]=="Y": + com_Y = float(p[1:]) + + # converting command X and Y to x, y coordinates (default conversion is cartesian) + x = com_X + y = com_Y + if self.is_scara(): + # m1 = thehta+alpha + # m2 = theta-alpha + # -> + # theta = (m1 + m2)/2 + # alpha = (m1-m2)/2 + # (moving /2 into the pi_conversion to reduce the number of multiplications) + theta = (com_X + com_Y + self.offset_1) * self.pi_conversion + rho = cos((com_X - com_Y + self.offset_2) * self.pi_conversion) * self.device_radius + # calculate cartesian coords + x = cos(theta) * rho + y = sin(theta) * rho + elif self.is_polar(): + x = cos((com_X + self.offset_1)*self.pi_conversion) * com_Y * self.device_radius + y = sin((com_X + self.offset_2)*self.pi_conversion) * com_Y * self.device_radius + + + if xxmax: + xmax = x + if yymax: + ymax = y + c = (x,y) + coords.append(c) + old_X = com_X + old_Y = com_Y + if self.verbose: + print("Coordinates:") + print(coords) + print("XMIN:{}, XMAX:{}, YMIN:{}, YMAX:{}".format(xmin, xmax, ymin, ymax)) + limits = { + "xmin": xmin, + "xmax": xmax, + "ymin": ymin, + "ymax": ymax + } + + # return the image obtained from the coordinates + return self.draw_image(coords, limits) + + + # draws an image with the given coordinates (array of tuple of points) and the extremes of the points + def draw_image(self, coords, limits): + limits = DotMap(limits) + # Make the image larger than needed so can apply antialiasing + factor = 5.0 + img_width = self.final_width*factor + img_height = self.final_height*factor + border_px = self.final_border_px*factor + image = Image.new('RGB', (int(img_width), int(img_height)), color=self.bg_color) + d = ImageDraw.Draw(image) + rangex = limits.xmax-limits.xmin + rangey = limits.ymax-limits.ymin + scaleX = float(img_width - border_px*2)/rangex + scaleY = float(img_height - border_px*2)/rangey + scale = min(scaleX, scaleY) + + def remapx(value): + return int((value-limits.xmin)*scale + border_px) + + def remapy(value): + return int(img_height-((value-limits.ymin)*scale + border_px)) + + p_1 = coords[0] + self.circle(d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width*factor/2, self.line_color) # draw a circle to make round corners + for p in coords[1:]: # create the line between two consecutive coordinates + d.line([remapx(p_1[0]), remapy(p_1[1]), remapx(p[0]), remapy(p[1])], \ + fill=self.line_color, width=int(self.line_width*factor)) + if self.verbose: + print("coord: {} _ {}".format(remapx(p_1[0]), remapy(p_1[1]))) + p_1 = p + self.circle(d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width*factor/2, self.line_color) # draw a circle to make round corners + + # Resize the image to the final dimension to use antialiasing + image = image.resize((int(self.final_width), int(self.final_height)), Image.ANTIALIAS) + return image + + def circle(self, d, c, r, color): + d.ellipse([c[0]-r, c[1]-r, c[0]+r, c[1]+r], fill=color, outline=None) + + def thr_to_image(self, file): + pass if __name__ == "__main__": - with open('test.gcode') as file: - im = gcode_to_image(file, verbose=True) + # testing scara + device = { + "type": "Scara", + "angle_conversion_factor": 6.0, + "radius": 200, + "offset_angle": -1.5, + "offset_angle_2": 1.5 + } + factory = ImageFactory(device, verbose=True) + with open('server/utils/test_scara.gcode') as file: + im = factory.gcode_to_image(file) + im.show() + + # testing cartesian + device = { + "type": "Cartesian" + } + factory = ImageFactory(device, verbose=True) + with open('server/utils/test_cartesian.gcode') as file: + im = factory.gcode_to_image(file) im.show() \ No newline at end of file diff --git a/server/utils/settings_utils.py b/server/utils/settings_utils.py index 0f16f902..e6888b9f 100644 --- a/server/utils/settings_utils.py +++ b/server/utils/settings_utils.py @@ -8,13 +8,14 @@ # Logging levels (see the documentation of the logging module for more details) LINE_SENT = 6 LINE_RECEIVED = 5 +LINE_SERVICE = 4 # settings paths settings_path = "./server/saves/saved_settings.json" defaults_path = "./server/saves/default_settings.json" def save_settings(settings): - dataj = json.dumps(settings) + dataj = json.dumps(settings, indent=4) with open(settings_path,"w") as f: f.write(dataj) @@ -56,8 +57,10 @@ def match_dict(mod_dict, ref_dict): # print the level of the logger selected def print_level(level, logger_name): description = "" - if level < LINE_RECEIVED: + if level < LINE_SERVICE: description = "NOT SET" + elif level < LINE_RECEIVED: + description = "LINE_SERVICE" elif level < LINE_SENT: description = "LINE_RECEIVED" elif level < 10: