diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 76fef76e..03b80a7e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,9 @@ name: Python application on: push: - branches: [ master, flask-tests ] + branches: [ master, flask-tests, alpha ] pull_request: - branches: [ master, flask-tests ] + branches: [ master, flask-tests, alpha ] jobs: build: diff --git a/docs/usage.md b/docs/usage.md index 569503f2..66248d78 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -23,6 +23,27 @@ In a playlist it is possible to add additional type of elements: In the manual control tab it is possible to run single gcode command with a `terminal-like` feature. On the right a preview of the position of the drawing is shown: the preview is not in realtime as the device is buffering the commands thus there may be a small delay between the drawing and the real position of the device. +## Settings + +### Scripts + +Scripts are executed once when the device is connected or before and after a "Drawing element" is played. You can put your own GCODE to make the table achieve specific tasks. It is also possible to use macros in those fields. Have a better look at the "Macros" in the "Advanced usage" section. + +## Advanced usage + +### Macros + +The software can evaluate some extra gcode formulas. Those formulas are not standardized macros but can be used as follow: macros are delimited by the `&` character. It is possible to use standard python notation in the formulas with the addition of the basic math functions. For more information about this check the [parser's instruction page](https://pypi.org/project/py-expression-eval/). +It is possible to use `X`, `Y` and `F` as variables. They will be substituted with the last received value. + +Some examples: +``` +G0 X10 Y7 +G0 X& x+5 & --> G0 X15 + +G92 X& x%6 & Y& y%6 & --> G92 X3 Y1 +``` + ___ -If you need help or something is not working feel free to open an issue. If you want to improve this page feel free to open a pull request ;) \ No newline at end of file +**If you need help or something is not working feel free to open an issue. If you want to improve this page feel free to open a pull request ;)** \ No newline at end of file diff --git a/docs/versions.md b/docs/versions.md index 25eaa45f..9c8f22fd 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -1,6 +1,15 @@ # Software versions and added features +## v0.4-alpha + +* Rework of the queue mechanics and the queue controls +* Added top bar controls +* Added eta estimation +* Fixed mobile layout +* Other fixes + ## v0.3-alpha + * Major improvements to the playlists * Stability and performance increased * Usability improved @@ -11,17 +20,20 @@ * Other minor fixes and improvements ## v0.2.1-alpha + * Some changes in the playlists and added a new "element" mechanics for the playlist: * The playlist can contain elements: at the moment only the drawing and the custom_command elements are available... Soon will introduce more * First test with leds. For the moment working with WS2812B and dimmable leds only * Some important fixes found during the development of the other features ## v0.2-alpha + * React introduction * The frontend has been reworked completely and now is based on React, Redux and Bootstrap * The interface is not tested much, easy to find bugs ## v0.1-alpha + * Very first release * This version is just a demo to show the software idea and to spread the word around. A lot of work must be done. The software is more or less stable but there are only basic functions available. diff --git a/frontend/src/App.scss b/frontend/src/App.scss index bde78628..393bd84d 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -64,6 +64,22 @@ button{ font-weight: 900; } +.no-margin{ + margin: 0px !important; +} + +.infos-box{ + @extend .bg-light; + @extend .rounded; + @extend .text-dark; +} + +footer{ + position: fixed; + bottom: 0px; + width: 100%; +} + /* HOVERS */ .image-overlay-light{ @@ -171,7 +187,6 @@ hr{ padding-right: 30px; padding-left: 30px; } - } /* Carousel mutli overrides */ diff --git a/frontend/src/components/ConfirmButton.js b/frontend/src/components/ConfirmButton.js index afdd4811..2a3d368a 100644 --- a/frontend/src/components/ConfirmButton.js +++ b/frontend/src/components/ConfirmButton.js @@ -9,14 +9,17 @@ class ConfirmButton extends Component{ } render(){ - return
- this.setState({mustConfirm: true})} - icon={this.props.icon}> - {this.props.children} - -
+ // TODO make this better + return
+
+ this.setState({mustConfirm: true})} + icon={this.props.icon}> + {this.props.children} + +
+
Are you sure? diff --git a/frontend/src/components/IconButton.js b/frontend/src/components/IconButton.js index 48266202..6efc8a79 100644 --- a/frontend/src/components/IconButton.js +++ b/frontend/src/components/IconButton.js @@ -1,21 +1,46 @@ import { Component } from 'react'; -import { Button } from 'react-bootstrap'; +import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import "../App.scss"; class IconButton extends Component{ - render(){ + renderButton(){ let iconProps = this.props.iconLarge === "true" ? {width: "32", height: "32"} : {}; + iconProps = this.props.iconMedium === "true" ? {width: "20", height: "20"} : iconProps; + let iconExtraClass = this.props.iconLarge === "true" ? "m-2" : ""; - let icon = this.props.icon !== undefined ? : ""; + if (this.props.children === undefined || this.props.children === "") + iconExtraClass = "no-margin"; + let icon = this.props.icon !== undefined ? : ""; let text = this.props.children !== undefined ? {this.props.children} : undefined; - return + else return } + render(){ + if (this.props.tip !== undefined && this.props.tip !== "") + return + {this.props.tip} + } + delay={{ show: 3000, hide: 250 }} + placement="bottom"> + {this.renderButton()} + + else return this.renderButton(); + } + } export default IconButton; \ No newline at end of file diff --git a/frontend/src/components/PlayContinuous.js b/frontend/src/components/PlayContinuous.js deleted file mode 100644 index 335deabf..00000000 --- a/frontend/src/components/PlayContinuous.js +++ /dev/null @@ -1,140 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { Col, Form, Row } from 'react-bootstrap'; -import { PlayFill, Shuffle, StopFill } from 'react-bootstrap-icons'; - -import IconButton from './IconButton'; - -import { getQueueEmpty, getQueueIntervalValue } from '../structure/tabs/queue/selector'; - -import { queueStartDrawings, queueStartShuffleDrawings, queueStopContinuous, queueSetInterval, playlistQueue } from '../sockets/sEmits'; - -const DEFAULT_MAX_VALUE = 86400.0; // 60*60*24 seconds in a day - -const mapStateToProps = (state) => { - return { - isQueueEmpty: getQueueEmpty(state), - intervalValue: getQueueIntervalValue(state) - } -} - -class PlayContinuous extends Component{ - constructor(props){ - super(props); - this.initialPropsIntervalValue = this.props.intervalValue; - this.state = { - intervalValue: this.props.intervalValue || 300 - }; - } - - convertMaxDelayLabel(delay){ - return delay > DEFAULT_MAX_VALUE ? (delay/DEFAULT_MAX_VALUE).toFixed(2) + " days" : "1 day"; - } - - saveInterval(){ - queueSetInterval(this.state.intervalValue); - } - - componentDidUpdate(){ - if (this.props.intervalValue !== this.initialPropsIntervalValue){ - this.initialPropsIntervalValue = this.props.intervalValue; - this.setState({...this.state, intervalValue: this.props.intervalValue || 300}); - } - } - - renderButtons(){ - if (!this.props.isQueueEmpty) - return - Stop queue - - else return - - { - queueStartDrawings(this.props.playlistId); - }} - className="w-100 center btn-dark p-2">Play - - - queueStartShuffleDrawings(this.props.playlistId)} - className="w-100 center btn-dark p-2">Shuffle play - - - } - - render(){ - return
- - {this.renderButtons()} - - - - 0s - Interval - {this.convertMaxDelayLabel(this.state.intervalValue)} - - - { - this.setState({...this.state, intervalValue: evt.target.value}); - }} - onMouseUp={(evt) => { - this.setState({...this.state, intervalValue: evt.target.value}, - this.saveInterval.bind(this)); - }}/> - - - - - - - - Delay between drawings [s] - - - - - { - this.saveInterval(); - }} - onChange={(evt) => { - if (evt.target.value === ""){ - this.setState({...this.state, intervalValue: ""}); - return; - } - let val = parseInt(evt.target.value); - if (Number.isInteger(val)){ - if (val > 0) - this.setState({...this.state, intervalValue: val}); - } - - }} - onKeyUp={(evt) => { - if (evt.code === "Enter"){ - if (this.state.intervalValue === "") - this.setState({...this.state, intervalValue: 0}, - this.saveInterval.bind(this)); - else this.saveInterval(); - evt.preventDefault(); - } - else return evt; - }}/> - - - - - -
- - } -} - -export default connect(mapStateToProps)(PlayContinuous); \ No newline at end of file diff --git a/frontend/src/components/Section.js b/frontend/src/components/Section.js index 9fc2c5e7..7400ffb3 100644 --- a/frontend/src/components/Section.js +++ b/frontend/src/components/Section.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import IconButton from './IconButton'; +import { Row, Col } from "react-bootstrap"; function renderTitle(component, tag){ const TagName = tag; @@ -13,15 +14,20 @@ class Section extends Component{ render(){ return
-
- {renderTitle(this, "h2")} - this.props.sectionButtonHandler()} - icon={this.props.buttonIcon}> - {this.props.sectionButton} - -
+ + + {renderTitle(this, "h2")} + + + + this.props.sectionButtonHandler()} + icon={this.props.buttonIcon}> + {this.props.sectionButton} + + + {this.props.children}
} diff --git a/frontend/src/components/SortableElements.js b/frontend/src/components/SortableElements.js index 3daffd63..f309cdd4 100644 --- a/frontend/src/components/SortableElements.js +++ b/frontend/src/components/SortableElements.js @@ -32,10 +32,8 @@ class SortableElements extends Component{ } removeElement(idx){ - let oldState = this.state.list; - let newList = oldState.filter((el, i)=> {return el.id !==idx}); - this.setState({...this.state, list: newList, edited: true}); - this.prepareUpdate(newList); + let newList = this.state.list.filter((el, key) => {return key !== idx}); + this.setState({...this.state, list: newList, edited: true}, () => this.prepareUpdate(newList)); } render(){ @@ -64,7 +62,7 @@ class SortableElements extends Component{ } return true; }}> - {this.state.list.map((el)=>{ // generate list of elements to show in the list + {this.state.list.map((el, idx)=>{ // generate list of elements to show in the list if (el.element_type === "control_card"){ let c = React.cloneElement(this.props.children, {key:0}); return c; // return the child as the control card @@ -72,8 +70,8 @@ class SortableElements extends Component{ let ElementType = getElementClass(el); - return this.removeElement(el.id)} + return this.removeElement(idx)} showCross={this.state.showChildCross}> this.props.onElementOptionsChange(el)} @@ -89,7 +87,8 @@ class ElementCard extends React.Component{ super(props); this.state = { active: true, - showCross: false + showCross: false, + unmounted: false } } @@ -102,8 +101,9 @@ class ElementCard extends React.Component{ } onTransitionEnd(){ - if (!this.state.active){ - this.props.handleUnmount(this) + if (!this.state.active && !this.state.unmounted){ + this.setState({...this.state, unmounted: true, active: true}, ()=>this.props.handleUnmount(this)); + // TODO check if it is deleting only one element at a time from the list } } diff --git a/frontend/src/sockets/sEmits.js b/frontend/src/sockets/sEmits.js index 20c8f1cb..73819a21 100644 --- a/frontend/src/sockets/sEmits.js +++ b/frontend/src/sockets/sEmits.js @@ -38,6 +38,14 @@ function drawingQueue(code){ socket.emit("drawing_queue", code); } +function drawingPause(){ + socket.emit("drawing_pause"); +} + +function drawingResume(){ + socket.emit("drawing_resume"); +} + // ---- LEDS ---- @@ -85,33 +93,38 @@ function queueSetOrder(list){ socket.emit("queue_set_order", JSON.stringify(list)); } -function queueStopCurrent(){ - socket.emit("queue_stop_current"); +// stops only the current drawing and go on with the next one +function queueNextDrawing(){ + socket.emit("queue_next_drawing"); window.showToast(
The current drawing is being stopped.
The device will still run until the buffer is empty.
) } +// clears the queue and stop the device function queueStopAll(){ socket.emit("queue_stop_all"); - window.showToast(
Stopping the device...
) + window.showToast(
Stopping the device...
); } -function queueStopContinuous(){ - socket.emit("queue_stop_continuous") +// updates the value of the "repeat" flag +function queueSetRepeat(val){ + socket.emit("queue_set_repeat", val); } -function queueStartDrawings(playlistId=0,shuffle=false, ){ - socket.emit("queue_start_drawings", JSON.stringify({shuffle: shuffle, playlist: playlistId})); +// updates the value of the "shuffle" flag +function queueSetShuffle(val){ + socket.emit("queue_set_shuffle", val); } -function queueStartShuffleDrawings(playlistId=0){ - queueStartDrawings(playlistId, true); +// updates the value of the queue interval +function queueSetInterval(val){ + socket.emit("queue_set_interval", val); } -function queueSetInterval(interval){ - socket.emit("queue_set_interval", interval); +// starts a random drawing +function queueStartRandom(){ + socket.emit("queue_start_random"); } - // ---- MANUAL CONTROL ---- function controlEmergencyStop(){ @@ -125,6 +138,8 @@ export { drawingDelete, drawingsRequest, drawingQueue, + drawingPause, + drawingResume, ledsSetColor, playlistsRequest, playlistDelete, @@ -133,12 +148,12 @@ export { playlistCreateNew, queueGetStatus, queueSetOrder, - queueStopCurrent, + queueNextDrawing, queueStopAll, - queueStopContinuous, - queueStartDrawings, - queueStartShuffleDrawings, + queueSetRepeat, + queueSetShuffle, queueSetInterval, + queueStartRandom, settingsSave, settingsShutdownSystem, settingsRebootSystem diff --git a/frontend/src/structure/Content.js b/frontend/src/structure/Content.js index 71967c90..37d94251 100644 --- a/frontend/src/structure/Content.js +++ b/frontend/src/structure/Content.js @@ -27,7 +27,7 @@ const mapStateToProps = (state) => { class Content extends Component{ render(){ - return
+ return
diff --git a/frontend/src/structure/Footer.js b/frontend/src/structure/Footer.js index 8ba79ae4..3910a64a 100644 --- a/frontend/src/structure/Footer.js +++ b/frontend/src/structure/Footer.js @@ -3,8 +3,8 @@ import {Navbar, Nav} from 'react-bootstrap'; class Footer extends Component{ render(){ - return
- + return
+ -
+ } } diff --git a/frontend/src/structure/TopBar.js b/frontend/src/structure/TopBar.js index 2de0f4a6..c6532093 100644 --- a/frontend/src/structure/TopBar.js +++ b/frontend/src/structure/TopBar.js @@ -4,7 +4,7 @@ import { ChevronCompactLeft, Sliders } from 'react-bootstrap-icons'; import { connect } from 'react-redux'; import IconButton from '../components/IconButton'; -import QueuePreview from './tabs/queue/QueuePreview'; +import QueueControls from './tabs/queue/QueueControls'; import { showBack } from './tabs/selector'; import { setTab, tabBack } from './tabs/Tabs.slice'; @@ -73,7 +73,7 @@ class TopBar extends Component{ {/*{this.props.handleTab("leds")}}>LEDs*/} {this.renderBack()} - {this.props.handleTab("queue")}}/> + {this.renderSettingsButton()} diff --git a/frontend/src/structure/tabs/Home.js b/frontend/src/structure/tabs/Home.js index 49dddf79..9204f0bc 100644 --- a/frontend/src/structure/tabs/Home.js +++ b/frontend/src/structure/tabs/Home.js @@ -7,7 +7,6 @@ import 'react-multi-carousel/lib/styles.css'; import { Section } from '../../components/Section'; import PlaceholderCard from '../../components/PlaceholderCard'; -import PlayContinuous from '../../components/PlayContinuous'; import UploadDrawingsModal from './drawings/UploadDrawing'; import DrawingCard from './drawings/DrawingCard'; @@ -23,16 +22,16 @@ import { setShowNewPlaylist } from './playlists/Playlists.slice'; const mapStateToProps = (state) => { return { - drawings: getDrawingsLimited(state), - playlists: getPlaylistsLimited(state) + drawings: getDrawingsLimited(state), + playlists: getPlaylistsLimited(state) } } const mapDispatchToProps = (dispatch) => { return { - setRefreshDrawing: () => dispatch(setRefreshDrawing(true)), - handleTab: (name) => dispatch(setTab(name)), - setShowNewPlaylist: () => dispatch(setShowNewPlaylist(true)) + setRefreshDrawing: () => dispatch(setRefreshDrawing(true)), + handleTab: (name) => dispatch(setTab(name)), + setShowNewPlaylist: () => dispatch(setShowNewPlaylist(true)) } } @@ -108,7 +107,6 @@ class Home extends Component{ buttonIcon={FileEarmarkPlus} sectionButtonHandler={()=>this.setState({showUpload: true})} titleButtonHandler={()=>this.props.handleTab("drawings")}> - {this.renderDrawings(this.props.drawings)} diff --git a/frontend/src/structure/tabs/drawings/DrawingCard.js b/frontend/src/structure/tabs/drawings/DrawingCard.js index 71b60d79..04c5dc6b 100644 --- a/frontend/src/structure/tabs/drawings/DrawingCard.js +++ b/frontend/src/structure/tabs/drawings/DrawingCard.js @@ -21,11 +21,13 @@ class DrawingCard extends Component{ render(){ if (this.props.drawing === undefined || this.props.drawing === null) return ""; + const highlight = this.props.highlight ? " card-highlight" : ""; + return drawingQueue(id)} drawing={this.props.drawing}> this.props.showSingleDrawing(this.props.drawing.id)}> -
- + Drawing image
diff --git a/frontend/src/structure/tabs/drawings/DrawingCard.scss b/frontend/src/structure/tabs/drawings/DrawingCard.scss index d23135dd..86673d62 100644 --- a/frontend/src/structure/tabs/drawings/DrawingCard.scss +++ b/frontend/src/structure/tabs/drawings/DrawingCard.scss @@ -1,7 +1,13 @@ +@import "../../../colors"; + .card-img-top{ height: 100%; } .modal-drawing-preview{ width: 80%; +} + +.card-highlight{ + border: 5px solid $primary; } \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/Drawings.js b/frontend/src/structure/tabs/drawings/Drawings.js index b3840bf4..6e5f3bac 100644 --- a/frontend/src/structure/tabs/drawings/Drawings.js +++ b/frontend/src/structure/tabs/drawings/Drawings.js @@ -4,20 +4,25 @@ import { FileEarmarkPlus } from 'react-bootstrap-icons'; import { connect } from 'react-redux'; import { Section } from '../../../components/Section'; -import PlayContinuous from '../../../components/PlayContinuous'; import UploadDrawingsModal from './UploadDrawing'; import DrawingCard from './DrawingCard'; import { setRefreshDrawing } from './Drawings.slice'; import { getDrawings } from './selector'; +import { getQueueCurrent } from '../queue/selector'; const mapStateToProps = (state) => { - return { drawings: getDrawings(state) } + return { + drawings: getDrawings(state), + currentElement: getQueueCurrent(state) + } } const mapDispatchToProps = (dispatch) => { - return {setRefreshDrawing: () => dispatch(setRefreshDrawing(true))} + return { + setRefreshDrawing: () => dispatch(setRefreshDrawing(true)) + } } class Drawings extends Component{ @@ -38,13 +43,17 @@ class Drawings extends Component{ } renderDrawings(drawings){ - if (drawings !== undefined) + if (drawings !== undefined){ + let currentDrawingId = 0; + if (this.props.currentElement !== undefined) + if (this.props.currentElement.element_type === "drawing") + currentDrawingId = this.props.currentElement.drawing_id return drawings.map((d, index)=>{ return - + }); - else{ + }else{ return
} } @@ -55,8 +64,6 @@ class Drawings extends Component{ sectionButton="Upload new drawing" buttonIcon={FileEarmarkPlus} sectionButtonHandler={()=>this.setState({showUpload: true})}> - - {this.renderDrawings(this.props.drawings)} diff --git a/frontend/src/structure/tabs/drawings/SingleDrawing.js b/frontend/src/structure/tabs/drawings/SingleDrawing.js index 00d68e2a..6e80e382 100644 --- a/frontend/src/structure/tabs/drawings/SingleDrawing.js +++ b/frontend/src/structure/tabs/drawings/SingleDrawing.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { Container, Form, Modal } from 'react-bootstrap'; +import { Container, Form, Modal, Row, Col } from 'react-bootstrap'; import { FileEarmarkX, Play, Plus, PlusSquare, X } from 'react-bootstrap-icons'; import { connect } from 'react-redux'; @@ -11,10 +11,9 @@ import IconButton from '../../../components/IconButton'; import { createElementDrawing } from '../playlists/elementsFactory'; import { getImgUrl } from '../../../utils/utils'; -import { getQueueEmpty } from '../queue/selector'; +import { getQueueCurrent } from '../queue/selector'; import { getSingleDrawing } from './selector'; import { getPlaylistsList } from '../playlists/selector'; -import { setQueueNotEmpty } from '../queue/Queue.slice'; import { tabBack } from '../Tabs.slice'; import { deleteDrawing, setRefreshDrawing } from './Drawings.slice'; import { addToPlaylist } from '../playlists/Playlists.slice'; @@ -23,19 +22,18 @@ import Image from '../../../components/Image'; const mapStateToProps = (state) => { return { - isQueueEmpty: getQueueEmpty(state), - drawing: getSingleDrawing(state), - playlists: getPlaylistsList(state) + currentElement: getQueueCurrent(state), + drawing: getSingleDrawing(state), + playlists: getPlaylistsList(state) } } const mapDispatchToProps = (dispatch) => { return { - setQueueNotEmpty: () => dispatch(setQueueNotEmpty()), - handleTabBack: () => dispatch(tabBack()), - refreshDrawings: () => dispatch(setRefreshDrawing()), - deleteDrawing: (id) => dispatch(deleteDrawing(id)), - addToPlaylist: (bundle) => dispatch(addToPlaylist(bundle)) + handleTabBack: () => dispatch(tabBack()), + refreshDrawings: () => dispatch(setRefreshDrawing()), + deleteDrawing: (id) => dispatch(deleteDrawing(id)), + addToPlaylist: (bundle) => dispatch(addToPlaylist(bundle)) } } @@ -50,7 +48,7 @@ class SingleDrawing extends Component{ renderAddToPlaylistButton(){ if (this.props.playlists.length > 0){ - return this.setState({...this.state, showPlaylists: true})}> Add to playlist @@ -61,7 +59,7 @@ class SingleDrawing extends Component{ render(){ if (this.props.drawing.id !== undefined){ let startDrawingLabel = "Queue drawing"; - if (this.props.isQueueEmpty){ + if (this.props.currentElement === undefined){ startDrawingLabel = "Start drawing"; } // TODO add possibility to edit the gcode file and render again the drawing @@ -69,25 +67,32 @@ class SingleDrawing extends Component{

{this.props.drawing.filename}

-
- { - drawingQueue(this.props.drawing.id); - }}> - {startDrawingLabel} - - {this.renderAddToPlaylistButton()} - { - drawingDelete(this.props.drawing.id); - this.props.deleteDrawing(this.props.drawing.id); - this.props.handleTabBack(); - }}> - Delete drawing - -
+ + + { + drawingQueue(this.props.drawing.id); + this.props.handleTabBack(); + }}> + {startDrawingLabel} + + + + {this.renderAddToPlaylistButton()} + + + { + drawingDelete(this.props.drawing.id); + this.props.deleteDrawing(this.props.drawing.id); + this.props.handleTabBack(); + }}> + Delete drawing + + +
Drawing image
diff --git a/frontend/src/structure/tabs/manual/CommandLine.js b/frontend/src/structure/tabs/manual/CommandLine.js index 16efad79..105484c8 100644 --- a/frontend/src/structure/tabs/manual/CommandLine.js +++ b/frontend/src/structure/tabs/manual/CommandLine.js @@ -87,7 +87,7 @@ class CommandLine extends Component{ onKeyUp={this.keyUpHandler.bind(this)} ref={this.inputRef}/> - +
diff --git a/frontend/src/structure/tabs/manual/ManualControl.js b/frontend/src/structure/tabs/manual/ManualControl.js index 37ea91a9..65e988de 100644 --- a/frontend/src/structure/tabs/manual/ManualControl.js +++ b/frontend/src/structure/tabs/manual/ManualControl.js @@ -25,9 +25,11 @@ class ManualControl extends Component{ - - - + + + + + diff --git a/frontend/src/structure/tabs/manual/ManualControl.scss b/frontend/src/structure/tabs/manual/ManualControl.scss index f8a7c2c3..95ff33d7 100644 --- a/frontend/src/structure/tabs/manual/ManualControl.scss +++ b/frontend/src/structure/tabs/manual/ManualControl.scss @@ -17,12 +17,12 @@ min-height: 40vh; } -.command-history-container{ +.command-line-history{ max-height: 40vh; } -@include media-breakpoint-up(md) { +@media (max-width: 575.98px) { .command-line-history{ - max-height: 40vh; + height: 300px; } } \ No newline at end of file diff --git a/frontend/src/structure/tabs/playlists/SinglePlaylist/Elements.js b/frontend/src/structure/tabs/playlists/SinglePlaylist/Elements.js index dc01d0c1..6a1cb652 100644 --- a/frontend/src/structure/tabs/playlists/SinglePlaylist/Elements.js +++ b/frontend/src/structure/tabs/playlists/SinglePlaylist/Elements.js @@ -186,7 +186,7 @@ class TimingElement extends GenericElement{ renderElement(){ let printTime = "" if (this.state.type === "delay") - printTime = "Delay: " + this.state.delay + "s"; + printTime = "Delay: " + Math.round(this.state.delay) + "s"; else if (this.state.type === "expiry_date") printTime = "Expires on: \n" + this.state.expiry_date; else if (this.state.type === "alarm_time") diff --git a/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js b/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js index b8f044d8..9cddb4c4 100644 --- a/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js +++ b/frontend/src/structure/tabs/playlists/SinglePlaylist/SinglePlaylist.js @@ -8,36 +8,35 @@ import { Play, X } from 'react-bootstrap-icons'; import ConfirmButton from '../../../../components/ConfirmButton'; import SortableElements from '../../../../components/SortableElements'; import IconButton from '../../../../components/IconButton'; +import ControlCard from './ControlCard'; import { playlistDelete, playlistQueue, playlistSave } from '../../../../sockets/sEmits'; import { listsAreEqual } from '../../../../utils/dictUtils'; import { tabBack } from '../../Tabs.slice'; import { addToPlaylist, deletePlaylist, resetPlaylistDeletedFlag, resetMandatoryRefresh, updateSinglePlaylist } from '../Playlists.slice'; -import ControlCard from './ControlCard'; import { getSinglePlaylist, playlistHasBeenDeleted, singlePlaylistMustRefresh } from '../selector'; -import PlayContinuous from '../../../../components/PlayContinuous'; const mapStateToProps = (state) => { return { - playlist: getSinglePlaylist(state), - mandatoryRefresh: singlePlaylistMustRefresh(state), - playlistDeleted: playlistHasBeenDeleted(state) + playlist: getSinglePlaylist(state), + mandatoryRefresh: singlePlaylistMustRefresh(state), + playlistDeleted: playlistHasBeenDeleted(state) } } const mapDispatchToProps = (dispatch) => { return { - handleTabBack: () => { + handleTabBack: () => { // can use multiple actions thanks to the thunk library dispatch(tabBack()); dispatch(resetPlaylistDeletedFlag()); }, - deletePlaylist: (id) => dispatch(deletePlaylist(id)), - updateSinglePlaylist: (pl) => dispatch(updateSinglePlaylist(pl)), - addElements: (elements) => dispatch(addToPlaylist(elements)), - resetMandatoryRefresh: () => dispatch(resetMandatoryRefresh()), - resetPlaylistDeletedFlag: () => dispatch(resetPlaylistDeletedFlag()) + deletePlaylist: (id) => dispatch(deletePlaylist(id)), + updateSinglePlaylist: (pl) => dispatch(updateSinglePlaylist(pl)), + addElements: (elements) => dispatch(addToPlaylist(elements)), + resetMandatoryRefresh: () => dispatch(resetMandatoryRefresh()), + resetPlaylistDeletedFlag: () => dispatch(resetPlaylistDeletedFlag()) } } @@ -87,6 +86,14 @@ class SinglePlaylist extends Component{ } componentDidUpdate(){ + if (this.props.mandatoryRefresh){ + this.setState({...this.state, elements: this.addControlCard(this.props.playlist.elements)}); + this.props.resetMandatoryRefresh() + } + + if (this.props.playlistHasBeenDeleted){ + this.props.handleTabBack(); + } } handleElementUpdate(element){ @@ -115,7 +122,21 @@ class SinglePlaylist extends Component{ renderStartButtons(){ if (this.state.elements.length === 0){ return "" - }else return + }else { + let startDrawingLabel = "Queue playlist"; + if (this.props.currentElement === undefined){ + startDrawingLabel = "Start playlist"; + } + return
+ { + playlistQueue(this.props.playlist.id); + }}> + {startDrawingLabel} + +
+ } } renderDeleteButton(){ @@ -132,14 +153,6 @@ class SinglePlaylist extends Component{ // TODO add "enter to confirm" event to save the values of the fields in the elements options modals and also the name change render(){ - if (this.props.mandatoryRefresh){ - this.setState({...this.state, elements: this.addControlCard(this.props.playlist.elements)}); - this.props.resetMandatoryRefresh() - } - - if (this.props.playlistHasBeenDeleted){ - this.props.handleTabBack(); - } return
@@ -171,11 +184,17 @@ class SinglePlaylist extends Component{ {this.props.playlist.name}
- {this.renderStartButtons()} - {this.renderElements()} - {this.renderDeleteButton()} + + + {this.renderStartButtons()} + + + {this.renderDeleteButton()} + + + {this.renderElements()}
} } diff --git a/frontend/src/structure/tabs/queue/ETA.js b/frontend/src/structure/tabs/queue/ETA.js new file mode 100644 index 00000000..7f82473c --- /dev/null +++ b/frontend/src/structure/tabs/queue/ETA.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; + + +// didn't found an efficent way of adding this as a class component +function Counter(props) { + const [counter, setCounter] = React.useState(props.eta); + + React.useEffect(() => { + const timer = + counter > 0 && setInterval(() => setCounter(counter - 1), 1000); + return () => clearInterval(timer); + }, [counter]); + + if (counter < 30) return

Almost done...

+ else if (counter < 3600) return

ETA {Math.floor(counter/60) + "m " + Math.floor(counter%60) + "s"}

+ else return

ETA {Math.floor(counter/3600) + "h " + Math.floor((counter%3600)/60) + "m"}

+ } + +class ETA extends Component{ + + printETA(){ + return + } + + renderEta(){ + if (this.props.isPaused) + return

Drawing paused

+ else if (this.props.progress.eta === -1) + return + Set a feedrate with a "G0 Fxxx" command to get the ETA in s + } + delay={{ show: 3000, hide:250 }}> +

ETA unavailable

+
; + else if (this.props.progress.units === "s") // if is using seconds and they are below 30 can show the "Almost done" message + return
+ {this.printETA()} +
+ // else the eta is in %. Will also show a tip to define a feedrate to enable eta in [s] + else return + Set a feedrate with a "G0 Fxxx" command to get the ETA in s + } + delay={{ show: 3000, hide:250 }}> +

{this.props.progress.eta}%

+
; + } + + render(){ + return
{this.renderEta()}
+ } +} + +export default ETA; \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/IntervalControl.js b/frontend/src/structure/tabs/queue/IntervalControl.js new file mode 100644 index 00000000..d7ee5a69 --- /dev/null +++ b/frontend/src/structure/tabs/queue/IntervalControl.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { Col, Form, OverlayTrigger, Row, Tooltip } from "react-bootstrap"; +import { queueSetInterval } from '../../../sockets/sEmits'; +import { getIntervalValue, getIsQueuePaused } from './selector'; + +const mapStateToProps = state => { + return { + intervalValue: getIntervalValue(state), + isPause: getIsQueuePaused(state) + } +} + +class IntervalControl extends Component{ + state = { + intervalValue: 0, + isChanging: false + } + + saveInterval(){ + queueSetInterval(this.state.intervalValue); + } + + componentDidMount(){ + if (this.props.intervalValue !== this.state.intervalValue && !this.state.isChanging){ + this.setState({...this.state, intervalValue: this.props.intervalValue}); + } + } + + render(){ + let tip = "Select 0 to run the drawings continuously, otherwise select the time interval that should be used between different drawings"; + if (this.props.isPause) + tip = "It is not possible to change the time interval while the current element is paused"; + return + {tip} + } + delay={{ show: 3000, hide: 250 }} + placement="bottom"> +
+
+ + Interval between drawings + +
+ Current value: {this.state.intervalValue}h +
+ + 0h + + { + this.setState({...this.state, intervalValue: evt.target.value, isChanging: true}); + }} + onMouseUp={(evt) => { + this.setState({...this.state, intervalValue: evt.target.value, isChanging: false}, + this.saveInterval.bind(this)); + }}/> + + 24h + +
+
+
+ } +} + +export default connect(mapStateToProps)(IntervalControl); \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/Queue.js b/frontend/src/structure/tabs/queue/Queue.js index e235de47..05b24de6 100644 --- a/frontend/src/structure/tabs/queue/Queue.js +++ b/frontend/src/structure/tabs/queue/Queue.js @@ -1,36 +1,40 @@ import React, { Component } from 'react'; import { Button, Col, Container, Row } from 'react-bootstrap'; -import { Stop, Trash } from 'react-bootstrap-icons'; import { connect } from 'react-redux'; import { Section, Subsection } from '../../../components/Section'; import SortableElements from '../../../components/SortableElements'; import { queueStatus } from '../../../sockets/sCallbacks'; -import { queueGetStatus, queueSetOrder, queueStopCurrent } from '../../../sockets/sEmits'; +import { queueGetStatus, queueSetOrder } from '../../../sockets/sEmits'; + import { listsAreEqual } from '../../../utils/dictUtils'; import { getElementClass } from '../playlists/SinglePlaylist/Elements'; -import { isViewQueue } from '../selector'; +import ETA from './ETA'; -import { setTab, tabBack } from '../Tabs.slice'; +import { getIsQueuePaused, getQueueCurrent, getQueueElements, getQueueEmpty, getQueueProgress } from './selector'; +import { isViewQueue } from '../selector'; import { setQueueElements, setQueueStatus } from './Queue.slice'; -import { getQueueCurrent, getQueueElements, getQueueEmpty } from './selector'; +import { setTab, tabBack } from '../Tabs.slice'; +import IntervalControl from './IntervalControl'; const mapStateToProps = (state) => { return { - elements: getQueueElements(state), + elements: getQueueElements(state), currentElement: getQueueCurrent(state), - isQueueEmpty: getQueueEmpty(state), - isViewQueue: isViewQueue(state) + isQueueEmpty: getQueueEmpty(state), + isViewQueue: isViewQueue(state), + progress: getQueueProgress(state), + isPaused: getIsQueuePaused(state) } } const mapDispatchToProps = (dispatch) => { return { - setQueueStatus: (val) => dispatch(setQueueStatus(val)), - handleTabBack: () => dispatch(tabBack()), - setQueueElements: (list) => dispatch(setQueueElements(list)), - setTabHome: () => dispatch(setTab('home')) + setQueueStatus: (val) => dispatch(setQueueStatus(val)), + handleTabBack: () => dispatch(tabBack()), + setQueueElements: (list) => dispatch(setQueueElements(list)), + setTabHome: () => dispatch(setTab('home')) } } @@ -46,9 +50,6 @@ class Queue extends Component{ if (!listsAreEqual(this.state.elements, this.props.elements)){ this.setState({...this.state, elements: this.props.elements, refreshList: true}); } - if (this.props.isQueueEmpty && this.props.isViewQueue){ - this.props.handleTabBack(); - } } componentDidMount(){ @@ -63,6 +64,8 @@ class Queue extends Component{ } handleSortableUpdate(list){ + console.log("Elements: "); + console.log(list); if (!listsAreEqual(list, this.state.elements)){ this.setState({...this.state, elements: list}); this.props.setQueueElements(list); @@ -70,34 +73,22 @@ class Queue extends Component{ } } - clearQueue(){ - // save an empty list - this.handleSortableUpdate([]); - } - - stopDrawing(){ - queueStopCurrent(); - } - renderList(){ if (this.state.elements !== undefined) if (this.state.elements.length > 0){ - return - - - + return + + + } return ""; } render(){ - if (this.props.isQueueEmpty){ + if (this.props.isQueueEmpty && this.props.currentElement === undefined){ return
Nothing is being drawn at the moment @@ -109,14 +100,22 @@ class Queue extends Component{ }else{ let ElementType = getElementClass(this.props.currentElement); return -
- - - +
+ + +
+ +
+ + +
+ + + + + +
diff --git a/frontend/src/structure/tabs/queue/Queue.scss b/frontend/src/structure/tabs/queue/Queue.scss index 629b03d4..e26b3d8d 100644 --- a/frontend/src/structure/tabs/queue/Queue.scss +++ b/frontend/src/structure/tabs/queue/Queue.scss @@ -3,13 +3,14 @@ .preview-bar-container{ //animation: pulse-queue-preview 1s infinite; //animation-direction: alternate; - background-color: $dark; + background-color: $black; } .preview-bar-image{ width:40px; max-width: 40px; position: relative; + border: 1px solid $primary; } @keyframes pulse-queue-preview { @@ -19,4 +20,4 @@ 100% { opacity: 100%; } -} +} \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/Queue.slice.js b/frontend/src/structure/tabs/queue/Queue.slice.js index a4780ce1..74fb860f 100644 --- a/frontend/src/structure/tabs/queue/Queue.slice.js +++ b/frontend/src/structure/tabs/queue/Queue.slice.js @@ -3,10 +3,12 @@ import { createSlice } from '@reduxjs/toolkit'; const queueSlice = createSlice({ name: "queue", initialState: { - isQueueEmpty: true, elements: [], currentElement: undefined, - intervalValue: 200 + repeat: false, + shuffle: false, + interval: 0, + status: {eta: -1} }, reducers: { setQueueElements(state, action){ @@ -18,18 +20,25 @@ const queueSlice = createSlice({ setQueueStatus(state, action){ let res = action.payload; res.current_element = res.current_element === "None" ? undefined : JSON.parse(res.current_element); - let queueEmpty = res.current_element === undefined; return { - isQueueEmpty: queueEmpty, - elements: res.elements, + elements: res.elements, currentElement: res.current_element, - intervalValue: res.intervalValue + interval: res.interval, + status: res.status, + repeat: res.repeat, + shuffle: res.shuffle } }, - setQueueNotEmpty(state, action){ + toggleQueueShuffle(state, action){ return { ...state, - isQueueEmpty: false + shuffle: !state.shuffle + } + }, + toggleQueueRepeat(state, action){ + return { + ...state, + repeat: !state.repeat } } } @@ -38,7 +47,8 @@ const queueSlice = createSlice({ export const{ setQueueElements, setQueueStatus, - setQueueNotEmpty + toggleQueueShuffle, + toggleQueueRepeat } = queueSlice.actions; export default queueSlice.reducer; \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/QueueControls.js b/frontend/src/structure/tabs/queue/QueueControls.js new file mode 100644 index 00000000..83504c26 --- /dev/null +++ b/frontend/src/structure/tabs/queue/QueueControls.js @@ -0,0 +1,118 @@ +import './Queue.scss'; + +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { ArrowRepeat, Eye, PauseFill, PlayFill, Shuffle, SkipForwardFill, StopFill } from 'react-bootstrap-icons'; + +import IconButton from '../../../components/IconButton'; + +import { getIsQueuePaused, getQueueElements, getQueueEmpty, getQueueIsRunning, getQueueRepeat, getQueueShuffle } from './selector'; +import { setTab } from '../Tabs.slice'; +import { isViewQueue } from '../selector'; +import { toggleQueueRepeat, toggleQueueShuffle } from './Queue.slice'; +import { drawingPause, drawingResume, queueNextDrawing, queueSetRepeat, queueSetShuffle, queueStartRandom, queueStopAll } from '../../../sockets/sEmits'; + +const mapStateToProps = (state) => { + return { + elements: getQueueElements(state), + isQueueEmpty: getQueueEmpty(state), + isPause: getIsQueuePaused(state), + isViewQueue: isViewQueue(state), + isRepeat: getQueueRepeat(state), + isShuffle: getQueueShuffle(state), + isRunning: getQueueIsRunning(state) + } +} + +const mapDispatchToProps = (dispatch) => { + return { + handleTab: (name) => dispatch(setTab(name)), + toggleShuffle: () => dispatch(toggleQueueShuffle()), + toggleRepeat: () => dispatch(toggleQueueRepeat()) + } +} + +class QueueControls extends Component{ + renderPausePlay(){ + if (this.props.isPause) + return { + drawingResume(); + }} + iconMedium = "true" + tip = "Resume current drawing" + icon={PlayFill}> + + else return { + drawingPause(); + }} + iconMedium = "true" + tip = "Pause current drawing (will require some seconds)" + icon={PauseFill}> + + } + + render(){ + if (this.props.isRunning){ + return
+ + {this.props.handleTab("queue")}} + iconMedium = "true" + tip = "Click to view the queue" + icon={Eye}> + + { + queueSetRepeat(!this.props.isRepeat); + this.props.toggleRepeat(); + }} + iconMedium = "true" + tip = "Click to enable the repetition of the elements in the queue" + icon={ArrowRepeat}> + + { + queueSetShuffle(!this.props.isShuffle); + this.props.toggleShuffle(); + }} + iconMedium = "true" + tip = "Click to enable shuffle mode for the queue" + icon={Shuffle}> + + {this.renderPausePlay()} + { + queueNextDrawing(); + }} + iconMedium = "true" + tip = "Click to stop the current drawing and start the next in the queue" + icon={SkipForwardFill}> + + { + queueStopAll(); + }} + iconMedium = "true" + tip = "Click to clear the queue and stop the current drawing" + icon={StopFill}> + +
+ }else{ + // TODO add a check to show this only if there is at least one uploaded drawing + return
+ { + queueStartRandom(); + }} + icon={Shuffle} + tip = "Will choose a random drawing to play among the ones uploaded. If the repeat button is selected will select a new one after the first is finished"> + Start a random drawing + +
+ } + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(QueueControls); \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/QueuePreview.js b/frontend/src/structure/tabs/queue/QueuePreview.js deleted file mode 100644 index a12ced11..00000000 --- a/frontend/src/structure/tabs/queue/QueuePreview.js +++ /dev/null @@ -1,33 +0,0 @@ -import './Queue.scss'; - -import { Component } from 'react'; -import { connect } from 'react-redux'; - -import { getQueueCurrent, getQueueElements, getQueueEmpty } from './selector'; -import { getElementClass } from '../playlists/SinglePlaylist/Elements'; - -const mapStateToProps = (state) => { - return { - elements: getQueueElements(state), - currentElement: getQueueCurrent(state), - isQueueEmpty: getQueueEmpty(state) - } -} - -class QueuePreview extends Component{ - render(){ - if (this.props.currentElement !== undefined){ - let ElementType = getElementClass(this.props.currentElement); - return
-
Now drawing:
-
- -
-
- }else{ - return "" - } - } -} - -export default connect(mapStateToProps)(QueuePreview); \ No newline at end of file diff --git a/frontend/src/structure/tabs/queue/selector.js b/frontend/src/structure/tabs/queue/selector.js index ea8138c5..02d637c2 100644 --- a/frontend/src/structure/tabs/queue/selector.js +++ b/frontend/src/structure/tabs/queue/selector.js @@ -1,10 +1,39 @@ -const getQueueEmpty = state => {return state.queue.isQueueEmpty}; +//returns true if the queue is empty +const getQueueEmpty = state => {return state.queue.elements.length === 0}; -const getQueueElements = state => {return state.queue.elements}; +// returns the list of elements in the queue +const getQueueElements = state => {return state.queue.elements}; -const getQueueCurrent = state => {return state.queue.currentElement}; +// returns the currently used element +const getQueueCurrent = state => {return state.queue.currentElement}; -const getQueueIntervalValue = state => {return state.queue.intervalValue}; +// returns the progress {eta, units} +const getQueueProgress = state => {return state.queue.status.progress}; -export {getQueueEmpty, getQueueElements, getQueueCurrent, getQueueIntervalValue}; \ No newline at end of file +// returns true if the feeder is paused +const getIsQueuePaused = state => {return state.queue.status.is_paused}; + +// returns true if the repeat mode is currently selected +const getQueueRepeat = state => {return state.queue.repeat} + +// returns true if the shuffle mode is currently selected +const getQueueShuffle = state => {return state.queue.shuffle} + +// returns true if the server is running, false, if is on hold +const getQueueIsRunning = state => {return state.queue.status.is_running} + +// returns the current interval value for the queue +const getIntervalValue = state => {return state.queue.interval} + +export { + getQueueEmpty, + getQueueElements, + getQueueCurrent, + getQueueProgress, + getIsQueuePaused, + getQueueRepeat, + getQueueShuffle, + getQueueIsRunning, + getIntervalValue +}; \ No newline at end of file diff --git a/frontend/src/structure/tabs/settings/defaultSettings.js b/frontend/src/structure/tabs/settings/defaultSettings.js index 83aaf893..87336828 100644 --- a/frontend/src/structure/tabs/settings/defaultSettings.js +++ b/frontend/src/structure/tabs/settings/defaultSettings.js @@ -133,18 +133,11 @@ const defaultSettings = { label: "Start drawing on power up", tip: "Enable this option to start drawing automatically from the full list of drawings every time the device is turned on" }, - shuffle: { - name: "autostart.shuffle", - type: "check", - value: true, - label: "Shuffle drawings on autostart", - tip: "When the autostart is enabled, will play the drawings shuffled" - }, interval: { name: "autostart.interval", type: "input", value: 0, - label: "Interval between drawings [s]", + label: "Interval between drawings [h]", tip: "Write the number of seconds to let the table pause between drawings" } }, diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 8b74e0fc..fb1c0d3d 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -22,7 +22,7 @@ function checkArray(arr){ // get the url of given images function getImgUrl(id){ if (id !== undefined) - return domain + "/Drawings/" + id; + return domain + "/Drawings/" + id + "?v=" + process.env.REACT_APP_VERSION; // adding version to automatically reload the images when a new version of the sofware is installed else return ""; } diff --git a/migrations/versions/05007b246e56_adding_drawing_path_lenght_for_eta.py b/migrations/versions/05007b246e56_adding_drawing_path_lenght_for_eta.py new file mode 100644 index 00000000..be6865f5 --- /dev/null +++ b/migrations/versions/05007b246e56_adding_drawing_path_lenght_for_eta.py @@ -0,0 +1,58 @@ +"""Adding drawing path lenght for ETA + +Revision ID: 05007b246e56 +Revises: 2f4ab5178ba3 +Create Date: 2021-04-06 15:30:50.963976 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '05007b246e56' +down_revision = '2f4ab5178ba3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('ids_sequences', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id_name', sa.String(length=20), nullable=False), + sa.Column('last_value', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id_name') + ) + + with op.batch_alter_table('uploaded_files', schema=None) as batch_op: + batch_op.add_column(sa.Column('dimensions_info', sa.String(length=150), nullable=True)) + batch_op.add_column(sa.Column('path_length', sa.Float(), nullable=True)) + + # ### end Alembic commands ### + + # setting up a sequence in the id column + batch_op.add_column(sa.Column('tmp_id', sa.Integer(), sa.Sequence("uploaded_id"))) + sa.PrimaryKeyConstraint("tmp_id") + + op.execute("UPDATE uploaded_files SET tmp_id=id") + + with op.batch_alter_table('uploaded_files', schema=None) as batch_op: + batch_op.drop_column("id") + # FIXME don't understand why this is not working: should create always a new id, instead is reusing the highest possible non used id (even if it was already used and then deleted). For the moment can create a new table with the last id and increment that every time a new drawing is uploaded (not a real solution, more like a workaround, I don't like it) + # This should fix issue #40 which is due to the cached image being loaded (because it is reusing an old id) + batch_op.add_column(sa.Column('id', sa.Integer(), sa.Sequence("uploaded_id"), autoincrement=True, primary_key=True)) + + op.execute("UPDATE uploaded_files SET id=tmp_id") + with op.batch_alter_table('uploaded_files', schema=None) as batch_op: + batch_op.drop_column("tmp_id") + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ids_sequences') + with op.batch_alter_table('uploaded_files', schema=None) as batch_op: + batch_op.drop_column('path_length') + batch_op.drop_column('dimensions_info') + + # ### end Alembic commands ### diff --git a/readme.md b/readme.md index 942813d3..9af72b5c 100755 --- a/readme.md +++ b/readme.md @@ -20,16 +20,16 @@ The project is really immature and barely usable at the moment, see it as a prev The project is opensource under MIT license and thus anyone can help (there is so much to do!). -# Some screenshots +### Some screenshots ![Main page](docs/images/preview.png) ![Playlist](docs/images/playlist.png) ![Drawing](docs/images/drawing.png) ![Manual](docs/images/control.png) -# Installation +## Installation -## Windows +### Windows Install python 3.7 or above together with pip, npm and git and restart your computer to make the commands available system wide. @@ -55,7 +55,7 @@ Now you can install SandyPi (will take a while): (env)$> install.bat ``` -## Raspbian OS (Buster and above) +### Raspbian OS (Buster and above) Make sure on your system git, npm, pip and virtualenv are already available: @@ -91,7 +91,7 @@ Now you can install SandyPi (will take a while): This step may be long (even over 1h). -## Running the server +### Running the server To run the server use: `$> python start.py` @@ -105,27 +105,27 @@ It is possible to set the server to start automatically when the device turns on To stop the server starting automatically use `$> python start.py -a=off` - -## Web interface +### Web interface Once the service is running it is possible to connect through a browser by typing the device ip address and connecting to the port 5000 like `192.168.1.15:5000` If you are running on the local device you can also use `127.0.0.1:5000` Follow the [guide](/docs/first_setup.md) for your first setup or for [more info about the usage](/docs/usage.md) -## Autodetect drawings +### Autodetect drawings The software will automatically load (`.gcode`) files positioned in the `server/autodetect` folder. The file will be automatically deleted from the folder once loaded. -# Installation troubleshooting +## Installation troubleshooting If you find problems during the installation check the [troubleshooting](/docs/troubleshooting.md) page **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 +## Boards and firmwares + At the moment, the software 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. @@ -133,14 +133,15 @@ The software has been built succesfully on Windows and Raspbian OS (running on R Raspberry Pi Zero W can be used but it is necessary to follow [this guide](/docs/pizero_installation.md) -## Marlin 2.0 setup -In the settings select the serial port, the correct baudrate (usually 115200 or 250000) and the correct firmware type. +### Marlin 2.0 setup -## Grbl 1.1 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 +## Updates The software will prompt weekly if a new tag version has been added. The tagged version should be more or less stable. @@ -168,19 +169,18 @@ _____ *NOTE:* the software is still in **ALPHA** which means lots of features may not work as expected. Updates may fix some but may also introduce more bugs. If you find any please open an issue. One the fundaments of the software are ready a stable branch will be released with more stable updates. ____ - -# Development and testing +## Development and testing Any help in the app development is accepted. Also testing the software counts! If you find any bug or you have any idea just check if an issue is already open for that topic or open it yourself. For the coding, debugging and so on check the [development section](/docs/development.md). In this case, during the installation it is necessary to run `(env) $> install.bat develop`. - -# Current status +## Current status The project is really primitive and need a lot of work. Here is a brief list of what the software is capable of and what will be implemented for sure in the future: + * [x] Web interface to be accessible from different devices over the network * [x] Connection to the hardware controller through serial * [x] Simple installation script to simplify the installation @@ -191,19 +191,21 @@ Here is a brief list of what the software is capable of and what will be impleme * [x] Run gcode commands manually * [x] Feed the table periodically * [x] Shuffle mode to play shuffled drawings continuosly +* [x] Show the realtime gcode simulation with time estimate (ETA) * [ ] Simple lights/led control * [ ] Update the software with a single button * [ ] Create logo * [ ] Run the server not on a production server -* [ ] Show the realtime gcode simulation with time estimate * [ ] Advanced lights controls: syncronization between lights and ball * [ ] Sandify "integration" (like upload a drawing directly from sandify or modify an uploaded drawing)? -* [ ] Add translations? +* [ ] Add translations for different languages? * [ ] Possibility to control multiple tables? * [ ] A lot more stuff... Just ask to know what you can help with or have a look at the [todo file](todos.md) for more detailed goals In a far far away future: + * [ ] Create a social network to share designs and update your personal playlists ## Versions + Check the latest version infos [here](docs/versions.md) diff --git a/requirements.txt b/requirements.txt index be5b35ad..9446c4b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ Flask-Migrate==2.5.3 Flask-SocketIO==4.3.0 Flask-SQLAlchemy==2.4.4 future==0.18.2 +importlib-metadata==4.5.0 iniconfig==1.1.1 isort==5.7.0 itsdangerous==1.1.0 @@ -22,10 +23,11 @@ MarkupSafe==1.1.1 mccabe==0.6.1 netifaces==0.10.9 packaging==20.8 -Pillow==8.1.2 -pip==21.0.1 +Pillow==8.2.0 +pip==21.1.2 pluggy==0.13.1 py==1.10.0 +py-expression-eval==0.3.13 pylint==2.6.0 pyparsing==2.4.7 pyserial==3.5 @@ -39,7 +41,10 @@ setuptools==51.1.1 six==1.15.0 SQLAlchemy==1.3.22 toml==0.10.2 +typed-ast==1.4.3 +typing-extensions==3.10.0.0 watchdog==2.0.2 Werkzeug==1.0.1 wheel==0.34.2 wrapt==1.12.1 +zipp==3.4.1 diff --git a/server/__init__.py b/server/__init__.py index 361f0b34..573d4b52 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -16,7 +16,7 @@ from threading import Thread from server.utils import settings_utils, software_updates, migrations -from server.utils.logging_utils import server_file_handler, server_stream_handler +from server.utils.logging_utils import server_stream_handler, server_file_handler # Updating setting files (will apply changes only when a new SW version is installed) settings_utils.update_settings_file_version() @@ -39,8 +39,8 @@ w_logger.setLevel(1) w_logger.handlers = [] -w_logger.addHandler(server_file_handler) w_logger.addHandler(server_stream_handler) +w_logger.addHandler(server_file_handler) w_logger.propagate = False @@ -49,8 +49,8 @@ app = Flask(__name__, template_folder='templates', static_folder="../frontend/build", static_url_path="/") app.logger.setLevel(1) -app.logger.addHandler(server_file_handler) -app.logger.addHandler(server_stream_handler) +w_logger.addHandler(server_stream_handler) +w_logger.addHandler(server_file_handler) app.config['SECRET_KEY'] = 'secret!' # TODO put a key here app.config['UPLOAD_FOLDER'] = "./server/static/Drawings" diff --git a/server/database/generic_playlist_element.py b/server/database/generic_playlist_element.py new file mode 100644 index 00000000..38c83cd6 --- /dev/null +++ b/server/database/generic_playlist_element.py @@ -0,0 +1,130 @@ +import json + +from server.database.models import db +from server.database.playlist_elements_tables import PlaylistElements + +UNKNOWN_PROGRESS = { + "eta": -1, # default is -1 -> ETA unknown + "units": "s" # ETA units +} + +""" + Base class for a playlist element + When creating a new element type, should extend this base class + The base class manages automatically to save the element correctly in the database if necessary + + Can override the init method but must pass **kwargs to the super().__init__ method + execute method: + The child element MUST implement the execute method as a generator in order to provide the commands to the feeder (can also iterate only once, but the method must be extended) + The execute method should yield the Gcode command to execute. If None is returned instead, the feeder will skip the iteration. + The feeder will stop only one the StopIteration exception is raised + before_start method: + by default returns the same element. + If the element is a placeholder to calculate some other type of element can return the correct element + get_progress method: + Must return a dict with the following format: + eta: float ETA value for the current element (-1 means "unknown") + units: can be "s" or "%" + + See examples to understand better + + NOTE: variable starting with "_" will not be saved in the database + NOTE: must implement the element also in the frontend (follow the instructions at the beginning of the "Elements.js" file) +""" +class GenericPlaylistElement(): + element_type = None + + # --- base class methods that must be implemented/overwritten in the child class --- + + def __init__(self, element_type, **kwargs): + self.element_type = element_type + self._pop_options = [] # list of fields that are column in the database and must be removed from the standard options (string column) + self.add_column_field("element_type") # need to pop the element_type from the final dict because this option is a column of the table + for v in kwargs: + setattr(self, v, kwargs[v]) + + # if this method return None if will not run the element in the playlist + # can override and return another element if necessary + def before_start(self, queue_manager): + return self + + # this methods yields a gcode command line to be executed + # the element is considered finished after the last line is yield + # if a None value is yield, the feeder will skip to the next iteration + def execute(self, logger): + raise StopIteration("You must implement an iterator in every element class") + + # returns a dict with ETA + # some type of elements may require a feedrate -> the function should always require a feedrate and should handle the case of a 0 feedrate + # dict format: + # * eta: float value for the eta. Can be -1 if unknown + # * units: eta units ("s" or "%") + # if an eta is not available in the child class under some conditions it is possible to use: return super().get_progress(feedrate) + def get_progress(self, feedrate): + return UNKNOWN_PROGRESS + + # --- base class methods - should not be necessary to overwrite these --- + + def _set_from_dict(self, values): + for k in values: + if hasattr("set_{}".format(k)): + pass + elif hasattr(k): + setattr(self, k, values[k]) + else: + raise ValueError + + def get_dict(self): + return GenericPlaylistElement.clean_dict(self.__dict__) + + def __str__(self): + return json.dumps(self.get_dict()) + + # add options that must be saved in a dedicated column insted of saving them inside the generic options of the element (like the element_type) + def add_column_field(self, option): + self._pop_options.append(option) + + def save(self, element_table): + options = self.get_dict() + # filter other pop options + kwargs = [] + for op in self._pop_options: + kwargs.append(options.pop(op)) + kwargs = zip(self._pop_options, kwargs) + kwargs = dict(kwargs) + options = json.dumps(options) + db.session.add(element_table(element_options = options, **kwargs)) + + @classmethod + def clean_dict(cls, val): + return {key:value for key, value in val.items() if not key.startswith('_') and not callable(key)} + + @classmethod + def create_element_from_dict(cls, dict_val): + if not type(dict_val) is dict: + raise ValueError("The argument must be a dict") + if 'element_type' in dict_val: + el_type = dict_val.pop("element_type") # remove element type. Should be already be choosen when using the class + else: + raise ValueError("the dictionary must contain an 'element_type'") + + from server.database.playlist_elements import _child_types # need to import here to avoid circular import + for elementClass in _child_types: + if elementClass.element_type == el_type: + return elementClass(**dict_val) + raise ValueError("'element_type' doesn't match any known element type") + + @classmethod + def create_element_from_json(cls, json_str): + dict_val = json.loads(json_str) + return cls.create_element_from_dict(dict_val) + + @classmethod + def create_element_from_db(cls, item): + if not isinstance(item, PlaylistElements): + raise ValueError("Need a db item from a playlist elements table") + + res = GenericPlaylistElement.clean_dict(item.__dict__) + tmp = res.pop("element_options") + res = {**res, **json.loads(tmp)} + return cls.create_element_from_dict(res) diff --git a/server/database/models.py b/server/database/models.py index bed973ae..1069f1d9 100644 --- a/server/database/models.py +++ b/server/database/models.py @@ -5,18 +5,49 @@ from server import db +# Incremental ids table +# Keep track of the highest id value for the other tables if it is necessary to have a monotonic id +class IdsSequences(db.Model): + id = db.Column(db.Integer, primary_key=True, nullable=False) + id_name = db.Column(db.String(20), unique=True, nullable=False) + last_value = db.Column(db.Integer, nullable=False) + + # return the incremented id and save the last value in the table + @classmethod + def get_incremented_id(cls, table): + ret_value = 1 + res = db.session.query(IdsSequences).filter(IdsSequences.id_name==table.__table__.name).first() + # check if a row for the table has already been created + if res is None: + # get highest id in the table + res = db.session.query(table).order_by(table.id.desc()).first() + # if table is empty start from 1 otherwise use max(id) + 1 + if not res is None: + ret_value = res.id + 1 + db.session.add(IdsSequences(id_name = table.__table__.name, last_value = ret_value)) + db.session.commit() + else: + res.last_value += 1 + db.session.commit() + ret_value = res.last_value + return ret_value # Gcode files table # Stores information about the single drawing class UploadedFiles(db.Model): - id = db.Column(db.Integer, primary_key=True) # drawing code + id = db.Column(db.Integer, db.Sequence("uploaded_id"), primary_key=True, autoincrement=True) # drawing code (use "sequence" to avoid using the same id for new drawings (this will create problems with the cached data on the frontend, showing an old drawing instead of the freshly uploaded one)) filename = db.Column(db.String(80), unique=False, nullable=False) # gcode filename up_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) # Creation timestamp edit_date = db.Column(db.DateTime, default=datetime.utcnow) # last time the drawing was edited (to update: datetime.datetime.utcnow()) last_drawn_date = db.Column(db.DateTime) # last time the drawing was used by the table: to update: (datetime.datetime.utcnow()) + path_length = db.Column(db.Float) # total path lenght + dimensions_info = db.Column(db.String(150), unique=False) # additional dimensions information as json string object def __repr__(self): - return '' % self.filename + return '' % self.filename + + def save(self): + return db.session.commit() @classmethod def get_full_drawings_list(cls): @@ -25,8 +56,10 @@ def get_full_drawings_list(cls): @classmethod def get_random_drawing(cls): return db.session.query(UploadedFiles).order_by(func.random()).first() - #return db.session.query(UploadedFiles).options(load_only('id')).offset(func.floor(func.random()*db.session.query(func.count(UploadedFiles.id)))).limit(1).all() + @classmethod + def get_drawing(cls, id): + return db.session.query(UploadedFiles).filter(UploadedFiles.id==id).first() # move these imports here to avoid circular import in the GenericPlaylistElement from server.database.playlist_elements_tables import create_playlist_table, delete_playlist_table, get_playlist_table_class @@ -35,11 +68,11 @@ def get_random_drawing(cls): # Playlist table # Keep track of all the playlists class Playlists(db.Model): - id = db.Column(db.Integer, primary_key=True) # id of the playlist + id = db.Column(db.Integer, primary_key=True) # id of the playlist name = db.Column(db.String(80), unique=False, nullable=False, default="New playlist") - creation_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) # Creation timestamp - edit_date = db.Column(db.DateTime, default=datetime.utcnow) # Last time the playlist was edited (to update: datetime.datetime.utcnow()) - version = db.Column(db.Integer, default=0) # Incremental version number: +1 every time the playlist is saved + creation_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) # Creation timestamp + edit_date = db.Column(db.DateTime, default=datetime.utcnow) # Last time the playlist was edited (to update: datetime.datetime.utcnow()) + version = db.Column(db.Integer, default=0) # Incremental version number: +1 every time the playlist is saved def save(self): self.edit_date = datetime.utcnow() diff --git a/server/database/playlist_elements.py b/server/database/playlist_elements.py index c6132396..28d66ef6 100644 --- a/server/database/playlist_elements.py +++ b/server/database/playlist_elements.py @@ -1,114 +1,32 @@ -import json import os +import re +import json from pathlib import Path - -from sqlalchemy.orm import load_only -from server.database.models import UploadedFiles +from dotmap import DotMap from time import time, sleep from datetime import datetime, timedelta +from math import sqrt -from server.database.playlist_elements_tables import PlaylistElements, get_playlist_table_class -from server import db +from server.database.models import UploadedFiles +from server.database.playlist_elements_tables import get_playlist_table_class +from server.database.generic_playlist_element import GenericPlaylistElement from server.utils.settings_utils import LINE_RECEIVED +from server.utils.gcode_converter import ImageFactory +from server.utils.settings_utils import load_settings, get_only_values -""" - Base class for a playlist element - When creating a new element type, should extend this base class - The base class manages automatically to save the element correctly in the database if necessary - - Can override the init method but must pass **kwargs to the super().__init__ method - execute method: - The child element MUST implement the execute method as a generator in order to provide the commands to the feeder (can also iterate only once, but the method must be extended) - The execute method should yield the Gcode command to execute. If None is returned instead, the feeder will skip the iteration. - The feeder will stop only one the StopIteration exception is raised - - See examples to understand better +""" + --------------------------------------------------------------------------- - NOTE: must implement the element also in the frontend (follow the instructions at the beginning of the "Elements.js" file) -""" -class GenericPlaylistElement(): - element_type = None - - def __init__(self, element_type, **kwargs): - self.element_type = element_type - self._pop_options = [] # list of fields that are column in the database and must be removed from the standard options (string column) - self.add_column_field("element_type") # need to pop the element_type from the final dict because this option is a column of the table - for v in kwargs: - setattr(self, v, kwargs[v]) - - def get_dict(self): - return GenericPlaylistElement.clean_dict(self.__dict__) + The elements must derive from the GenericPlaylistElement class. + Check "generic_playlist_element.py" for more detailed instructions. + New elements must be added to the _child_types list at the end of this file - def __str__(self): - return json.dumps(self.get_dict()) - - def execute(self, logger): - raise StopIteration("You must implement an iterator in every element class") - - def _set_from_dict(self, values): - for k in values: - if hasattr("set_{}".format(k)): - pass - elif hasattr(k): - setattr(self, k, values[k]) - else: - raise ValueError - - # if this method return None if will not run the element in the playlist - # can override if should not run the current element but something else - def before_start(self, queue_manager): - return self - - # add options that must be saved in a dedicated column insted of saving them inside the generic options of the element (like the element_type) - def add_column_field(self, option): - self._pop_options.append(option) - - def save(self, element_table): - options = self.get_dict() - # filter other pop options - kwargs = [] - for op in self._pop_options: - kwargs.append(options.pop(op)) - kwargs = zip(self._pop_options, kwargs) - kwargs = dict(kwargs) - options = json.dumps(options) - db.session.add(element_table(element_options = options, **kwargs)) - - @classmethod - def clean_dict(cls, val): - return {key:value for key, value in val.items() if not key.startswith('_') and not callable(key)} - - @classmethod - def create_element_from_dict(cls, dict_val): - if not type(dict_val) is dict: - raise ValueError("The argument must be a dict") - if 'element_type' in dict_val: - el_type = dict_val.pop("element_type") # remove element type. Should be already be choosen when using the class - else: - raise ValueError("the dictionary must contain an 'element_type'") - for elementClass in _child_types: - if elementClass.element_type == el_type: - return elementClass(**dict_val) - raise ValueError("'element_type' doesn't match any known element type") - - @classmethod - def create_element_from_json(cls, json_str): - dict_val = json.loads(json_str) - return cls.create_element_from_dict(dict_val) - - @classmethod - def create_element_from_db(cls, item): - if not isinstance(item, PlaylistElements): - raise ValueError("Need a db item from a playlist elements table") - - res = GenericPlaylistElement.clean_dict(item.__dict__) - tmp = res.pop("element_options") - res = {**res, **json.loads(tmp)} - return cls.create_element_from_dict(res) + --------------------------------------------------------------------------- +""" """ - Identifies a drawing in the playlist + Identifies a drawing element """ class DrawingElement(GenericPlaylistElement): element_type = "drawing" @@ -120,19 +38,75 @@ def __init__(self, drawing_id=None, **kwargs): self.drawing_id = int(drawing_id) except: raise ValueError("The drawing id must be an integer") + self._distance = 0 + self._total_distance = 0 + self._new_position = DotMap({"x":0, "y":0}) + self._last_position = self._new_position + self._x_regex = re.compile("[X]([0-9.-]+)($|\s)") # looks for a +/- float number after an X, until the first space or the end of the line + self._y_regex = re.compile("[Y]([0-9.-]+)($|\s)") # looks for a +/- float number after an Y, until the first space or the end of the line + def execute(self, logger): + # generate filename filename = os.path.join(str(Path(__file__).parent.parent.absolute()), "static/Drawings/{0}/{0}.gcode".format(self.drawing_id)) + + # loads the total lenght of the drawing to calculate eta + drawing_infos = UploadedFiles.get_drawing(self.drawing_id) + self._total_distance = drawing_infos.path_length + if (self._total_distance is None) or (self._total_distance < 0): + self._total_distance = 0 + # if no path lenght is available try to calculate it and save it again (necessary for old versions compatibility, TODO remove this in future versions?) + # need to open the file an extra time to analyze it completely (cannot do it while executing the element) + try: + with open(filename) as f: + settings = load_settings() + factory = ImageFactory(get_only_values(settings["device"])) + dimensions, _ = factory.gcode_to_coords(f) # ignores the coordinates and use only the drawing dimensions + drawing_infos.path_length = dimensions["total_lenght"] + del dimensions["total_lenght"] + drawing_infos.dimensions_info = json.dumps(dimensions) + drawing_infos.save() + self._total_distance = drawing_infos.path_length + except Exception as e: + logger.exception(e) + with open(filename) as f: for line in f: + # clears the line if line.startswith(";"): # skips commented lines continue if ";" in line: # remove in line comments line.split(";") line = line[0] + # calculates the distance travelled + try: + if "X" in line: + self._new_position.x = float(self._x_regex.findall(line)[0][0]) + if "Y" in line: + self._new_position.y = float(self._y_regex.findall(line)[0][0]) + self._distance += sqrt((self._new_position.x - self._last_position.x)**2 + (self._new_position.y - self._last_position.y)**2) + self._last_position = self._new_position + except Exception as e: + logger.exception(e) + # yields the line yield line - + def get_progress(self, feedrate): + # if for some reason the total distance was not calculated the ETA is unknown + if self._total_distance == 0: + return super().get_progress(feedrate) + + # if a feedrate is available will use "s" otherwise will calculate the ETA as a percentage + if feedrate <= 0: + return { + "eta": self._distance/self._total_distance * 100, + "units": "%" + } + else: + return { + "eta": (self._total_distance - self._distance)/feedrate, + "units": "s" + } """ Identifies a command element (sends a specific command/list of commands to the board) """ @@ -164,36 +138,52 @@ def __init__(self, delay=None, expiry_date=None, alarm_time=None, type="", **kwa self.expiry_date = expiry_date if expiry_date != "" else None self.alarm_time = alarm_time if alarm_time != "" else None self.type = type + self._final_time = -1 def execute(self, logger): - final_time = time() - if self.type == "alarm_type": # compare the actual hh:mm:ss to the alarm to see if it must run today or tomorrow + self._final_time = time() + if self.type == "alarm_type": # compare the actual hh:mm:ss to the alarm to see if it must run today or tomorrow now = datetime.now() - midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) # get midnight and add the alarm time + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) # get midnight and add the alarm time alarm_time = datetime.strptime(self.alarm_time, "%H:%M:%S") alarm = midnight + timedelta(hours = alarm_time.hour, minutes = alarm_time.minute, seconds = alarm_time.second) if alarm == now: return elif alarm < now: - alarm += timedelta(hours=24) # if the alarm is expired for today adds 24h - final_time = datetime.timestamp(alarm) + alarm += timedelta(hours=24) # if the alarm is expired for today adds 24h + self._final_time = datetime.timestamp(alarm) if self.type == "expiry_date": - final_time = datetime.timestamp(datetime.strptime(self.expiry_date, "%Y-%m-%d %H:%M:%S.%f")) + self._final_time = datetime.timestamp(datetime.strptime(self.expiry_date, "%Y-%m-%d %H:%M:%S.%f")) elif self.type == "delay": - final_time += float(self.delay) # store current time and applies the delay - else: # should not be the case because the check is done already in the constructore + self._final_time += float(self.delay) # store current time and applies the delay + else: # should not be the case because the check is done already in the constructore return - + while True: - if time() >= final_time: # If the delay expires can break the while to start the next element + if time() >= self._final_time: # If the delay expires can break the while to start the next element break - elif time() < final_time-1: - logger.log(LINE_RECEIVED, "Waiting {:.1f} more seconds".format(final_time-time())) + elif time() < self._final_time-1: + logger.log(LINE_RECEIVED, "Waiting {:.1f} more seconds".format(self._final_time-time())) sleep(1) yield None else: - sleep(final_time-time()) + sleep(self._final_time-time()) yield None + + # updates the delay value + # used when in continuous mode + def update_delay(self, interval): + self._final_time += (float(interval - self.delay)) + self.delay = interval + + # return a progress only if the element is running + def get_progress(self, feedrate): + if self._final_time != -1: + return { + "eta": self._final_time - time(), + "units": "s" + } + else: return super().get_progress(feedrate) """ Plays an element in the playlist with a random order @@ -218,7 +208,7 @@ def before_start(self, app): return None """ - Start another playlist + Starts another playlist """ class StartPlaylistElement(GenericPlaylistElement): element_type = "start_playlist" @@ -233,6 +223,10 @@ def before_start(self, app): playlist_queue(self.playlist_id) return None + + +# TODO implement also the other element types (execute method but also the frontend options) + """ Controls the led lights """ @@ -244,8 +238,6 @@ def __init__(self, **kwargs): super().__init__(element_type=LightsControl.element_type, **kwargs) -# TODO implement also the other element types (execute method but also the frontend options) - """ Identifies a particular behaviour for the ball between drawings (like: move to the closest border, start from the center) (should put this as a drawing option?) """ diff --git a/server/hw_controller/continuous_queue_generator.py b/server/hw_controller/continuous_queue_generator.py index 56ac4e3f..02d6106d 100644 --- a/server/hw_controller/continuous_queue_generator.py +++ b/server/hw_controller/continuous_queue_generator.py @@ -3,13 +3,12 @@ from server.database.playlist_elements import DrawingElement, ShuffleElement, TimeElement class ContinuousQueueGenerator: - def __init__(self, shuffle=False, interval=300, playlist=0): + def __init__(self, shuffle=False, interval=0, playlist=0): self.shuffle = shuffle self.set_interval(interval) self.just_started = True self.playlist = playlist - # if should not shuffle starts from the first drawing on - # caches the list of drawings to avoid problems with newly created items + # if should not shuffle starts from the first drawing in the list if not self.shuffle: if self.playlist == 0: drawings = UploadedFiles.get_full_drawings_list() @@ -22,10 +21,13 @@ def __init__(self, shuffle=False, interval=300, playlist=0): self._uploaded_files_generator = self._create_uploaded_files_generator() - # if is running a playlist may be better to set the interval for each single playlist + # set the interval def set_interval(self, interval): self.interval = interval + def set_shuffle(self, shuffle): + self.shuffle = shuffle + # return an element to put as a delay between drawings def generate_timing_element(self): return TimeElement(delay=self.interval, type="delay") @@ -40,7 +42,7 @@ def generate_drawing_element(self): return next(self._uploaded_files_generator) # get next element from uploaded files # generate the next element(s) for the queue - def generate_next_elements(self): + def generate_next_elements(self, _depth=0): try: if self.just_started or self.interval == 0: self.just_started = False @@ -48,10 +50,13 @@ def generate_next_elements(self): else: return [self.generate_timing_element(), self.generate_drawing_element()] except StopIteration: - # when the list to run is empty returns None - return None + if _depth==0: # check the depth to get only one recursion + # when the list to run is empty restart + self._uploaded_files_generator = self._create_uploaded_files_generator() + return self.generate_next_elements(self, _depth=1) + else: return None + - # TODO should return all the elements for a playlist to fill the queue properly # TODO May change the queue behaviour in the next updates to show the playlist instead of single elements # private function to iterate over the list of cached drawings (only if is not shuffling) diff --git a/server/hw_controller/feeder.py b/server/hw_controller/feeder.py index 1168665c..00601200 100644 --- a/server/hw_controller/feeder.py +++ b/server/hw_controller/feeder.py @@ -6,19 +6,21 @@ from copy import deepcopy import re import logging -from logging.handlers import RotatingFileHandler from dotenv import load_dotenv from dotmap import DotMap +from py_expression_eval import Parser from server.utils import limited_size_dict, buffered_timeout, settings_utils -from server.utils.logging_utils import formatter +from server.utils.logging_utils import formatter, MultiprocessRotatingFileHandler from server.hw_controller.device_serial import DeviceSerial from server.hw_controller.gcode_rescalers import Fit import server.hw_controller.firmware_defaults as firmware +from server.database.playlist_elements import DrawingElement, TimeElement +from server.database.generic_playlist_element import UNKNOWN_PROGRESS """ -This class duty is to send commands to the hw. It can be a single command or an entire drawing. +This class duty is to send commands to the hw. It can handle single commands as well as elements. """ @@ -47,8 +49,9 @@ def on_device_ready(self): # List of commands that are buffered by the controller -BUFFERED_COMMANDS = ("G0", "G00", "G1", "G01", "G2", "G02", "G3", "G03", "G28") - +BUFFERED_COMMANDS = ("G0", "G00", "G1", "G01", "G2", "G02", "G3", "G03", "G28") +# Defines the character used to define macros +MACRO_CHAR = "&" class Feeder(): def __init__(self, handler = None, **kargvs): @@ -56,16 +59,16 @@ def __init__(self, handler = None, **kargvs): # logger setup self.logger = logging.getLogger(__name__) self.logger.handlers = [] # remove all handlers - self.logger.propagate = False # set it to files to avoid passing it to the parent logger + self.logger.propagate = False # set it to False to avoid passing it to the parent logger # add custom logging levels logging.addLevelName(settings_utils.LINE_SENT, "LINE_SENT") logging.addLevelName(settings_utils.LINE_RECEIVED, "LINE_RECEIVED") logging.addLevelName(settings_utils.LINE_SERVICE, "LINE_SERVICE") - self.logger.setLevel(settings_utils.LINE_SENT) # set to logger lowest level + self.logger.setLevel(settings_utils.LINE_SERVICE) # set to logger lowest level # create file logging handler - file_handler = RotatingFileHandler("server/logs/feeder.log", maxBytes=200000, backupCount=5) - file_handler.setLevel(settings_utils.LINE_SENT) + file_handler = MultiprocessRotatingFileHandler("server/logs/feeder.log", maxBytes=200000, backupCount=5) + file_handler.setLevel(settings_utils.LINE_SERVICE) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) @@ -88,11 +91,10 @@ def __init__(self, handler = None, **kargvs): # variables setup - self._isrunning = False + self._current_element = None + self._is_running = False self._stopped = False - self._ispaused = False - self.total_commands_number = None - self.command_number = 0 + self._is_paused = False self._th = None self.serial_mutex = Lock() self.status_mutex = Lock() @@ -109,6 +111,9 @@ def __init__(self, handler = None, **kargvs): self.feed_regex = re.compile("[F]([0-9.-]+)($|\s)") # looks for a +/- float number after an F, until the first space or the end of the line self.x_regex = re.compile("[X]([0-9.-]+)($|\s)") # looks for a +/- float number after an X, until the first space or the end of the line self.y_regex = re.compile("[Y]([0-9.-]+)($|\s)") # looks for a +/- float number after an Y, until the first space or the end of the line + self.macro_regex = re.compile(MACRO_CHAR+"(.*?)"+MACRO_CHAR) # looks for stuff between two "%" symbols. Used to parse macros + + self.macro_parser = Parser() # macro expressions parser # buffer controll attrs self.command_buffer = deque() @@ -140,8 +145,12 @@ 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()} + with self.status_mutex: + return { + "is_running": self._is_running, + "progress": self._current_element.get_progress(self.feedrate) if not self._current_element is None else UNKNOWN_PROGRESS, + "is_paused": self._is_paused + } def connect(self): self.logger.info("Connecting to serial device...") @@ -176,11 +185,10 @@ def start_element(self, element, force_stop=False): with self.serial_mutex: self._th = Thread(target = self._thf, args=(element,), daemon=True) self._th.name = "drawing_feeder" - self._isrunning = True + self._is_running = True self._stopped = False - self._ispaused = False + self._is_paused = False self._current_element = element - self.command_number = 0 with self.command_buffer_mutex: self.command_buffer.clear() self._th.start() @@ -189,17 +197,23 @@ def start_element(self, element, force_stop=False): # ask if the feeder is already sending a file def is_running(self): with self.status_mutex: - return self._isrunning + return self._is_running # ask if the feeder is paused def is_paused(self): with self.status_mutex: - return self._ispaused + return self._is_paused # return the code of the drawing on the go def get_element(self): with self.status_mutex: return self._current_element + + def update_current_time_element(self, new_interval): + with self.status_mutex: + if type(self._current_element) is TimeElement: + if self._current_element.type == "delay": + self._current_element.update_delay(new_interval) # stops the drawing # blocking function: waits until the thread is stopped @@ -209,7 +223,7 @@ def stop(self): with self.status_mutex: if not self._stopped: self.logger.info("Stopping drawing") - self._isrunning = False + self._is_running = False self._current_element = None # block the function until the thread is stopped otherwise the thread may still be running when the new thread is started # (_isrunning will turn True and the old thread will keep going) @@ -238,17 +252,21 @@ def stop(self): # can resume with "resume()" def pause(self): with self.status_mutex: - self._ispaused = True + self._is_paused = True + self.logger.info("Paused") # resumes the drawing (only if used with "pause()" and not "stop()") def resume(self): with self.status_mutex: - self._ispaused = False + self._is_paused = False + self.logger.info("Resumed") # 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): + command = self._parse_macro(command) + if "G28" in command: self.last_commanded_position.x = 0 self.last_commanded_position.y = 0 @@ -269,14 +287,17 @@ def send_gcode_command(self, command, hide_command=False): 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: - self.feedrate = self.feed_regex.findall(command)[0][0] - if "X" in command: - self.last_commanded_position.x = self.x_regex.findall(command)[0][0] - if "Y" in command: - self.last_commanded_position.y = self.y_regex.findall(command)[0][0] - + try: + if any(code in command for code in BUFFERED_COMMANDS): + if "F" in command: + self.feedrate = float(self.feed_regex.findall(command)[0][0]) + if "X" in command: + self.last_commanded_position.x = float(self.x_regex.findall(command)[0][0]) + if "Y" in command: + self.last_commanded_position.y = float(self.y_regex.findall(command)[0][0]) + except: + self.logger.error("Cannot parse something in the command: " + command) + finally: # wait until the lock for the buffer length is released -> means the board sent the ack for older lines and can send new ones with self.command_send_mutex: # wait until get some "ok" command to remove extra entries from the buffer pass @@ -318,7 +339,8 @@ def serial_ports_list(self): return result def is_connected(self): - return self.serial.is_connected() + with self.serial_mutex: + return self.serial.is_connected() # stops immediately the device def emergency_stop(self): @@ -354,26 +376,25 @@ def _on_device_ready_delay(self): def delay(): time.sleep(5) self._on_device_ready() - th = Thread(target = delay) + th = Thread(target = delay, daemon=True) + th.name = "waiting_device_ready" th.start() # thread function # TODO move this function in a different class? def _thf(self, element): - self.send_script(self.settings['scripts']['before']["value"]) + # runs the script only it the element is a drawing, otherwise will skip the "before" script + if isinstance(element, DrawingElement): + self.send_script(self.settings['scripts']['before']["value"]) self.logger.info("Starting new drawing with code {}".format(element)) - with self.serial_mutex: - element = self._current_element # TODO retrieve saved information for the gcode filter dims = {"table_x":100, "table_y":100, "drawing_max_x":100, "drawing_max_y":100, "drawing_min_x":0, "drawing_min_y":0} - # TODO calculate an estimate about the remaining time for the current drawing (for the moment can output the number of rows over the total number of lines in the file) - self.total_commands_number = 10**6 # TODO change this placeholder - + filter = Fit(dims) - for k, line in enumerate(element.execute(self.logger)): # execute the element (iterate over the commands or do what the element is designed for) + for k, line in enumerate(self.get_element().execute(self.logger)): # execute the element (iterate over the commands or do what the element is designed for) if not self.is_running(): break @@ -381,17 +402,25 @@ def _thf(self, element): continue line = line.upper() + + self.send_gcode_command(line) + while self.is_paused(): time.sleep(0.1) - # TODO parse line to scale/add padding to the drawing according to the drawing settings (in order to keep the original .gcode file) - #line = filter.parse_line(line) - #line = "N{} ".format(file_line) + line + # if a "stop" command is raised must exit the pause and stop the drawing + if not self.is_running(): + break - self.send_gcode_command(line) + # TODO parse line to scale/add padding to the drawing according to the drawing settings (in order to keep the original .gcode file) + #line = filter.parse_line(line) + #line = "N{} ".format(file_line) + line with self.status_mutex: self._stopped = True - if self.is_running(): + + # runs the script only it the element is a drawing, otherwise will skip the "after" script + if isinstance(element, DrawingElement): self.send_script(self.settings['scripts']['after']["value"]) + if self.is_running(): self.stop() # thread that keep reading the serial port @@ -415,7 +444,7 @@ def _update_timeout(self): # 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): + if (self.command_buffer_mutex.locked and self.line_number == self._timeout_last_line and not self.is_paused()): # self.logger.warning("!Buffer timeout. Trying to clean the buffer!") # 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) @@ -547,9 +576,14 @@ def _parse_device_line(self, line): # the response looks like: X:115.22 Y:116.38 Z:0.00 E:0.00 Count A:9218 B:9310 Z:0 # still, M114 will receive the last position in the look-ahead planner thus the drawing will end first on the interface and then in the real device elif "Count" in line: - l = line.split(" ") - x = float(l[0][2:]) # remove "X:" from the string - y = float(l[1][2:]) # remove "Y:" from the string + try: + l = line.split(" ") + x = float(l[0][2:]) # remove "X:" from the string + y = float(l[1][2:]) # remove "Y:" from the string + except Exception as e: + self.logger.error("Error while parsing M114 result for line: {}".format(line)) + self.logger.exception(e) + # if the last commanded position coincides with the current position it means the buffer on the device is empty (could happen that the position is the same between different points but the M114 command should not be that frequent to run into this problem.) TODO check if it is good enough or if should implement additional checks like a timeout # use a tolerance instead of equality because marlin is using strange rounding for the coordinates if (abs(float(self.last_commanded_position.x)-x) Drawing ended") + self.app.logger.info("Drawing ended") self.app.semits.show_toast_on_UI("Element ended") - self.app.qmanager.set_is_drawing(False) - self.app.qmanager.start_next() + self.app.qmanager.set_element_ended() if self.app.qmanager.is_queue_empty(): self.app.qmanager.send_queue_status() def on_element_started(self, element): - self.app.logger.info("B> Drawing started") - self.app.semits.show_toast_on_UI("Element started") self.app.qmanager.set_element(element) + self.app.logger.info("Drawing started") + self.app.semits.show_toast_on_UI("Element started") self.app.qmanager.send_queue_status() self.command_index = 0 @@ -39,4 +38,5 @@ def on_new_line(self, line): self.app.semits.update_hw_preview(line) def on_device_ready(self): - self.app.qmanager.check_autostart() \ No newline at end of file + self.app.qmanager.check_autostart() + self.app.qmanager.send_queue_status() \ No newline at end of file diff --git a/server/hw_controller/queue_manager.py b/server/hw_controller/queue_manager.py index d2f56241..0ef3db51 100644 --- a/server/hw_controller/queue_manager.py +++ b/server/hw_controller/queue_manager.py @@ -1,54 +1,113 @@ -from enum import auto from queue import Queue import json -from server.sockets_interface.socketio_callbacks import queue_start_drawings +from threading import Thread +import time +import random + from server.utils import settings_utils from server.hw_controller.continuous_queue_generator import ContinuousQueueGenerator -from server.database.playlist_elements import TimeElement +from server.database.playlist_elements import ShuffleElement, TimeElement + +TIME_CONVERSION_FACTOR = 60*60 # hours to seconds class QueueManager(): def __init__(self, app, socketio): - self._isdrawing = False - self._element = None - self.app = app - self.socketio = socketio - self.continuous_generator = None - self.continuous_interval = 300 # TODO load interval from the saved settings - self.q = Queue() + self._isdrawing = False + self._element = None + self.app = app + self.socketio = socketio + self.q = Queue() + self.repeat = False # true if should not delete the current element from the queue + self.shuffle = False # true if should shuffle the queue + self.interval = 0 # pause between drawing in repeat mode + self._last_time = 0 # timestamp of the end of the last drawing + self._is_force_stop = False + self._is_random = False # used when queueing random drawings + + # setup status timer + self._th = Thread(target=self._thf, daemon=True) + self._th.name = "queue_status_interval" + self._th.start() def is_drawing(self): return self._isdrawing + # pauses the feeder + def pause(self): + self.app.feeder.pause() + self.send_queue_status() + self.app.logger.info("Drawing paused") + + # resumes the feeder + def resume(self): + self.app.feeder.resume() + self.send_queue_status() + self.app.logger.info("Drawing resumed") + + # returns a boolean: true if the queue is empty and it is drawing, false otherwise def is_queue_empty(self): return not self._isdrawing and len(self.q.queue)==0 def set_is_drawing(self, dr): self._isdrawing = dr + # returns the current element def get_element(self): return self._element + # set the current element def set_element(self, element): - self.app.logger.info("Code: {}".format(element)) + self.app.logger.info("Now running: {}".format(element)) self._element = element - self.set_is_drawing(True) - - def set_continuous_interval(self, interval_value): - self.continuous_interval = interval_value - # TODO save the value in the settings - if not self.continuous_generator is None: - self.continuous_generator.set_interval(interval_value) # stop the current drawing and start the next - def stop(self): + def stop(self, is_random=False): + self._is_random = is_random + self._is_force_stop = True self.app.feeder.stop() + # set the repeat flag + def set_repeat(self, val): + if type(val) == type(True): + self.repeat = val + if self._is_random: + if val: + self.start_random_drawing() + else: + self.clear_queue() + else: raise ValueError("The argument must be boolean") + + # set the shuffle flag + def set_shuffle(self, val): + if self._is_random: # can change the shuffle option only if is not playing a random drawing + return + if type(val) == type(True): + self.shuffle = val + else: raise ValueError("The argument must be boolean") + + # set the queue interval [h] + def set_interval(self, val): + self.interval = val + + # starts a random drawing from the uploaded files + def start_random_drawing(self, repeat=False): + self.set_shuffle(True) + if self.q.empty(): + self.queue_element(ShuffleElement(shuffle_type="0"), is_random=True) # queue a new random element drawing + if repeat: # call again the same method only once to put an element in the queue + self.start_random_drawing(False) + else: + if not self.is_drawing(): + self.queue_element(ShuffleElement(shuffle_type="0"), is_random=True) + + # add an element to the queue - def queue_element(self, element, show_toast=True): + def queue_element(self, element, show_toast=True, is_random=False): + self._is_random = is_random if self.q.empty() and not self.is_drawing(): self.start_element(element) return - self.app.logger.info("Adding {} to the queue".format(element.drawing_id)) + self.app.logger.info("Adding {} to the queue".format(element)) self.q.put(element) if show_toast: self.app.semits.show_toast_on_UI("Element added to the queue") @@ -61,6 +120,17 @@ def queue_str(self): def get_queue(self): return self.q.queue + def set_element_ended(self): + self.set_is_drawing(False) + if self._is_random: + self.start_random_drawing() + # if the ended element was forced to stop should not set the "last_time" otherwise when a new element is started there will be a delay element first + if self._is_force_stop: + self._is_force_stop = False + else: + self._last_time = time.time() + self.app.qmanager.start_next() + # clear the queue def clear_queue(self): self.q.queue.clear() @@ -87,10 +157,6 @@ def remove(self, code): def queue_length(self): return self.q.qsize() - def update_status(self): - pass - # in this method should ask the updated status to the feeder (like if is drawing, queue and if necessary other stuff) - # start the next drawing of the queue # by default will start it only if not already printing something # with "force_stop = True" will stop the actual drawing and start the next @@ -98,23 +164,46 @@ def start_next(self, force_stop=False): if(self.is_drawing()): if not force_stop: return False + else: + # will reset the last_time to 0 in order to get the next element running without a delay and stop the current drawing. + # Once the current drawing the next drawing should start from the feeder event manager + self._last_time = 0 + self.stop(self._is_random) + return True try: + # should not remove the element from the queue if repeat is active. Should just add it at the end of the queue + if (not self._element is None) and (self.repeat) and (not hasattr(self._element, "_repeat_off") and (not self._is_random)): + self.q.put(self._element) + + # if the time has not expired should start a new drawing otherwise should start a delay element + if (self.interval != 0) and (not hasattr(self._element, "_repeat_off") and (self.queue_length()>0)): + if (self._last_time + self.interval*TIME_CONVERSION_FACTOR > time.time()): + element = TimeElement(delay=self.interval*TIME_CONVERSION_FACTOR + time.time() - self._last_time, type="delay") + element._repeat_off = True # when the "repeat" flag is selected, should not add this element to the queue + self.start_element(element) + return True + self._element = None if self.queue_length() > 0: - element = self.q.queue.popleft() + element = None + # if shuffle is enabled select a random drawing from the queue otherwise uses the first element of the queue + if self.shuffle: + tmp = None + elements = list(self.q.queue) + if len(elements)>1: # if the list is longer than 2 will pop the last element to avoid using it again + tmp = elements.pop(-1) + element = elements.pop(random.randrange(len(elements))) + elements.append(tmp) + self.set_new_order(elements) + else: + element = self.q.queue.popleft() + # starts the choosen element self.start_element(element) - self.app.logger.info("Starting next element: {}".format(element.type)) + self.app.logger.info("Starting next element: {}".format(element)) return True - elif not self.continuous_generator is None: - els = self.continuous_generator.generate_next_elements() - if els is None: - self.continuous_generator = None - else: - for e in els: - self.queue_element(e) return False except Exception as e: - self.app.logger.error(e) + self.app.logger.exception(e) self.app.logger.error("An error occured while starting a new drawing from the queue:\n{}".format(str(e))) self.start_next() @@ -123,36 +212,45 @@ def start_element(self, element): element = element.before_start(self.app) if not element is None: self.app.logger.info("Sending gcode start command") + self.set_is_drawing(True) self.app.feeder.start_element(element, force_stop = True) else: self.start_next() + # sends the queue status to the frontend def send_queue_status(self): elements = list(map(lambda x: str(x), self.q.queue)) if len(self.q.queue) > 0 else [] # converts elements to json res = { - "current_element": str(self._element), - "elements": elements, - "intervalValue": self.continuous_interval + "current_element": str(self._element), + "elements": elements, + "status": self.app.feeder.get_status(), + "repeat": self.repeat, + "shuffle": self.shuffle, + "interval": self.interval } self.app.semits.emit("queue_status", json.dumps(res)) - def stop_continuous(self): - self.continuous_generator = None - if type(self._element) is TimeElement: - self.stop() - else: - self.app.semits.show_toast_on_UI("Will finish the current drawing and then stop") - - def start_continuous_drawing(self, shuffle=False, playlist=0): - self.continuous_generator = ContinuousQueueGenerator(shuffle=shuffle, interval=self.continuous_interval, playlist=playlist) - self.start_next() - + # checks if should start drawing after the server is started and ready (can be set in the settings page) def check_autostart(self): autostart = settings_utils.get_only_values(settings_utils.load_settings()["autostart"]) - try: - autostart["interval"] = int(autostart["interval"]) - except: - autostart["interval"] = 0 if autostart["on_ready"]: - self.set_continuous_interval(autostart["interval"]) - self.start_continuous_drawing(autostart["shuffle"]) \ No newline at end of file + self.start_random_drawing(repeat=True) + self.set_repeat(True) + + try: + if autostart["interval"]: + self.set_interval(float(autostart["interval"])) + except Exception as e: + self.app.logger.exception(e) + + # periodically updates the queue status, used by the thread + def _thf(self): + while(True): + try: + # updates the queue status every 30 seconds but only while is drawing + time.sleep(30) + if self.is_drawing(): + self.send_queue_status() + + except Exception as e: + self.app.logger.exception(e) \ No newline at end of file diff --git a/server/preprocessing/drawing_creator.py b/server/preprocessing/drawing_creator.py index 20d14aac..f5df0733 100644 --- a/server/preprocessing/drawing_creator.py +++ b/server/preprocessing/drawing_creator.py @@ -1,10 +1,11 @@ from server.utils import settings_utils from server import app, db -from server.database.models import UploadedFiles +from server.database.models import IdsSequences, UploadedFiles from werkzeug.utils import secure_filename from server.utils.gcode_converter import ImageFactory import traceback +import json import os import shutil @@ -15,7 +16,11 @@ def preprocess_drawing(filename, file): 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(filename) - new_file = UploadedFiles(filename = filename) + + # this workaround fixes issue #40. Should fix it through the UploadFiles model with the primary key autoincrement but at the moment it is not working + id = IdsSequences.get_incremented_id(UploadedFiles) + + new_file = UploadedFiles(id = id, filename = filename) db.session.add(new_file) db.session.commit() factory = ImageFactory(settings_utils.get_only_values(settings["device"])) @@ -34,8 +39,16 @@ def preprocess_drawing(filename, file): # create the preview image try: with open(os.path.join(folder, str(new_file.id)+".gcode")) as file: - image = factory.gcode_to_image(file) + dimensions, coords = factory.gcode_to_coords(file) + image = factory.draw_image(coords, dimensions) + # saving the new image image.save(os.path.join(folder, str(new_file.id)+".jpg")) + + # saving additional information + new_file.path_length = dimensions["total_lenght"] + del dimensions["total_lenght"] + new_file.dimensions_info = json.dumps(dimensions) + db.session.commit() except: app.logger.error("Error during image creation") app.logger.error(traceback.print_exc()) diff --git a/server/saves/default_settings.json b/server/saves/default_settings.json index 33ba2573..90524563 100644 --- a/server/saves/default_settings.json +++ b/server/saves/default_settings.json @@ -137,18 +137,11 @@ "label": "Start drawing on power up", "tip": "Enable this option to start drawing automatically from the full list of drawings every time the device is turned on" }, - "shuffle": { - "name": "autostart.shuffle", - "type": "check", - "value": true, - "label": "Shuffle drawings on autostart", - "tip": "When the autostart is enabled, will play the drawings shuffled" - }, "interval": { "name": "autostart.interval", "type": "input", "value": 0, - "label": "Interval between drawings [s]", + "label": "Interval between drawings [h]", "tip": "Write the number of seconds to let the table pause between drawings" } }, diff --git a/server/sockets_interface/socketio_callbacks.py b/server/sockets_interface/socketio_callbacks.py index 70ef7d11..d69c91aa 100644 --- a/server/sockets_interface/socketio_callbacks.py +++ b/server/sockets_interface/socketio_callbacks.py @@ -126,6 +126,14 @@ def drawing_queue(code): element = DrawingElement(drawing_id=code) app.qmanager.queue_element(element) +@socketio.on("drawing_pause") +def drawing_pause(): + app.qmanager.pause() + +@socketio.on("drawing_resume") +def drawing_resume(): + app.qmanager.resume() + @socketio.on("drawing_delete") def drawing_delete(code): item = db.session.query(UploadedFiles).filter_by(id=code).first() @@ -164,32 +172,43 @@ def queue_set_order(elements): app.qmanager.set_new_order(map(lambda e: GenericPlaylistElement.create_element_from_dict(e), json.loads(elements))) # stops only the current element -@socketio.on("queue_stop_current") -def queue_stop_current(): +@socketio.on("queue_next_drawing") +def queue_next_drawing(): app.semits.show_toast_on_UI("Stopping drawing...") - app.qmanager.stop() + app.qmanager.start_next(force_stop=True) if not app.qmanager.is_drawing(): # if the drawing was the last in the queue must send the updated status app.qmanager.send_queue_status() # clears the queue and stops the current element @socketio.on("queue_stop_all") def queue_stop_all(): - queue_stop_continuous() - queue_stop_current() - -@socketio.on("queue_stop_continuous") -def queue_stop_continuous(): - app.qmanager.stop_continuous() + queue_set_repeat(False) + queue_set_shuffle(False) queue_set_order("") + app.qmanager.stop() + +# sets the repeat flag for the queue +@socketio.on("queue_set_repeat") +def queue_set_repeat(val): + app.qmanager.set_repeat(val) + app.logger.info("repeat: {}".format(val)) -@socketio.on("queue_start_drawings") -def queue_start_drawings(res): - res = json.loads(res) - app.qmanager.start_continuous_drawing(res["shuffle"], res["playlist"]) +# sets the shuffle flag for the queue +@socketio.on("queue_set_shuffle") +def queue_set_shuffle(val): + app.qmanager.set_shuffle(val) + app.logger.info("shuffle: {}".format(val)) +# sets the queue interval @socketio.on("queue_set_interval") -def queue_set_interval(interval): - app.qmanager.set_continuous_interval(interval) +def queue_set_interval(val): + app.qmanager.set_interval(float(val)) + app.logger.info("interval: {}".format(val)) + +# starts a random drawing from the uploaded list +@socketio.on("queue_start_random") +def queue_start_random(): + app.qmanager.start_random_drawing(repeat=False) # --------------------------------------------------------- LEDS CALLBACKS ------------------------------------------------------------------------------- diff --git a/server/static/Drawings/placeholder.jpg b/server/static/Drawings/placeholder.jpg index 489a001d..5e630685 100644 Binary files a/server/static/Drawings/placeholder.jpg and b/server/static/Drawings/placeholder.jpg differ diff --git a/server/utils/gcode_converter.py b/server/utils/gcode_converter.py index 9707650e..62aa214c 100644 --- a/server/utils/gcode_converter.py +++ b/server/utils/gcode_converter.py @@ -1,5 +1,5 @@ from PIL import Image, ImageDraw -from math import cos, sin, pi +from math import cos, sin, pi, sqrt from dotmap import DotMap class ImageFactory: @@ -19,7 +19,7 @@ class ImageFactory: # - 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): + 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=1, 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 @@ -56,7 +56,8 @@ def is_scara(self): # converts a gcode file to an image # requires: gcode file (not filepath) # return the image file - def gcode_to_image(self, file): + def gcode_to_coords(self, file): + total_lenght = 0 coords = [] xmin = 100000 xmax = -100000 @@ -92,6 +93,9 @@ def gcode_to_image(self, file): if p[0]=="Y": com_Y = float(p[1:]) + # calculates incremental lenght + total_lenght += sqrt(com_X**2 + com_Y**2) + # converting command X and Y to x, y coordinates (default conversion is cartesian) x = com_X y = com_Y @@ -106,7 +110,7 @@ def gcode_to_image(self, file): 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 + y = -sin(theta) * rho # uses - to remove preview mirroring 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_1)*self.pi_conversion) * com_Y * self.device_radius @@ -128,7 +132,8 @@ def gcode_to_image(self, file): print("Coordinates:") print(coords) print("XMIN:{}, XMAX:{}, YMIN:{}, YMAX:{}".format(xmin, xmax, ymin, ymax)) - limits = { + drawing_infos = { + "total_lenght": total_lenght, "xmin": xmin, "xmax": xmax, "ymin": ymin, @@ -136,12 +141,12 @@ def gcode_to_image(self, file): } # return the image obtained from the coordinates - return self.draw_image(coords, limits) + return drawing_infos, coords # 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) + def draw_image(self, coords, drawing_infos): + limits = DotMap(drawing_infos) # Make the image larger than needed so can apply antialiasing factor = 5.0 img_width = self.final_width*factor diff --git a/server/utils/logging_utils.py b/server/utils/logging_utils.py index 66aa6a91..fa66acc3 100644 --- a/server/utils/logging_utils.py +++ b/server/utils/logging_utils.py @@ -1,11 +1,35 @@ from logging import Formatter import logging -from logging.handlers import RotatingFileHandler +from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener +from queue import Queue +from multiprocessing import RLock +import shutil + +# creating a custom multiprocessing rotating file handler +# https://stackoverflow.com/questions/32099378/python-multiprocessing-logging-queuehandler-with-rotatingfilehandler-file-bein +class MultiprocessRotatingFileHandler(RotatingFileHandler): + def __init__(self, *kargs, **kwargs): + super(MultiprocessRotatingFileHandler, self).__init__(*kargs, **kwargs) + self.lock = RLock() + + def shouldRollover(self, record): + with self.lock: + return super(MultiprocessRotatingFileHandler, self).shouldRollover(record) + + # not sure why but the .log file was seen already open when it was necessary to rotate to a new file. + # instead of renaming the file now I'm copying the entire file to the new log.1 file and the clear the original .log file + # this is for sure not the best solution but it looks like it is working now + def rotate(self, source, dest): + shutil.copyfile(source, dest) + f = open(source, 'r+') + f.truncate(0) + +# fixme the rotating file handler is not working for some reason. should find a different solution. Create a new log file everytime the table is turned on? The file should be cached for some iterations? (5?) # create a common formatter for the app formatter = Formatter("[%(asctime)s] %(levelname)s in %(name)s (%(filename)s): %(message)s") -server_file_handler = RotatingFileHandler("server/logs/server.log", maxBytes=2000000, backupCount=5) +server_file_handler = MultiprocessRotatingFileHandler("server/logs/server.log", maxBytes=2000000, backupCount=5) server_file_handler.setLevel(1) server_file_handler.setFormatter(formatter) diff --git a/todos.md b/todos.md index 4a8ad8e1..ae32284b 100644 --- a/todos.md +++ b/todos.md @@ -2,15 +2,8 @@ Here is a brief list of "TODOs". More are available also in the code itself. When using vscode it is possible to use the "todo tree" extension to have a full list. -* fix the queue page: - * better visualization/layout - * playlist element that shows at which point of the playlist we are - * change the buttons in: - * clear queue - * next drawing - * stop all - * pause/resume -* highlight which drawing is being used both in the full list and in the playlist +* update the queue tab to show a playlist element that shows at which point of the playlist we are in instead of showing only the element +* highlight which drawing is being used both in the playlists and highlight also the playlist * add the possibility to save the interval between drawings in the playlists * save the interval between drawings in the home/drawings pages such that is loaded instead of being reset every time * add back a button to queue multiple playlists (also with the interval option) @@ -22,3 +15,5 @@ Here is a brief list of "TODOs". More are available also in the code itself. Whe * make a better preview for the full drawing (preview with respect to the table used) * create a category of "clear" drawings to use between other drawings. * add the clear drawing element to the playlists and to the automatic start +* limit the gcode commands to the machine dimensions +* add possibility to resize the drawing to fit (or clip it) the machine size