diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..94f480de --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 256651ce..e0059021 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,8 @@ If applicable, add screenshots to help explain your problem. - server OS: [Windows, Raspbian OS] - server hardware [PC, Raspberry Pi 3B+, ....] - serial device firmware [Marling, Grbl, ...] + version - - Version [use `git rev-parse --short HEAD`] + - Version hash [can be seen from the settings page in the web interface] + - Branch [if different than the main stable branch] **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1d9024d9..4bb4cac9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -39,4 +39,4 @@ jobs: flask db upgrade - name: Test with pytest run: | - python -m pytest server/tests + python -m pytest -s -v server/tests diff --git a/docs/old_troubleshooting.md b/docs/old_troubleshooting.md index 308c0c95..93993452 100644 --- a/docs/old_troubleshooting.md +++ b/docs/old_troubleshooting.md @@ -11,7 +11,7 @@ $> source env/bin/activate (env) $> python3 -m pip install pyserial ``` -## "Serial not available. Will use fake device" +## "Serial not available. Will use virtual device" The previous message may appear on the command line while running the program. This is a normal behaviour on the first run because it is necessary to select the serial device to connect from the UI. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4ad29a93..80de8c75 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,3 +24,4 @@ Please specify: * firmware (Marlin, Grbl, ...) * The issue * add a copy of the terminal result you get during the installation and when after running the software in orded to analyze better the problem +* if the UI is working, you can download a diagnostic zip file that can be uploaded together with the issue. This can be done with the button located at the bottom of the settings page. diff --git a/frontend/src/structure/TopBar.js b/frontend/src/structure/TopBar.js index eb5ea4fb..6f10395d 100644 --- a/frontend/src/structure/TopBar.js +++ b/frontend/src/structure/TopBar.js @@ -12,7 +12,7 @@ import { showLEDs, systemIsLinux, updateDockerComposeLatest } from './tabs/setti import { settingsRebootSystem, settingsShutdownSystem } from '../sockets/sEmits'; const mapStateToProps = (state) => { - return { + return { showBack: showBack(state), isLinux: systemIsLinux(state), showLEDs: showLEDs(state), @@ -22,34 +22,34 @@ const mapStateToProps = (state) => { } const mapDispatchToProps = (dispatch) => { - return { + return { handleTab: (name) => dispatch(setTab(name)), handleTabBack: () => dispatch(tabBack()) } } -class TopBar extends Component{ +class TopBar extends Component { - renderBack(){ + renderBack() { if (this.props.showBack) - return {this.props.handleTabBack()}}>Back + return { this.props.handleTabBack() }}>Back else return ""; } - renderSettingsButton(){ + renderSettingsButton() { let notificationCounter = 0; let renderedCounter = ""; if (!this.props.dockerComposeUpdateAvailable) notificationCounter++; - if (notificationCounter>0){ - renderedCounter = {notificationCounter} + if (notificationCounter > 0) { + renderedCounter = {notificationCounter} } if (this.props.isLinux) return - {this.props.handleTab("settings")}} + { this.props.handleTab("settings") }} icon={Sliders}> - Settings{renderedCounter} + Settings{renderedCounter} @@ -60,59 +60,59 @@ class TopBar extends Component{ onClick={() => settingsRebootSystem()}>Reboot - else return {this.props.handleTab("settings")}} - icon={Sliders}> - Settings{renderedCounter} - + else return { this.props.handleTab("settings") }} + icon={Sliders}> + Settings{renderedCounter} + } - renderLEDsTab(){ + renderLEDsTab() { if (this.props.showLEDs.value) return {this.props.handleTab("leds")}}> - LEDs + key={5} + onClick={() => { this.props.handleTab("leds") }}> + LEDs else return ""; } - render(){ + render() { return
- + - {this.props.handleTab("home")}}> -

Sandypi

-
- - - - - {this.renderSettingsButton()} - + { this.props.handleTab("home") }}> +

Sandypi

+
+ + + + + {this.renderSettingsButton()} +
} diff --git a/frontend/src/structure/tabs/playlists/Playlists.slice.js b/frontend/src/structure/tabs/playlists/Playlists.slice.js index 63d8f16b..b5c43756 100644 --- a/frontend/src/structure/tabs/playlists/Playlists.slice.js +++ b/frontend/src/structure/tabs/playlists/Playlists.slice.js @@ -17,13 +17,13 @@ const playlistsSlice = createSlice({ let elements = action.payload.elements; const playlistId = action.payload.playlistId; let pls = state.playlists.map((pl) => { - pl = {...pl}; - if (pl.id === playlistId){ + pl = { ...pl }; + if (pl.id === playlistId) { let maxId = 1; if (Array.isArray(pl.elements)) - // looking for the highest element id to add a higher value to the elements that are being added (this avoid the creation of a new element when the element with id is sent back from the server) - maxId = Math.max(pl.elements.map(el => {return el.id}), 1) + 1; - for (let e in elements){ + // looking for the highest element id to add a higher value to the elements that are being added (this avoid the creation of a new element when the element with id is sent back from the server) + maxId = Math.max(pl.elements.map(el => { return el.id }), 1) + 1; + for (let e in elements) { elements[e].id = maxId++; } pl.elements = [...pl.elements]; @@ -33,34 +33,35 @@ const playlistsSlice = createSlice({ } return pl; }); - return {...state, playlists: pls, mandatoryRefresh: true, playlistAddedNewElement: true }; + return { ...state, playlists: pls, mandatoryRefresh: true, playlistAddedNewElement: true }; }, deletePlaylist: (state, action) => { - return { ...state, playlists: state.playlists.filter((item) => { - return item.id !== action.payload; - })} + return { + ...state, playlists: state.playlists.filter((item) => { + return item.id !== action.payload; + }) + } }, resetPlaylistDeletedFlag: (state, action) => { - return {...state, playlistDeleted: false }; + return { ...state, playlistDeleted: false }; }, resetMandatoryRefresh: (state, action) => { - return {...state, mandatoryRefresh: false}; + return { ...state, mandatoryRefresh: false }; }, setPlaylists: (state, action) => { let playlistDeleted = true; // to check if the playlist has been deleted from someone else - let pls = action.payload.map((pl)=>{ - if (pl.id === state.playlistId){ + let pls = action.payload.map((pl) => { + if (pl.id === state.playlistId) { playlistDeleted = false; } - pl.elements = JSON.parse(pl.elements); return pl; }); - return { - ...state, - playlists: pls, - playlistDeleted: playlistDeleted, + return { + ...state, + playlists: pls, + playlistDeleted: playlistDeleted, mandatoryRefresh: true - }; + }; }, setSinglePlaylistId: (state, action) => { return { ...state, playlistId: action.payload, mandatoryRefresh: true, showNewPlaylist: false }; @@ -70,11 +71,11 @@ const playlistsSlice = createSlice({ let version = 0; let isNew = true; let res = state.playlists.map((pl) => { - if (pl.id === playlist.id){ + if (pl.id === playlist.id) { version = pl.version; isNew = false; return playlist; - }else{ + } else { return pl; } }); @@ -87,9 +88,9 @@ const playlistsSlice = createSlice({ res.push(playlist) // check if it is necessary to refresh the playlist view let mustRefresh = (playlist.id === state.playlistId) && ((playlist.version > version) || state.playlistAddedNewElement); - return { ...state, playlists: res, playlistDeleted: false, mandatoryRefresh: mustRefresh, playlistAddedNewElement: false}; + return { ...state, playlists: res, playlistDeleted: false, mandatoryRefresh: mustRefresh, playlistAddedNewElement: false }; }, - setShowNewPlaylist(state, action){ + setShowNewPlaylist(state, action) { return { ...state, showNewPlaylist: action.payload } } } diff --git a/frontend/src/structure/tabs/queue/Queue.slice.js b/frontend/src/structure/tabs/queue/Queue.slice.js index 1d1699fb..826be10a 100644 --- a/frontend/src/structure/tabs/queue/Queue.slice.js +++ b/frontend/src/structure/tabs/queue/Queue.slice.js @@ -8,49 +8,49 @@ const queueSlice = createSlice({ repeat: false, shuffle: false, interval: 0, - status: {eta: -1} + status: { eta: -1 } }, reducers: { - setInterval(state, action){ + setInterval(state, action) { return { - ...state, + ...state, interval: action.payload } }, - setQueueElements(state, action){ + setQueueElements(state, action) { return { ...state, elements: action.payload } }, - setQueueStatus(state, action){ + setQueueStatus(state, action) { let res = action.payload; res.current_element = res.current_element === "None" ? undefined : JSON.parse(res.current_element); return { - elements: res.elements, + elements: res.elements, currentElement: res.current_element, - interval: res.interval, - status: res.status, - repeat: res.repeat, - shuffle: res.shuffle + interval: res.interval, + status: res.status, + repeat: res.repeat, + shuffle: res.shuffle } }, - toggleQueueShuffle(state, action){ + toggleQueueShuffle(state, action) { return { ...state, shuffle: !state.shuffle } }, - toggleQueueRepeat(state, action){ + toggleQueueRepeat(state, action) { return { - ...state, + ...state, repeat: !state.repeat } } } }); -export const{ +export const { setInterval, setQueueElements, setQueueStatus, diff --git a/frontend/src/structure/tabs/queue/selector.js b/frontend/src/structure/tabs/queue/selector.js index 02d637c2..41af5f21 100644 --- a/frontend/src/structure/tabs/queue/selector.js +++ b/frontend/src/structure/tabs/queue/selector.js @@ -1,39 +1,39 @@ //returns true if the queue is empty -const getQueueEmpty = state => {return state.queue.elements.length === 0}; +const getQueueEmpty = state => { return state.queue.elements.length === 0 }; // returns the list of elements in the queue -const getQueueElements = state => {return state.queue.elements}; +const getQueueElements = state => { return state.queue.elements }; // returns the currently used element -const getQueueCurrent = state => {return state.queue.currentElement}; +const getQueueCurrent = state => { return state.queue.currentElement }; // returns the progress {eta, units} -const getQueueProgress = state => {return state.queue.status.progress}; +const getQueueProgress = state => { return state.queue.status.progress }; // returns true if the feeder is paused -const getIsQueuePaused = state => {return state.queue.status.is_paused}; +const getIsQueuePaused = state => { return state.queue.status.paused }; // returns true if the repeat mode is currently selected -const getQueueRepeat = state => {return state.queue.repeat} +const getQueueRepeat = state => { return state.queue.repeat } // returns true if the shuffle mode is currently selected -const getQueueShuffle = state => {return state.queue.shuffle} +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} +const getQueueIsRunning = state => { return state.queue.status.running } // returns the current interval value for the queue -const getIntervalValue = state => {return state.queue.interval} +const getIntervalValue = state => { return state.queue.interval } export { - getQueueEmpty, - getQueueElements, - getQueueCurrent, - getQueueProgress, - getIsQueuePaused, - getQueueRepeat, - getQueueShuffle, - getQueueIsRunning, + getQueueEmpty, + getQueueElements, + getQueueCurrent, + getQueueProgress, + getIsQueuePaused, + getQueueRepeat, + getQueueShuffle, + getQueueIsRunning, getIntervalValue }; \ No newline at end of file diff --git a/frontend/src/structure/tabs/settings/SoftwareVersion.js b/frontend/src/structure/tabs/settings/SoftwareVersion.js index c9f16ff6..d9d03796 100644 --- a/frontend/src/structure/tabs/settings/SoftwareVersion.js +++ b/frontend/src/structure/tabs/settings/SoftwareVersion.js @@ -1,13 +1,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { Col, Container, Row } from 'react-bootstrap'; -import { CloudArrowDown, CloudSlash, ExclamationTriangleFill } from 'react-bootstrap-icons'; +import { CloudArrowDown, CloudSlash, ExclamationTriangleFill, FileEarmarkArrowDown } from 'react-bootstrap-icons'; import IconButton from '../../../components/IconButton'; import { getCurrentHash, updateAutoEnabled, updateDockerComposeLatest } from './selector'; import { setTab } from '../Tabs.slice'; import { toggleAutoUpdateEnabled } from '../../../sockets/sEmits'; -import { home_site } from '../../../utils/utils'; +import { domain, home_site } from '../../../utils/utils'; const mapStateToProps = (state) => { return { @@ -23,37 +23,44 @@ const mapDispatchToProps = (dispatch) => { } } -class SoftwareVersion extends Component{ +class SoftwareVersion extends Component { - renderUpdateButton(){ + renderUpdateButton() { if (this.props.updateEnabled) - return Disable automatic updates - else return Enable automatic updates + return Disable automatic updates + else return Enable automatic updates } - renderDockerComposeUpdate(){ - if (!this.props.dockerComposeLatest){ + renderDockerComposeUpdate() { + if (!this.props.dockerComposeLatest) { return -

-

-

Docker-compose.yml file update available


A new version of the docker-compose file is available but requires to be updated manually. Check the Github homepage to see how to update.
-

+
+
+

Docker-compose.yml file update available


A new version of the docker-compose file is available but requires to be updated manually. Check the Github homepage to see how to update.
+
} return ""; } // todo add docker files version check - render(){ + render() { return

- + Current software version shash:  

{this.props.currentHash}

- + {this.renderUpdateButton()} + + window.open(domain + '/diagnostics')}> + Download diagnostic files + +
{this.renderDockerComposeUpdate()}
diff --git a/frontend/src/structure/tabs/settings/defaultSettings.js b/frontend/src/structure/tabs/settings/defaultSettings.js index 613c723e..fd891f1c 100644 --- a/frontend/src/structure/tabs/settings/defaultSettings.js +++ b/frontend/src/structure/tabs/settings/defaultSettings.js @@ -6,10 +6,10 @@ const defaultSettings = { port: { name: "serial.port", type: "select", - value: "FAKE", + value: "Virtual", label: "Serial port", available_values: [ - "FAKE" + "Virtual" ], tip: "Select the serial port" }, diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 007e68a2..07850338 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2528,9 +2528,9 @@ async-limiter@~1.0.0: integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== async@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" - integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + version "2.6.4" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" + integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== dependencies: lodash "^4.17.14" @@ -4840,9 +4840,9 @@ events@^3.0.0: integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== eventsource@^1.0.7: - version "1.1.0" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf" - integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== + version "1.1.1" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.1.tgz#4544a35a57d7120fba4fa4c86cb4023b2c09df2f" + integrity sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA== dependencies: original "^1.0.0" @@ -5170,9 +5170,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0: - version "1.14.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" - integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== + version "1.14.8" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" + integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== for-in@^1.0.2: version "1.0.2" @@ -6315,7 +6315,7 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: isarray@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" - integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4= + integrity sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ== isexe@^2.0.0: version "2.0.0" @@ -7417,9 +7417,9 @@ minimatch@3.0.4, minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" @@ -7508,7 +7508,7 @@ move-concurrently@^1.0.1: ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" @@ -7539,9 +7539,9 @@ nan@^2.12.1: integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== nanoid@^3.1.30: - version "3.1.30" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" - integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ== + version "3.2.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" + integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== nanomatch@^1.2.9: version "1.2.13" @@ -10257,9 +10257,9 @@ socket.io-client@^2.3.1: to-array "0.1.4" socket.io-parser@~3.3.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6" - integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg== + version "3.3.3" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.3.tgz#3a8b84823eba87f3f7624e64a8aaab6d6318a72f" + integrity sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg== dependencies: component-emitter "~1.3.0" debug "~3.1.0" @@ -10817,9 +10817,9 @@ terser-webpack-plugin@^1.4.3: worker-farm "^1.7.0" terser@^4.1.2, terser@^4.6.2, terser@^4.6.3: - version "4.8.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" - integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== + version "4.8.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f" + integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw== dependencies: commander "^2.20.0" source-map "~0.6.1" @@ -11215,7 +11215,15 @@ url-loader@4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" -url-parse@^1.4.3, url-parse@^1.5.3: +url-parse@^1.4.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url-parse@^1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== diff --git a/requirements.txt b/requirements.txt index 7280afae..a134d662 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,14 +22,14 @@ isort==5.10.1 itsdangerous==2.0.1 Jinja2==3.0.3 lazy-object-proxy==1.7.1 -Mako==1.1.6 +Mako==1.2.2 MarkupSafe==2.0.1 mccabe==0.6.1 mypy-extensions==0.4.3 netifaces==0.11.0 packaging==21.3 pathspec==0.9.0 -Pillow==9.0.0 +Pillow==9.0.1 pip==21.3.1 platformdirs==2.4.1 pluggy==1.0.0 diff --git a/server/__init__.py b/server/__init__.py index dfe1d3f0..fcc5417c 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,4 +1,10 @@ -from server.utils.settings_utils import get_ip4_addresses +import os +import logging +from threading import Thread +from time import sleep + +from dotenv import load_dotenv + from flask import Flask, url_for from flask.helpers import send_from_directory from flask_socketio import SocketIO @@ -9,23 +15,14 @@ from werkzeug.utils import secure_filename -import os - -from time import sleep -from dotenv import load_dotenv -import logging -from threading import Thread - from server.utils import settings_utils, software_updates, migrations +from server.utils.diagnostic import generate_diagnostic_zip 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() -# Shows ipv4 adresses -print("\nTo run the server use 'ip:5000' in your browser with one of the following ip adresses: {}\n".format(str(get_ip4_addresses())), flush=True) - # Logging setup load_dotenv() level = os.getenv("FLASK_LEVEL") @@ -48,34 +45,62 @@ # app setup # is using the frontend build forlder for the static path -app = Flask(__name__, template_folder='templates', static_folder="../frontend/build", static_url_path="/") +app = Flask( + __name__, template_folder="templates", static_folder="../frontend/build", static_url_path="/" +) app.logger.setLevel(1) 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" +app.config["SECRET_KEY"] = "secret!" # TODO put a key here +app.config["UPLOAD_FOLDER"] = "./server/static/Drawings" + +# increasing this number increases CPU usage but it may be necessary to be able to run leds in realtime (default should be 16) +Payload.max_decode_packets = 200 -Payload.max_decode_packets = 200 # increasing this number increases CPU usage but it may be necessary to be able to run leds in realtime (default should be 16) socketio = SocketIO(app, cors_allowed_origins="*") -CORS(app) # setting up cors for react +CORS(app) # setting up cors for react - -@app.route('/Drawings/') + +# Home routes +@app.route("/") +def home(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/Drawings/") def base_static(filename): + """ + Send back the required drawing preview + """ filename = secure_filename(filename) - return send_from_directory(app.root_path + app.config['UPLOAD_FOLDER'].replace("./server", "")+ "/{}/".format(filename), "{}.jpg".format(filename)) + return send_from_directory( + app.root_path + + app.config["UPLOAD_FOLDER"].replace("./server", "") + + "/{}/".format(filename), + "{}.jpg".format(filename), + ) + + +@app.route("/diagnostics") +def download_diagnostics(): + """ + Route to download the diagnostics zip file + """ + zip_path = generate_diagnostic_zip() + return send_from_directory("static", os.path.basename(zip_path)) + # database DATABASE_FILENAME = os.path.join("server", "database", "db", "database.db") dbpath = os.environ.get("DB_PATH") if not dbpath is None: file_path = os.path.join(dbpath, DATABASE_FILENAME) -else: +else: file_path = os.path.join(os.path.abspath(os.getcwd()), DATABASE_FILENAME) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'+file_path -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + file_path +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app) migrate = Migrate(app, db, include_object=migrations.include_object) @@ -84,12 +109,12 @@ def base_static(filename): import server.api.drawings from server.sockets_interface.socketio_emits import SocketioEmits import server.sockets_interface.socketio_callbacks - from server.hw_controller.queue_manager import QueueManager - from server.hw_controller.feeder import Feeder - from server.hw_controller.feeder_event_manager import FeederEventManager + from server.hardware.feeder_event_manager import FeederEventManager + from server.hardware.device.feeder import Feeder + from server.hardware.queue_manager import QueueManager from server.preprocessing.file_observer import GcodeObserverManager - from server.hw_controller.leds.leds_controller import LedsController - from server.hw_controller.buttons.buttons_manager import ButtonsManager + from server.hardware.leds.leds_controller import LedsController + from server.hardware.buttons.buttons_manager import ButtonsManager from server.utils.stats import StatsManager except Exception as e: @@ -100,7 +125,6 @@ def base_static(filename): # Device controller initialization app.feeder = Feeder(FeederEventManager(app)) -#app.feeder.connect() app.qmanager = QueueManager(app, socketio) # Buttons controller initialization @@ -115,31 +139,33 @@ def base_static(filename): # Stats manager app.smanager = StatsManager() + @app.context_processor def override_url_for(): return dict(url_for=versioned_url_for) + # Adds a version number to the static url to update the cached files when a new version of the software is loaded def versioned_url_for(endpoint, **values): - if endpoint == 'static': + if endpoint == "static": pass values["version"] = app.umanager.short_hash return url_for(endpoint, **values) -# Home routes -@app.route('/') -def home(): - return send_from_directory(app.static_folder, "index.html") +@app.teardown_appcontext +def shutdown_session(exception=None): + db.session.close() + db.engine.dispose() # Starting the feeder after the server is ready to avoid problems with the web page not showing up def run_post(): sleep(2) - app.feeder.connect() app.lmanager.start() -th = Thread(target = run_post) + +th = Thread(target=run_post, daemon=True) th.name = "feeder_starter" th.start() @@ -147,5 +173,5 @@ def run_post(): # initializes the .gcode file observer on the autostart folder app.observer = GcodeObserverManager("./server/autodetect", logger=app.logger) -if __name__ == '__main__': - socketio.run(app) +if __name__ == "__main__": + socketio.run(app, threaded=True) diff --git a/server/api/drawings.py b/server/api/drawings.py index d39bced6..ee223efa 100644 --- a/server/api/drawings.py +++ b/server/api/drawings.py @@ -5,20 +5,22 @@ ALLOWED_EXTENSIONS = ["gcode", "nc", "thr"] + def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + # Upload route for the dropzone to load new drawings -@app.route('/api/upload/', methods=['GET','POST']) +@app.route("/api/upload/", methods=["GET", "POST"]) def api_upload(): if request.method == "POST": - if 'file' in request.files: - file = request.files['file'] - if file and file.filename!= '' and allowed_file(file.filename): + if "file" in request.files: + file = request.files["file"] + if file and file.filename != "" and allowed_file(file.filename): # create entry in the database and preview image - id = preprocess_drawing(file.filename, file) + drawing_id = preprocess_drawing(file.filename, file) # refreshing list of drawings for all the clients drawings_refresh() - return jsonify(id) - return jsonify(-1) \ No newline at end of file + return jsonify(drawing_id) + return jsonify(-1) diff --git a/server/database/elements_factory.py b/server/database/elements_factory.py index 0b594a59..9ae8b626 100644 --- a/server/database/elements_factory.py +++ b/server/database/elements_factory.py @@ -2,17 +2,24 @@ from server.database.generic_playlist_element import GenericPlaylistElement from server.database.playlist_elements_tables import PlaylistElements -class ElementsFactory(): + +class ElementsFactory: @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 + if "element_type" in dict_val: + # removing the element type. Should be already be choosen when using the class + el_type = dict_val.pop("element_type") else: raise ValueError("the dictionary must contain an 'element_type'") - from server.database.playlist_elements import _get_elements_types # need to import here to avoid circular import + # need to import here to avoid circular import + from server.database.playlist_elements import ( + _get_elements_types, + ) + for elementClass in _get_elements_types(): if elementClass.element_type == el_type: return elementClass(**dict_val) @@ -27,9 +34,8 @@ def create_element_from_json(cls, json_str): 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) - \ No newline at end of file diff --git a/server/database/generic_playlist_element.py b/server/database/generic_playlist_element.py index 76d8cdf0..331cafe5 100644 --- a/server/database/generic_playlist_element.py +++ b/server/database/generic_playlist_element.py @@ -1,12 +1,3 @@ -import json - -from server.database.models import db - -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 @@ -30,23 +21,34 @@ 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(): + +import json + +from server.database.models import db + +UNKNOWN_PROGRESS = {"eta": -1, "units": "s"} # default is -1 -> ETA unknown # ETA units + + +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 + # list of fields that are column in the database and must be removed from the standard options (string column) + self._pop_options = [] + # need to pop the element_type from the final dict because this option is a column of the table + self.add_column_field("element_type") 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 + # pylint: disable=unused-argument 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 @@ -66,7 +68,7 @@ def get_progress(self, feedrate): def get_path_length_total(self): """Returns the total lenght of the path of the drawing""" return 0 - + # Returns the current partial path done (in [mm]) def get_path_lenght_done(self): """Returns the path lenght that has been done for the current drawing""" @@ -82,7 +84,7 @@ def _set_from_dict(self, values): setattr(self, k, values[k]) else: raise ValueError - + def get_dict(self): return GenericPlaylistElement.clean_dict(self.__dict__) @@ -92,7 +94,7 @@ def __str__(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 @@ -102,8 +104,12 @@ def save(self, element_table): kwargs = zip(self._pop_options, kwargs) kwargs = dict(kwargs) options = json.dumps(options) - db.session.add(element_table(element_options = options, **kwargs)) + 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)} \ No newline at end of file + return { + key: value + for key, value in val.items() + if not key.startswith("_") and not callable(key) + } diff --git a/server/database/models.py b/server/database/models.py index 63bf199a..e8eed135 100644 --- a/server/database/models.py +++ b/server/database/models.py @@ -1,3 +1,10 @@ +# pylint: disable=E1101 +""" +Database models + +""" + + from datetime import datetime import json @@ -16,7 +23,11 @@ class IdsSequences(db.Model): @classmethod def get_incremented_id(cls, table): ret_value = 1 - res = db.session.query(IdsSequences).filter(IdsSequences.id_name==table.__table__.name).first() + 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 @@ -24,7 +35,7 @@ def get_incremented_id(cls, table): # 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.add(IdsSequences(id_name=table.__table__.name, last_value=ret_value)) db.session.commit() else: res.last_value += 1 @@ -32,49 +43,67 @@ def get_incremented_id(cls, table): 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, 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 + # 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)) + id = db.Column(db.Integer, db.Sequence("uploaded_id"), primary_key=True, autoincrement=True) + # gcode filename + filename = db.Column(db.String(80), unique=False, nullable=False) + # Creation timestamp + up_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + # last time the drawing was edited (to update: datetime.datetime.utcnow()) + edit_date = db.Column(db.DateTime, default=datetime.utcnow) + # last time the drawing was used by the table: to update: (datetime.datetime.utcnow()) + last_drawn_date = db.Column(db.DateTime) + # total path lenght + path_length = db.Column(db.Float) + # additional dimensions information as json string object + dimensions_info = db.Column(db.String(150), unique=False) def __repr__(self): - return '' % self.filename - + return "" % self.filename + def save(self): return db.session.commit() @classmethod def get_full_drawings_list(cls): return db.session.query(UploadedFiles).order_by(UploadedFiles.edit_date.desc()).all() - + @classmethod def get_random_drawing(cls): return db.session.query(UploadedFiles).order_by(func.random()).first() @classmethod - def get_drawing(cls, id): - return db.session.query(UploadedFiles).filter(UploadedFiles.id==id).first() + def get_drawing(cls, drawing_id): + return db.session.query(UploadedFiles).filter(UploadedFiles.id == drawing_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 +from server.database.playlist_elements_tables import ( + create_playlist_table, + delete_playlist_table, + get_playlist_table_class, +) from server.database.elements_factory import ElementsFactory from server.database.generic_playlist_element import GenericPlaylistElement # 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 of the playlist + id = db.Column(db.Integer, primary_key=True) + # playlist name 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 timestamp + creation_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + # Last time the playlist was edited (to update: datetime.datetime.utcnow()) + edit_date = db.Column(db.DateTime, default=datetime.utcnow) + # Incremental version number: +1 every time the playlist is saved + version = db.Column(db.Integer, default=0) + def save(self): self.edit_date = datetime.utcnow() self.version += 1 @@ -86,13 +115,15 @@ def add_element(self, elements): if not isinstance(elements, list): elements = [elements] for i in elements: - if "id" in i: # delete old ids to mantain the new sorting scheme (the elements list should be already ordered, for this reason we clear the elements and add them in the right order) + # delete old ids to mantain the new sorting scheme + # (the elements list should be already ordered, for this reason we clear the elements and add them in the right order) + if "id" in i: del i["id"] if not isinstance(i, GenericPlaylistElement): i = ElementsFactory.create_element_from_dict(i) i.save(self._ec()) db.session.commit() - + def clear_elements(self): return self._ec().clear_elements() @@ -102,25 +133,27 @@ def get_elements(self): for e in els: res.append(ElementsFactory.create_element_from_db(e)) return res - + def get_elements_json(self): els = self.get_elements() return json.dumps([e.get_dict() for e in els]) def to_json(self): - return json.dumps({ - "name": self.name, - "elements": self.get_elements_json(), - "id": self.id, - "version": self.version - }) + return json.dumps( + { + "name": self.name, + "elements": [e.get_dict() for e in self.get_elements()], + "id": self.id, + "version": self.version, + } + ) # returns the database table class for the elements of that playlist def _ec(self): if not hasattr(self, "_tc"): self._tc = get_playlist_table_class(self.id) return self._tc - + @classmethod def create_playlist(cls): item = Playlists() @@ -128,24 +161,23 @@ def create_playlist(cls): db.session.commit() create_playlist_table(item.id) return item - + @classmethod def get_playlist(cls, id): if id is None: raise ValueError("An id is necessary to select a playlist") try: - return db.session.query(Playlists).filter(Playlists.id==id).one() # todo check if there is at leas one line (if the playlist exist) + return db.session.query(Playlists).filter(Playlists.id == id).one() except: return Playlists.create_playlist() @classmethod - def delete_playlist(cls, id): - item = db.session.query(Playlists).filter_by(id=id).first() + def delete_playlist(cls, playlist_id): + item = db.session.query(Playlists).filter_by(id=playlist_id).first() db.session.delete(item) db.session.commit() - delete_playlist_table(id) + delete_playlist_table(playlist_id) - # The app is using Flask-migrate # When a modification is applied to the db structure (new table, table structure modification like column name change, new column etc.) diff --git a/server/database/playlist_elements.py b/server/database/playlist_elements.py index aa713880..dfd5f3ca 100644 --- a/server/database/playlist_elements.py +++ b/server/database/playlist_elements.py @@ -15,6 +15,7 @@ from server.utils.gcode_converter import ImageFactory from server.utils.settings_utils import load_settings, get_only_values + """ --------------------------------------------------------------------------- @@ -23,34 +24,43 @@ New elements must be added to the _get_elements_types list at the end of this file --------------------------------------------------------------------------- -""" +""" -""" - Identifies a drawing element -""" class DrawingElement(GenericPlaylistElement): + """ + Identifies a drawing element + + """ + element_type = "drawing" def __init__(self, drawing_id=None, **kwargs): - super(DrawingElement, self).__init__(element_type=DrawingElement.element_type, **kwargs) # define the element type - self.add_column_field("drawing_id") # the drawing id must be saved in a dedicated column to be able to query the database and find for example in which playlist the drawing is used + # define the element type + super(DrawingElement, self).__init__(element_type=DrawingElement.element_type, **kwargs) + + # the drawing id must be saved in a dedicated column to be able to query the database and find for example in which playlist the drawing is used + self.add_column_field("drawing_id") try: 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._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 + # regexs for a +/- float number after an X, until the first space or the end of the line + self._x_regex = re.compile("[X]([0-9.-]+)($|\s)") + # regex for a +/- float number after an Y, until the first space or the end of the line + self._y_regex = re.compile("[Y]([0-9.-]+)($|\s)") - 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)) - + 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 @@ -59,11 +69,12 @@ def execute(self, logger): # 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: + with open(filename, encoding="utf-8") 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"] + # ignores the coordinates and use only the drawing dimensions + dimensions, _ = factory.gcode_to_coords(f) + drawing_infos.path_length = dimensions["total_lenght"] del dimensions["total_lenght"] drawing_infos.dimensions_info = json.dumps(dimensions) drawing_infos.save() @@ -74,9 +85,9 @@ def execute(self, logger): with open(filename) as f: for line in f: # clears the line - if line.startswith(";"): # skips commented lines + if line.startswith(";"): # skips commented lines continue - if ";" in line: # remove in line comments + if ";" in line: # remove in line comments line.split(";") line = line[0] # calculates the distance travelled @@ -85,7 +96,10 @@ def execute(self, logger): 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._distance += sqrt( + (self._new_position.x - self._last_position.x) ** 2 + + (self._new_position.y - self._last_position.y) ** 2 + ) self._last_position = copy.copy(self._new_position) except Exception as e: logger.exception(e) @@ -99,28 +113,25 @@ def get_progress(self, 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": "%" - } + return {"eta": self._distance / self._total_distance * 100, "units": "%"} else: - return { - "eta": (self._total_distance - self._distance)/feedrate, - "units": "s" - } - + return {"eta": (self._total_distance - self._distance) / feedrate, "units": "s"} + def get_path_length_total(self): """Returns the total lenght of the path of the drawing""" return self._total_distance - + def get_path_lenght_done(self): """Returns the path lenght that has been done for the current drawing""" return self._distance -""" - Identifies a command element (sends a specific command/list of commands to the board) -""" + class CommandElement(GenericPlaylistElement): + """ + Identifies a command element (sends a specific command/list of commands to the board) + + """ + element_type = "command" def __init__(self, command, **kwargs): @@ -133,10 +144,12 @@ def execute(self, logger): yield c -""" - Identifies a timing element (delay between drawings, next drawing at specific time of the day, repetitions, etc) -""" class TimeElement(GenericPlaylistElement): + """ + Identifies a timing element (delay between drawings, next drawing at specific time of the day, repetitions, etc) + + """ + element_type = "timing" # delay: wait the specified amount of seconds @@ -149,56 +162,68 @@ def __init__(self, delay=None, expiry_date=None, alarm_time=None, type="", **kwa self.alarm_time = alarm_time if alarm_time != "" else None self.type = type self._final_time = -1 - + def execute(self, logger): 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 + 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 + # get midnight and add the alarm time + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) 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) + 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 + alarm += timedelta(hours=24) # if the alarm is expired for today adds 24h self._final_time = datetime.timestamp(alarm) if self.type == "expiry_date": - self._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": - 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 - + 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 constructor + return + while True: - if time() >= self._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() < self._final_time-1: - logger.log(LINE_RECEIVED, "Waiting {:.1f} more seconds".format(self._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(self._final_time-time()) + else: + 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._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) + return {"eta": self._final_time - time(), "units": "s"} + else: + return super().get_progress(feedrate) + -""" - Plays an element in the playlist with a random order -""" class ShuffleElement(GenericPlaylistElement): + """ + Plays an element in the playlist with a random order + + """ + element_type = "shuffle" def __init__(self, shuffle_type=None, playlist_id=None, **kwargs): @@ -211,64 +236,88 @@ def before_start(self, app): if self.shuffle_type == None or self.shuffle_type == "0": # select random drawing drawing = UploadedFiles.get_random_drawing() - if drawing is None: # there is no drawing to be played + if drawing is None: # there is no drawing to be played return None - element = DrawingElement(drawing_id = drawing.id) + element = DrawingElement(drawing_id=drawing.id) elif self.playlist_id != 0: # select a random drawing from the current playlist res = get_playlist_table_class(self.playlist_id).get_random_drawing_element() # convert the db element to the drawing element format element = GenericPlaylistElement.create_element_from_db(res) - element.was_random = True + # this is to keep track that the current element was generated by a shuffle element + element.was_shuffle = True return element -""" - Starts another playlist -""" + class StartPlaylistElement(GenericPlaylistElement): + """ + Starts another playlist + + """ + element_type = "start_playlist" def __init__(self, playlist_id=None, **kwargs): - super(StartPlaylistElement, self).__init__(element_type=StartPlaylistElement.element_type, **kwargs) + super(StartPlaylistElement, self).__init__( + element_type=StartPlaylistElement.element_type, **kwargs + ) self.playlist_id = int(playlist_id) if playlist_id is not None else 0 - + def before_start(self, app): # needs to import here to avoid circular import issue from server.sockets_interface.socketio_callbacks import playlist_queue + 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 -""" + class LightsControl(GenericPlaylistElement): + """ + Controls the led lights + + """ + element_type = "" - def __init__(self, **kwargs): super().__init__(element_type=LightsControl.element_type, **kwargs) -""" - 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?) -""" class PositioningElement(GenericPlaylistElement): + """ + 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?) + + """ + element_type = "positioning" + def __init__(self, **kwargs): super().__init__(element_type=PositioningElement.element_type, **kwargs) -""" - Identifies a "clear all" pattern (really necessary?) -""" + class ClearElement(GenericPlaylistElement): + """ + Identifies a "clear all" pattern (really necessary?) + + """ + element_type = "clear" def __init__(self, **kwargs): super().__init__(element_type=ClearElement.element_type, **kwargs) + def _get_elements_types(): - return [DrawingElement, TimeElement, CommandElement, ShuffleElement, StartPlaylistElement, PositioningElement, ClearElement, LightsControl] + return [ + DrawingElement, + TimeElement, + CommandElement, + ShuffleElement, + StartPlaylistElement, + PositioningElement, + ClearElement, + LightsControl, + ] diff --git a/server/database/playlist_elements_tables.py b/server/database/playlist_elements_tables.py index 088bac47..bcf12145 100644 --- a/server/database/playlist_elements_tables.py +++ b/server/database/playlist_elements_tables.py @@ -1,3 +1,5 @@ +# pylint: disable=E1101 + import sqlalchemy from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql import func @@ -21,32 +23,39 @@ def clear_elements(cls): res = db.session.query(cls).delete() db.session.commit() return res - + # get random drawing element from the playlist (for the shuffle element) @classmethod def get_random_drawing_element(cls): return cls.query.filter(cls.drawing_id.isnot(None)).order_by(func.random()).first() + # creates sqlalchemy base class with the addition of the custom class -Base = declarative_base(cls = PlaylistElements) +Base = declarative_base(cls=PlaylistElements) Base.query = db.session.query_property() def get_playlist_table_class(id): - if id is None: raise ValueError("A playlist id must be specified") - table_name = "_playlist_{}".format(id) # table name is prefix + table_id - + if id is None: + raise ValueError("A playlist id must be specified") + table_name = "_playlist_{}".format(id) # table name is prefix + table_id + # if table exist use autoload otherwise create the table table_exist = table_name in sqlalchemy.inspect(db.engine).get_table_names() class PTable(Base): - __tablename__ = table_name # table name + __tablename__ = table_name # table name # set table args to load existing table if possible - __table_args__ = {'extend_existing': True, 'autoload': table_exist, 'autoload_with': db.get_engine()} + __table_args__ = { + "extend_existing": True, + "autoload": table_exist, + "autoload_with": db.get_engine(), + } id = db.Column(db.Integer, primary_key=True) element_type = db.Column(db.String(10), default="") - drawing_id = db.Column(db.Integer, default = None) # drawing id added explicitely for possible queries - element_options = db.Column(db.String(1000), default="") # element options + # drawing id added explicitely for possible queries + drawing_id = db.Column(db.Integer, default=None) + element_options = db.Column(db.String(1000), default="") # element options # change class attrs manually to avoid getting a warning ("This declarative base already contains a class with the same class name and module name") PTable.__name__ = table_name @@ -58,16 +67,18 @@ class PTable(Base): return PTable -def create_playlist_table(id): + +def create_playlist_table(playlist_id): """ Create a table associated to a single playlist. The number of tables will be the same as the number of playlists. """ - p_class = get_playlist_table_class(id) + _ = get_playlist_table_class(playlist_id) + -def delete_playlist_table(id): +def delete_playlist_table(playlist_id): """ Delete a table associated to a single playlist. """ - p_class = get_playlist_table_class(id) - p_class.__table__.drop(db.get_engine()) \ No newline at end of file + p_class = get_playlist_table_class(playlist_id) + p_class.__table__.drop(db.get_engine()) diff --git a/server/hw_controller/__init__.py b/server/hardware/__init__.py similarity index 100% rename from server/hw_controller/__init__.py rename to server/hardware/__init__.py diff --git a/server/hw_controller/buttons/__init__.py b/server/hardware/buttons/__init__.py similarity index 100% rename from server/hw_controller/buttons/__init__.py rename to server/hardware/buttons/__init__.py diff --git a/server/hw_controller/buttons/actions.py b/server/hardware/buttons/actions.py similarity index 88% rename from server/hw_controller/buttons/actions.py rename to server/hardware/buttons/actions.py index abfa490a..53bdbf10 100644 --- a/server/hw_controller/buttons/actions.py +++ b/server/hardware/buttons/actions.py @@ -1,12 +1,16 @@ from colorsys import hsv_to_rgb from random import random + # in this file are defined the events that can be associated to a button -from server.hw_controller.buttons.generic_button_event import GenericButtonAction +from server.hardware.buttons.generic_button_event import GenericButtonAction + class StartPause(GenericButtonAction): label = "Start/pause drawing" - description = "Resumes or pauses the current drawing. If nothing is in the queue starts a random drawing." + description = ( + "Resumes or pauses the current drawing. If nothing is in the queue starts a random drawing." + ) def execute(self): if self.app.qmanager.is_queue_empty(): @@ -14,7 +18,8 @@ def execute(self): else: if self.app.qmanager.is_paused(): self.app.qmanager.resume() - else: self.app.qmanager.pause() + else: + self.app.qmanager.pause() class StopAll(GenericButtonAction): @@ -23,6 +28,7 @@ class StopAll(GenericButtonAction): def execute(self): from server.sockets_interface.socketio_callbacks import queue_stop_all + queue_stop_all() @@ -32,6 +38,7 @@ class StartNext(GenericButtonAction): def execute(self): from server.sockets_interface.socketio_callbacks import queue_next_drawing + queue_next_drawing() @@ -52,7 +59,7 @@ class BrightnessDown(GenericButtonAction): def execute(self): self.app.lmanager.decrease_brightness() - + def tic(self, tic): self.execute() @@ -61,7 +68,7 @@ class BrightnessUpDown(GenericButtonAction): label = "Change LEDs brightness" description = "Changes LEDs brightness with a long press. After releasing the button the mode is toggled between ramp up and ramp down" usage = "long" - + def __init__(self, *args, **kargv): super().__init__(*args, **kargv) self.increasing = True @@ -88,7 +95,6 @@ class LEDsChangeColor(GenericButtonAction): description = "Chooses a random color for the LEDs" def execute(self): - rgb = hsv_to_rgb(random(),1,1) - c = [i*255 for i in rgb] + rgb = hsv_to_rgb(random(), 1, 1) + c = [i * 255 for i in rgb] self.app.lmanager.fill(c) - diff --git a/server/hw_controller/buttons/buttons_manager.py b/server/hardware/buttons/buttons_manager.py similarity index 63% rename from server/hw_controller/buttons/buttons_manager.py rename to server/hardware/buttons/buttons_manager.py index 7e32a133..999c807a 100644 --- a/server/hw_controller/buttons/buttons_manager.py +++ b/server/hardware/buttons/buttons_manager.py @@ -1,23 +1,29 @@ -# this class gathers all the classes from the actions.py file and share them with the frontend +# this class gathers all the classes from the actions.py file and share them with the frontend # (this is to avoid doubling the data on server side and frontend) # when a button is set, the same class will listen on the GPIO for the button actions import inspect -from server.hw_controller.buttons.generic_button_event import GenericButtonAction, GenericButtonEventManager -import server.hw_controller.buttons.actions as button_actions +from server.hardware.buttons.generic_button_event import ( + GenericButtonAction, + GenericButtonEventManager, +) +import server.hardware.buttons.actions as button_actions from server.utils.settings_utils import load_settings BOUNCE_TIME = 100 -class ButtonsManager: +class ButtonsManager: def __init__(self, app): self.app = app try: import RPi.GPIO as GPIO + self._gpio_available = True except (RuntimeError, ModuleNotFoundError): - self.app.logger.error("buttons:The GPIO is not accessible. If you are using a raspberry pi be sure to use superuser privileges to run this software. \n Be sure to check also the installation instructions dedicated to the hw options\n If the error persist open an issue on github") + self.app.logger.error( + "buttons:The GPIO is not accessible. If you are using a raspberry pi be sure to use superuser privileges to run this software. \n Be sure to check also the installation instructions dedicated to the hw options\n If the error persist open an issue on github" + ) self._gpio_available = False # loading actions @@ -28,16 +34,25 @@ def __init__(self, app): for cl in inspect.getmembers(button_actions, inspect.isclass): if not cl[1] == GenericButtonAction: # preparing array for the frontend (will be jsonized) - self.available_buttons_actions.append({"description": cl[1].description, "label": cl[1].label, "name": cl[0], "usage": cl[1].usage}) - self._actions[cl[0]] = cl[1] # storing classes in an array to instantiate the actions later - self._labels[cl[1].label] = cl[0] # creating a map from class label to class - + self.available_buttons_actions.append( + { + "description": cl[1].description, + "label": cl[1].label, + "name": cl[0], + "usage": cl[1].usage, + } + ) + self._actions[cl[0]] = cl[ + 1 + ] # storing classes in an array to instantiate the actions later + self._labels[cl[1].label] = cl[0] # creating a map from class label to class + self.update_settings(load_settings()) - + def get_buttons_options(self): # TODO filter actions that are not available (like leds brightness/control if the leds are not available) return self.available_buttons_actions - + def update_settings(self, settings): if self.gpio_is_available(): settings = settings["buttons"] @@ -45,13 +60,16 @@ def update_settings(self, settings): if not self._buttons is None: pairs = zip(settings["buttons"], self._buttons) # if something changed, reload all the buttons callbacks - if not any(x != y for x, y in pairs): # should update only if any difference has been found or if the self._button object is None + if not any( + x != y for x, y in pairs + ): # should update only if any difference has been found or if the self._button object is None should_update = False - + if not should_update: return - import RPi.GPIO as GPIO + import RPi.GPIO as GPIO + if not self._buttons is None: # clear GPIO for b in self._buttons: @@ -62,17 +80,19 @@ def update_settings(self, settings): GPIO.cleanup() GPIO.setmode(GPIO.BCM) self._buttons = settings["buttons"] - # set new callbacks + # set new callbacks for b in self._buttons: try: pin = int(b["pin"]["value"]) except: self.app.logger.error("Check the button pin number. Looks like is not a number") - + bobj = GenericButtonEventManager(self.app, pin) # adding actions to the generic button event manager bobj.set_click_action(self._actions[self._labels[b["click"]["value"]]](self.app)) - bobj.set_long_press_action(self._actions[self._labels[b["press"]["value"]]](self.app)) + bobj.set_long_press_action( + self._actions[self._labels[b["press"]["value"]]](self.app) + ) pull_up_down = None if b["pull"]["value"] == "Pullup internal": @@ -83,9 +103,11 @@ def update_settings(self, settings): if pull_up_down is None: GPIO.setup(pin, GPIO.IN) - else: + else: GPIO.setup(pin, GPIO.IN, pull_up_down=pull_up_down) - GPIO.add_event_detect(pin, GPIO.BOTH, callback = bobj.button_change, bouncetime = BOUNCE_TIME) # the rising or falling edge is detected in the button event class - + GPIO.add_event_detect( + pin, GPIO.BOTH, callback=bobj.button_change, bouncetime=BOUNCE_TIME + ) # the rising or falling edge is detected in the button event class + def gpio_is_available(self): return self._gpio_available diff --git a/server/hw_controller/buttons/generic_button_event.py b/server/hardware/buttons/generic_button_event.py similarity index 100% rename from server/hw_controller/buttons/generic_button_event.py rename to server/hardware/buttons/generic_button_event.py diff --git a/server/hardware/device/__init__.py b/server/hardware/device/__init__.py new file mode 100644 index 00000000..56fe7883 --- /dev/null +++ b/server/hardware/device/__init__.py @@ -0,0 +1 @@ +# added this file to avoid linter errors/warnings diff --git a/server/hardware/device/comunication/__init__.py b/server/hardware/device/comunication/__init__.py new file mode 100644 index 00000000..56fe7883 --- /dev/null +++ b/server/hardware/device/comunication/__init__.py @@ -0,0 +1 @@ +# added this file to avoid linter errors/warnings diff --git a/server/hardware/device/comunication/device_serial.py b/server/hardware/device/comunication/device_serial.py new file mode 100644 index 00000000..6c351ebd --- /dev/null +++ b/server/hardware/device/comunication/device_serial.py @@ -0,0 +1,256 @@ +from queue import Queue +import sys +import logging +import glob +from threading import Thread, RLock +from time import sleep +import serial +import serial.tools.list_ports + +from server.hardware.device.comunication.emulator import Emulator +from server.hardware.device.comunication.readline_buffer import ReadlineBuffer + +# loops in this class need a short sleep otherwise the entire app get stuck for some reason +LOOPS_SLEEP_TIME = 0.001 + + +class DeviceSerial: + """ + Connect to a serial device + If the serial device request is not available it will create a virtual serial device + """ + + def __init__(self, serial_name=None, baudrate=115200, logger_name=None): + """ + Args: + serial_name: name of the serial port to use + baudrate: baudrate value to use + logger_name: name of the logger to use + """ + self.logger = ( + logging.getLogger(logger_name) if not logger_name is None else logging.getLogger() + ) + self.serialname = serial_name + self.baudrate = baudrate + self.is_virtual = False + self.serial = None + self._emulator = Emulator() + self._readline_buffer = ReadlineBuffer() + self._send_buffer = [] + + # empty callback function + def useless(arg): + pass + + # setting up the read thread + self._mutex = RLock() + self._th = Thread(target=self._thf, daemon=True) + self._th.name = "serial_read" + self._running = False + + # setting up callbacks (they are called in a separate thread to have non blocking serial handling) + self._callbacks_queue = Queue() + self.set_on_readline_callback(useless) + self._callbacks_th = Thread(target=self._use_callbacks, daemon=True) + self._callbacks_th.name = "serial_callbacks" + + def open(self): + """ + Open the serial port + + If the port is not working, work as a virtual device + """ + try: + if self.serialname in self.get_serial_port_list(): + args = dict(baudrate=self.baudrate, timeout=0, write_timeout=0) + self.serial = serial.Serial(**args) + self.serial.port = self.serialname + self.serial.open() + self.logger.info("Serial device connected") + else: + self.is_virtual = True + self.logger.error( + "The selected serial port is not available. Starting a virtual device..." + ) + except Exception as e: + # FIXME should check for different exceptions + self.logger.exception(e) + self.is_virtual = True + self.logger.error( + "Serial not available. Are you sure the device is connected and is not in use by other softwares? " + + "(Will use the virtual serial)" + ) + + self._th.start() + + def set_on_readline_callback(self, callback): + """ + Set the a callback for a new line received + + Args: + callback: the function to call when a new line is received. + The function will receive the line as an argument + """ + self._on_readline = callback + + @property + def is_running(self): + """ + Check if the reading thread is running + + Returns: + True if the readline thread is running + """ + return self._running + + @property + def is_send_buffer_empty(self): + """ + Returns: + True if the send_buffer is empty + """ + return not bool(self._send_buffer) + + def stop(self): + """ + Stop the serial read thread + """ + self._running = False + + def send(self, line): + """ + Send a line to the device + + Args: + line: the line to send to the device + """ + with self._mutex: + self._send_buffer.append(line) + + @property + def is_connected(self): + """ + Returns: + True if the serial is open on a real device + """ + if self.is_virtual: + return False + return self.serial.is_open + + def close(self): + """ + Close the connection with the serial device + """ + self.stop() + try: + self.serial.close() + self.logger.info("Serial port closed") + except: + self.logger.error("Error: serial already closed or not available") + + # private functions + + def _readline(self): + """ + Reads a line from the device (if available) and call the callback + """ + line = "" + if not self.is_virtual: + if self.serial.is_open and not (self.serial is None): + if self.serial.in_waiting > 0: + line = self.serial.readline() + line = line.decode(encoding="UTF-8") + else: + line = self._emulator.readline() + + if (line == "") or (line is None): + return + + self._readline_buffer.update_buffer(line) + + def _send_line(self): + # send a new line from the buffer + if len(self._send_buffer) == 0: + return + if self.is_virtual: + line = self._send_buffer.pop(0) + self._emulator.send(line) + else: + if self.serial.is_open: + try: + # wait for the serial to be clear before sending to reduce the possibility of a collision + if (self.serial.out_waiting > 0) or (self.serial.in_waiting > 0): + return + with self._mutex: + line = self._send_buffer.pop(0) + self.serial.write(str(line).encode()) + sleep(0.01) + except: + self.close() + self.logger.error("Error while sending a command") + + def _thf(self): + """ + Thread function for the readline + """ + self._running = True + + self._callbacks_th.start() + + while self.is_running: + # do not understand why but with the emulator need this to make everything work correctly + with self._mutex: + self._readline() + self._send_line() + + # check if should use the callback when there is a new full line + full_lines = self._readline_buffer.full_lines + # use the callback for every full line available + for full_line in full_lines: + self._callbacks_queue.put(full_line) + + def filter_callbacks_queue(self, filter_fun): + """ + Remove the unprocessed strings received with the given content + + Args: + filter_fun: funtion to filter if the content should be dropped + """ + tmp = [i for i in self._callbacks_queue.queue if filter_fun(i)] + self._callbacks_queue = Queue() + for i in tmp: + self._callbacks_queue.put(i) + + def _use_callbacks(self): + """ + Run the callback when a line is received + + Keep the operation asynchronous to avoid deadlocks with the "send" command + """ + while self._running: + sleep(LOOPS_SLEEP_TIME) + if not self._callbacks_queue.empty(): + full_line = self._callbacks_queue.get() + try: + self._on_readline(full_line) + except Exception as exception: + self.logger.error( + f"Exception while raising the readline callback on line '{full_line}'" + ) + self.logger.exception(exception) + + @classmethod + def get_serial_port_list(cls): + """ + Returns: + list of the names of the available serial ports + """ + if sys.platform.startswith("win"): + plist = serial.tools.list_ports.comports() + ports = [port.device for port in plist] + elif sys.platform.startswith("linux") or sys.platform.startswith("cygwin"): + # this excludes your current terminal "/dev/tty" + ports = glob.glob("/dev/tty[A-Za-z]*") + else: + raise EnvironmentError("Unsupported platform") + return ports diff --git a/server/hw_controller/emulator.py b/server/hardware/device/comunication/emulator.py similarity index 59% rename from server/hw_controller/emulator.py rename to server/hardware/device/comunication/emulator.py index c0bf8616..c3753cad 100644 --- a/server/hw_controller/emulator.py +++ b/server/hardware/device/comunication/emulator.py @@ -1,39 +1,57 @@ -import time, re, math +import time +import re +import math from collections import deque from server.utils.settings_utils import load_settings -import server.hw_controller.firmware_defaults as firmware emulated_commands_with_delay = ["G0", "G00", "G1", "G01"] -ACK = "ok\n\r" +ACK = "ok\n" + + +class Emulator: + """Emulates a device""" -class Emulator(): def __init__(self): self.feedrate = 5000.0 - self.ack_buffer = deque() # used for the standard "ok" acks timing - self.message_buffer = deque() # used to emulate marlin response to special commands + self.ack_buffer = deque() # used for the standard "ok" acks timing + self.message_buffer = deque() # used to emulate marlin response to special commands self.last_time = time.time() - self.xr = re.compile("[X]([0-9.]+)($|\s)") - self.yr = re.compile("[Y]([0-9.]+)($|\s)") - self.fr = re.compile("[F]([0-9.]+)($|\s)") + self.xr = re.compile("[X]([0-9.]+)($|\s|)") + self.yr = re.compile("[Y]([0-9.]+)($|\s|)") + self.fr = re.compile("[F]([0-9.]+)($|\s|)") self.last_x = 0.0 self.last_y = 0.0 self.settings = load_settings() - self.message_buffer.append(firmware.get_ready_message(self.settings["device"]["firmware"]["value"])+"\n") # sends back a message to tell the board is ready and can receive commands def get_x(self, line): + """ + Return the x value of the command if available in the given line + """ return float(self.xr.findall(line)[0][0]) - + def get_y(self, line): + """ + Return the y value of the command if available in the given line + """ return float(self.yr.findall(line)[0][0]) - + def _buffer_empty(self): - return len(self.ack_buffer)<1 + """Return True if the buffer is empty""" + return len(self.ack_buffer) < 1 def send(self, command): + """ + Used to send a command to the emulator + + Args: + - command: the command sent to the emulator + """ + if self._buffer_empty(): self.last_time = time.time() + # TODO introduce the response for particular commands (like feedrate request, position request and others) # reset position for G28 command @@ -60,8 +78,11 @@ def send(self, command): y = self.last_y # calculate time self.feedrate = max(self.feedrate, 0.01) - t = max(math.sqrt((x-self.last_x)**2 + (y-self.last_y)**2) / self.feedrate * 60.0, 0.1) # TODO need to use the max 0.005 because cannot simulate anything on the frontend otherwise... May look for a better solution - + t = max( + math.sqrt((x - self.last_x) ** 2 + (y - self.last_y) ** 2) / self.feedrate * 60.0, + 0.1, + ) # TODO need to use the max 0.005 because cannot simulate anything on the frontend otherwise... May look for a better solution + # update positions self.last_x = x self.last_y = y @@ -74,16 +95,22 @@ def send(self, command): self.message_buffer.append(ACK) def readline(self): + """ + Readline method for the emulated device. Used by the serial controller + """ + # this time is needed to slow down the loop otherwise the software get stuck with the emulator + time.sleep(0.001) # special commands response if len(self.message_buffer) >= 1: return self.message_buffer.popleft() - + # standard lines acks (G0, G1) if self._buffer_empty(): return None - oldest = self.ack_buffer.popleft() + oldest = 1000000000 + if len(self.ack_buffer): + oldest = self.ack_buffer.popleft() if oldest > time.time(): self.ack_buffer.appendleft(oldest) return None - else: - return ACK \ No newline at end of file + return ACK diff --git a/server/hardware/device/comunication/readline_buffer.py b/server/hardware/device/comunication/readline_buffer.py new file mode 100644 index 00000000..e572494b --- /dev/null +++ b/server/hardware/device/comunication/readline_buffer.py @@ -0,0 +1,44 @@ +class ReadlineBuffer: + """ + This buffer handles the data received from the serial + + The received data is stored and the returned value is different than None only if + there is a newline character + """ + + def __init__(self): + self._buff = "" + self._full_lines = [] + + def update_buffer(self, new_bytes): + """ + Update the buffer with the last received bytes + + Args: + new_bytes: the freshly received bytes from the serial + """ + if (new_bytes == "") or (new_bytes is None): + return + + self._buff += new_bytes + tmp = self._buff.split("\n") + if len(tmp) > 1: + # setting the buffer to use the last received line bit + self._buff = tmp[-1] + # adding current lines to the full list of lines to check + self._full_lines += tmp[:-1] + + @property + def full_lines(self): + """ + Returns the list of full lines received and then will clear the list + + Returns: + list of full lines that have been received + """ + if len(self._full_lines) == 0: + return [] + + tmp = self._full_lines + self._full_lines = [] + return tmp diff --git a/server/hardware/device/estimation/__init__.py b/server/hardware/device/estimation/__init__.py new file mode 100644 index 00000000..56fe7883 --- /dev/null +++ b/server/hardware/device/estimation/__init__.py @@ -0,0 +1 @@ +# added this file to avoid linter errors/warnings diff --git a/server/hardware/device/estimation/cartesian.py b/server/hardware/device/estimation/cartesian.py new file mode 100644 index 00000000..eeb094e3 --- /dev/null +++ b/server/hardware/device/estimation/cartesian.py @@ -0,0 +1,5 @@ +from server.hardware.device.estimation.generic_estimator import GenericEstimator + + +class Cartesian(GenericEstimator): + ... diff --git a/server/hardware/device/estimation/generic_estimator.py b/server/hardware/device/estimation/generic_estimator.py new file mode 100644 index 00000000..4318bc5c --- /dev/null +++ b/server/hardware/device/estimation/generic_estimator.py @@ -0,0 +1,94 @@ +import re +from dotmap import DotMap + +# List of commands that can be parsed by the estimator +KNOWN_COMMANDS = ("G0", "G00", "G1", "G01", "G28", "G92") + +# TODO change this class to work in a different way +# every command should be represented by a function +# if the function is available will call the corresponding function in order to estimate the real trajectory/position + +# TODO should add realtime estimation in the "get_current_position" method instead of returning the one from the last command + + +class GenericEstimator: + """ + Keep track of the current position and extimate the time elapsed or the length of the path done + """ + + def __init__(self): + + self._position = DotMap({"x": 0, "y": 0}) + self._feedrate = 0 + self._path_length = 0 + + # regex generation for the parser + 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 + + @property + def position(self): + """ + Returns: + estimated position of the device + """ + return self._position + + @position.setter + def position(self, pos): + """ + Set the position + + Args: + pos (dict): dict which must contain x and y coordinates + """ + if not (hasattr(pos, "x") and hasattr(pos, "y")): + raise ValueError("The position given must have both the x and y coordinates") + self._position = DotMap(pos) + + @property + def feedrate(self): + return self._feedrate + + def get_last_commanded_position(self): + """ + Returns: + dict: last commanded x,y position + """ + # TODO this will change once the estimation is done properly + return self._position + + def reset_path_length(self): + """ + Reset traveled path length + """ + self._path_length = 0 + + def parse_command(self, command): + """ + Parse buffer commands to get the position commanded or to reset position if is using G28/G92 + """ + # handling homing commands + if "G28" in command and (not "X" in command or "Y" in command): + self._position.x = 0 + self._position.y = 0 + return + # G92 is handled in the buffered commands + + if any(code in command for code in KNOWN_COMMANDS): + if "F" in command: + self._feedrate = float(self._feed_regex.findall(command)[0][0]) + if "X" in command: + self._position.x = float(self._x_regex.findall(command)[0][0]) + if "Y" in command: + self._position.y = float(self._y_regex.findall(command)[0][0]) + + def __str__(self): + return f"Estimator type: {type(self).__name__}" diff --git a/server/hardware/device/estimation/polar.py b/server/hardware/device/estimation/polar.py new file mode 100644 index 00000000..e4bebbd9 --- /dev/null +++ b/server/hardware/device/estimation/polar.py @@ -0,0 +1,5 @@ +from server.hardware.device.estimation.generic_estimator import GenericEstimator + + +class Polar(GenericEstimator): + ... diff --git a/server/hardware/device/estimation/scara.py b/server/hardware/device/estimation/scara.py new file mode 100644 index 00000000..0ebeaef3 --- /dev/null +++ b/server/hardware/device/estimation/scara.py @@ -0,0 +1,5 @@ +from server.hardware.device.estimation.generic_estimator import GenericEstimator + + +class Scara(GenericEstimator): + ... diff --git a/server/hardware/device/feeder.py b/server/hardware/device/feeder.py new file mode 100644 index 00000000..f0a773e5 --- /dev/null +++ b/server/hardware/device/feeder.py @@ -0,0 +1,365 @@ +import logging +import os +import time + +from threading import RLock, Thread +from dotenv import load_dotenv +from dotmap import DotMap +from server.database.playlist_elements import DrawingElement, TimeElement + +from server.hardware.device.estimation.cartesian import Cartesian +from server.hardware.device.estimation.generic_estimator import GenericEstimator +from server.hardware.device.estimation.polar import Polar +from server.hardware.device.estimation.scara import Scara +from server.hardware.device.feeder_event_handler import FeederEventHandler +from server.hardware.device.firmwares.firmware_event_handler import FirwmareEventHandler +from server.hardware.device.firmwares.generic_firmware import GenericFirmware +from server.hardware.device.firmwares.grbl import Grbl +from server.hardware.device.firmwares.marlin import Marlin + +from server.database.generic_playlist_element import UNKNOWN_PROGRESS, GenericPlaylistElement + +from server.utils import settings_utils +from server.utils.logging_utils import formatter, MultiprocessRotatingFileHandler + +# list of known firmwares +available_firmwares = DotMap({"Marlin": Marlin, "Grbl": Grbl, "Generic": GenericFirmware}) + +# list of known device types, for which an estimator has been built +available_estimators = DotMap( + {"Cartesian": Cartesian, "Polar": Polar, "Scara": Scara, "Generic": GenericEstimator} +) + + +class Feeder(FirwmareEventHandler): + """ + Feed the gcode to the device + + Handle single commands but also the preloaded scripts and complete drawings or elements + """ + + def __init__(self, event_handler: FeederEventHandler): + """ + Args: + event_handler: handler for the events like drawing started, drawing ended and so on + """ + # initialize logger + self.init_logger() + + self.event_handler = event_handler + self._mutex = RLock() + self._device = None + + # feeder variables + self._status = DotMap({"running": False, "paused": False, "progress": UNKNOWN_PROGRESS}) + self._current_element = None + # self._stopped will be true when the device is correctly stopped after calling stop() + self._stopped = False + # thread instance running the elements + self.__th = None + + # initialize the device + self.init_device(settings_utils.load_settings()) + + def init_logger(self): + """ + Initialize the logger + + Initiate the stream logger for the command line but also the file logger for the rotating log files + """ + self.logger = logging.getLogger(__name__) + self.logger.handlers = [] # remove default handlers + self.logger.propagate = False # False -> avoid passing it to the parent logger + logging.addLevelName(settings_utils.LINE_SENT, "LINE_SENT") + logging.addLevelName(settings_utils.LINE_RECEIVED, "LINE_RECEIVED") + logging.addLevelName(settings_utils.LINE_SERVICE, "LINE_SERVICE") + + # set logger to lowest level to make availables all the levels to the handlers + # in this way can have different levels in the handlers + self.logger.setLevel(settings_utils.LINE_SERVICE) + + # create file logging handler + file_handler = MultiprocessRotatingFileHandler( + "server/logs/feeder.log", maxBytes=200000, backupCount=5 + ) + # the file logs must use the lowest level available + file_handler.setLevel(settings_utils.LINE_SERVICE) + file_handler.setFormatter(formatter) + # add handler to the logger + self.logger.addHandler(file_handler) + + # load sterr (cmd line) logging level from environment variables + load_dotenv() + level = os.getenv("FEEDER_LEVEL") + # check if the level has been set in the environment variables (should be done in the flask.env or .env files) + if not level is None: + level = int(level) + else: + level = 0 # lowest level by default + + # create stream handler (to show the log on the command line) + stream_handler = logging.StreamHandler() + stream_handler.setLevel(level) # can use a different level with respect to the file handler + stream_handler.setFormatter(formatter) + # add handler to the logger + self.logger.addHandler(stream_handler) + + # print the logger level on the command line + settings_utils.print_level(level, __name__.split(".")[-1]) + + def init_device(self, settings): + """ + Init the serial device + + Initialize the firmware depending on the settings given as argument + + Args: + settings: the settings dict + """ + with self._mutex: + if self._status.running: + self.stop() + # close connection with previous settings if available + if not self._device is None: + if self._device.is_connected: + self._device.close() + + self.settings = settings + firmware = settings["device"]["firmware"]["value"] + # create the device based on the choosen firmware + if not available_firmwares.has_key(firmware): + firmware = "Generic" + self._device = available_firmwares[firmware]( + settings["serial"], logger=self.logger.name, event_handler=self + ) + + # enable or disable fast mode for the device + self._device.fast_mode = settings["serial"]["fast_mode"]["value"] + + # select the right estimator depending on the device type + device_type = settings["device"]["type"]["value"] + if available_estimators.has_key(device_type): + self._device.estimator = available_estimators[device_type]() + # try to connect to the serial device + # if the device is not available will create a fake/virtual device + self._device.connect() + + @property + def is_connected(self): + """ + Returns: + True if is connected to a real device + False if is using a virtual device + """ + with self._mutex: + return self._device.is_connected + + @property + def status(self): + """ + Returns: + dict with the current status: + * running: True if there is a drawing going on + * paused: if the device is paused + * progress: the progress of the current element + """ + with self._mutex: + self._status.progress = ( + self._current_element.get_progress(self._device.estimator.feedrate) + if not self._current_element is None + else UNKNOWN_PROGRESS + ) + return self._status + + @property + def current_element(self): + """ + Returns: + currently being used element + """ + with self._mutex: + return self._current_element + + def pause(self): + """ + Pause the current drawing + """ + with self._mutex: + self._status.paused = True + self.logger.info("Paused") + + def resume(self): + """ + Resume the current paused drawing + """ + with self._mutex: + self._status.paused = False + self.logger.info("Resumed") + + def stop(self): + """ + Stop the current element + + This is a blocking function. Will wait until the element is completely stopped before going on with the execution + """ + with self._mutex: + # if is not running, no need to stop it + if not self._status.running: + return + + tmp = ( + self._current_element + ) # store the current element to raise the "on_element_ended" callback + # self._current_element = None + self._status.running = False + if not self._stopped: + self.logger.info("Stopping drawing") + while True: + with self._mutex: + if self._stopped: + break + + # waiting comand buffer to be cleared before calling the "drawing ended" event + while True: + self.logger.debug(f"Stopping element. Buffer length: {len(self._device.buffer)}") + time.sleep(0.1) + if len(self._device.buffer) <= 1: + break + + # clean the device status + self._device.reset_status() + + # call the element ended callback + self.logger.info("Calling on element ended") + self.event_handler.on_element_ended(tmp) + + def send_gcode_command(self, command): + """ + Send a gcode command to the device + """ + self._device.send_gcode_command(command) + + def send_script(self, script): + """ + Send a series of commands (script) + + Args: + script: a string containing "\n" separated gcode commands + """ + with self._mutex: + script = script.split("\n") + for s in script: + if s != "" and s != " ": + self.send_gcode_command(s) + + def start_element(self, element: GenericPlaylistElement): + """ + Start the given element + + The element will start only if the feeder is not running. + If there is already something running will not run the element and return False. + To run an element must first stop the feeder. The "on_element_ended" callback will be raised when the device is stopped + + Args: + element: the element to be played + + Returns: + True if the element is being started, False otherwise + """ + with self._mutex: + # if is already running something and the force_stop is not set will return False directly + if self._status.running: + return False + + # starting the thread + self.__th = Thread(target=self.__thf, daemon=True) + self.__th.name = "feeder_send_element" + + # resetting status + self._status.running = True + self._status.paused = False + self._stopped = False + self._current_element = element + self._device.buffer.clear() + # starting the thread + self.__th.start() + + # callback for the element being started + self.event_handler.on_element_started(element) + + return True + + def update_current_time_element(self, new_interval): + """ + If the current element is a TimeElement, allow to change the interval value to update the due date + + Args: + new_interval: the new interval value for the TimeElement + """ + with self._mutex: + if type(self._current_element) is TimeElement: + if self._current_element.type == "delay": + self._current_element.update_delay(new_interval) + + # event handler methods + + def on_line_sent(self, line): + self.event_handler.on_new_line(line) + + def on_line_received(self, line): + self.event_handler.on_message_received(line) + + def on_device_ready(self): + self.logger.info(f"\nDevice ready.\n{self._device}\n") + self.send_script(self.settings["scripts"]["connected"]["value"]) + + # private methods + + def __thf(self): + """ + This function handle the element once the start element method is called + + This function must not be called directly but will run in a separate thread + """ + # run the "before" script only if the given element is a drawing + with self._mutex: + if isinstance(self._current_element, DrawingElement): + self.send_script(self.settings["scripts"]["before"]["value"]) + + self.logger.info(f"Starting new drawing with code {self._current_element}") + + # TODO add "scale/fit/clip" filters + + # execute the command (iterate over the lines/commands or just execute what is necessary) + for k, line in enumerate(self._current_element.execute(self.logger)): + # if the feeder is being stopped the running flag will be False -> should exit the loop immediately + with self._mutex: + if not self._status.running: + break + + # if the line is None should just go to the next iteration + if line is None: + continue + self.send_gcode_command(line) # send the line to the device + + # if the feeder is paused should just wait until the drawing is resumed + while True: + with self._mutex: + # if not paused or if a stop command is used should exit the loop + if not self._status.paused or not self._status.running: + break + time.sleep(0.1) + + if self._stopped: + self.logger.info("Element stopped") + else: + self.logger.info("Element finished") + + # run the "after" script only if the given element is a drawing + with self._mutex: + if isinstance(self._current_element, DrawingElement): + self.send_script(self.settings["scripts"]["after"]["value"]) + + self._stopped = True + if self._status.running: + self.stop() diff --git a/server/hardware/device/feeder_event_handler.py b/server/hardware/device/feeder_event_handler.py new file mode 100644 index 00000000..a1092751 --- /dev/null +++ b/server/hardware/device/feeder_event_handler.py @@ -0,0 +1,48 @@ +class FeederEventHandler: + """ + Handle the event calls from the feeder + This is just a base class, every method is empty + Need to implement this in a custom event handler + """ + + def on_element_ended(self, element): + """ + Used when a drawing is finished + + Args: + element: the element that was ended + """ + pass + + def on_element_started(self, element): + """ + Used when a drawing is started + + Args: + element: the element that was started + """ + pass + + def on_message_received(self, line): + """ + Used when the device send a message that must be sent to the frontend + + Args: + line: the line received from the device + """ + pass + + def on_new_line(self, line): + """ + Used when a new line is passed to the device + + Args: + line: the line sent to the device + """ + pass + + def on_device_ready(self): + """ + Used when the connection with the device has been done and the device is ready + """ + pass diff --git a/server/hardware/device/firmwares/__init__.py b/server/hardware/device/firmwares/__init__.py new file mode 100644 index 00000000..56fe7883 --- /dev/null +++ b/server/hardware/device/firmwares/__init__.py @@ -0,0 +1 @@ +# added this file to avoid linter errors/warnings diff --git a/server/hardware/device/firmwares/commands_buffer.py b/server/hardware/device/firmwares/commands_buffer.py new file mode 100644 index 00000000..06886c9f --- /dev/null +++ b/server/hardware/device/firmwares/commands_buffer.py @@ -0,0 +1,115 @@ +from collections import deque +from threading import RLock, Lock + +from server.utils import limited_size_dict + + +class CommandBuffer: + """Buffer to store the commands and keep of the buffer status on the device""" + + def __init__(self, max_length=8): + """ + Args: + max_length (int): the maximum number of messages that can be sent in a row without receiving acks + """ + # this lock is used to check if the buffer is full + # if the mutex is locker means that must wait before sending a new line + self._send_mutex = Lock() + # this lock is just to access the buffer + self._mutex = RLock() + # command buffers + self._buffer = deque() + # max number of messages + self._buffer_max_length = max_length + # keep saved the last n commands (this will be helpfull for marlin) + self._buffer_history = limited_size_dict.LimitedSizeDict( + size_limit=self._buffer_max_length + 40 + ) + + def is_empty(self): + """ + Returns: + True if the buffer is empty + """ + return len(self._buffer) > 0 + + def get_buffer_wait_mutex(self): + """ + Returns: + send_mutex: the mutex is locked if the buffer is full and cannot send more commands + """ + return self._send_mutex + + def push_command(self, command, line_number, no_buffer=False): + """ + Args: + command: the command that must be buffered + line_number: the line number to be buffered + no_buffer: if False, will not make the buffer longer (used with the control commands) + """ + with self._mutex: + self._buffer.append(line_number) + self._buffer_history[f"N{line_number}"] = command + if no_buffer: + self._buffer.popleft() # remove an element to get a free ack from the non buffered command. Still must keep it in the buffer in the case of an error in sending the line + + if len(self._buffer) >= self._buffer_max_length and not self._send_mutex.locked(): + self._send_mutex.acquire() # if the buffer is full acquire the lock so that cannot send new lines until the reception of an ack. Notice that this will stop only buffered commands. The other commands will be sent anyway + + def clear(self): + """ + Clear the buffer + """ + with self._mutex: + self._buffer.clear() + self._buffer_history.clear() + self.check_buffer_mutex_status() + + def ack_received(self, safe_line_number=None, append_left_extra=False): + """ + This method must be called when a new ack has been received + Clear the oldest sent command in order to free up space in the buffer + + Args: + safe_line_number: if set, will delete all the commands older than the given line number + append_left_extra: adds extra entry (should be used together with safe_line_number) + """ + with self._mutex: + if safe_line_number is None: + if len(self._buffer) != 0: + self._buffer.popleft() + else: + while True: + # Remove the numbers lower than the specified safe_line_number (used in the resend line command: lines older than the one required can be deleted safely) + if len(self._buffer) != 0: + line_number = self._buffer.popleft() + if line_number >= safe_line_number: + self._buffer.appendleft(line_number) + break + if append_left_extra: + self._buffer.appendleft(safe_line_number - 1) + + self.check_buffer_mutex_status() + + def check_buffer_mutex_status(self): + """ + Check if the send lock must be released + """ + with self._mutex: + if self._send_mutex.locked() and (len(self._buffer) < self._buffer_max_length): + self._send_mutex.release() + + def popleft(self): + """Return and remove the last entry""" + return self._buffer.popleft() + + @property + def last(self): + """Return the last entry""" + if len(self._buffer) > 0: + return self._buffer[-1] + return None + + def __len__(self): + with self._mutex: + return len(self._buffer) diff --git a/server/hardware/device/firmwares/firmware_event_handler.py b/server/hardware/device/firmwares/firmware_event_handler.py new file mode 100644 index 00000000..f1798181 --- /dev/null +++ b/server/hardware/device/firmwares/firmware_event_handler.py @@ -0,0 +1,25 @@ +from abc import abstractmethod, ABC + + +class FirwmareEventHandler(ABC): + """ + Event handler for line sent and received from the serial device + """ + + @abstractmethod + def on_line_sent(self, line): + """ + Called when a new line has been sent to the serial device + """ + + @abstractmethod + def on_line_received(self, line): + """ + Called when a new line has been received to the serial device + """ + + @abstractmethod + def on_device_ready(self): + """ + Called when the connected device is ready + """ diff --git a/server/hardware/device/firmwares/generic_firmware.py b/server/hardware/device/firmwares/generic_firmware.py new file mode 100644 index 00000000..5eb9e3a0 --- /dev/null +++ b/server/hardware/device/firmwares/generic_firmware.py @@ -0,0 +1,358 @@ +import logging +import re + +from threading import RLock, Lock, Timer +from py_expression_eval import Parser + +from server.hardware.device.comunication.device_serial import DeviceSerial +from server.hardware.device.estimation.generic_estimator import GenericEstimator +from server.hardware.device.firmwares.commands_buffer import CommandBuffer +from server.hardware.device.firmwares.firmware_event_handler import FirwmareEventHandler +from server.utils import buffered_timeout, settings_utils + +# Defines the character used to define macros +MACRO_CHAR = "&" + +# List of commands that are buffered by the controller +BUFFERED_COMMANDS = ("G0", "G00", "G1", "G01", "G2", "G02", "G3", "G03", "G28", "G92") + + +class GenericFirmware: + """ + Abstract class for a firmware + + The implementer must handle the messages send to and received from the device + """ + + def __init__(self, serial_settings, logger, event_handler: FirwmareEventHandler): + """ + Args: + serial_settings: dict containing the serial settings + Must have: + serial_name: name of the serial port (like COM3 or tty/USB0) + baudrate: serial baudrate + logger: name of the logger to use for logging the communication + event_handler: event handler for line sent and line received + """ + + if serial_settings is None: + raise TypeError("The serial_device must not be None") + self._serial_settings = serial_settings + self._logger = logging.getLogger(logger) if not logger is None else logging.getLogger() + self.event_handler = event_handler + + self._serial_device = None + self._fast_mode = True # by default fast_mode is enabled + self.line_number = 0 + self._command_resolution = "{:.3f}" # by default will use 3 decimals in fast mode + self._mutex = RLock() + # this mutex is used to send messages with a priority with respect to the standard commands + # (like for the 'resend' command in marlin for which all the commands must be sent in a row) + self._priority_mutex = Lock() + self._is_ready = False # the device will not be ready at the beginning + self.estimator = GenericEstimator() + + # buffer control + self._buffer = CommandBuffer(2) + + # timeout setup + self.force_ack_command = "" # command used to force an ack + self.ack = "ok" # the ack string sent from the device + # timeout used to clear the buffer if some acks are lost + self._timeout_last_line = 0 + self._timeout = buffered_timeout.BufferTimeout(30, self._on_timeout) + self._timeout.start() + + # regex generation for the macro parser + 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 + + @property + def fast_mode(self): + """ + Returns: + True if the device is using fast mode (GCODE is sent without blanks) + """ + with self._mutex: + return self._fast_mode + + @fast_mode.setter + def fast_mode(self, mode): + """ + Set fast mode + + Args: + mode: True if fast mode must be used (blank spaces are removed from the gcode line) + """ + with self._mutex: + self._fast_mode = mode + + @property + def feedrate(self): + """ + Returns the current feedrate + """ + with self._mutex: + return self._feedrate + + @feedrate.setter + def feedrate(self, feedrate): + """ + Set the current feedrate + """ + with self._mutex: + self._feedrate = feedrate + + @property + def buffer(self): + """ + Returns: + the buffer of commands sent to the device + """ + with self._mutex: + return self._buffer + + @property + def is_ready(self): + """ + Returns: + True if the device can be used + False if the device has not been initialized correctly yet + """ + with self._mutex: + return self._is_ready + + @property + def is_connected(self): + """ + Returns: + True if the device is connected + """ + return self._serial_device.is_connected + + def get_current_position(self): + """ + Returns: + coords: current x and y position in a dict + """ + with self._mutex: + return self.estimator.position + + def send_gcode_command(self, command, hide_command=False): + """ + Send the command + + Args: + command [str]: the command to send + hide_command [bool]: True to hide the command from the list of sent commands in the UI + """ + command = self._prepare_command(command) + if command == "": + return + # wait until the lock for the buffer length is released + # if the lock is released means the board sent the ack for older lines and can send new ones + with self._buffer.get_buffer_wait_mutex(): + pass + # if the priority mutex is locked should wait before sending the command until it is unlocked + while self._priority_mutex.locked(): + ... + + # now can send the command + with self._mutex: + self._handle_send_command(command, hide_command) + self.estimator.parse_command(command) + self._update_timeout() # update the timeout because a new command has been sent + + def connect(self): + """ + Start the connection procedure with the serial device + """ + with self._mutex: + self._logger.info("Connecting to the serial device") + self._serial_device = DeviceSerial( + self._serial_settings["port"]["value"], + self._serial_settings["baud"]["value"], + self._logger.name, + ) + self._serial_device.set_on_readline_callback(self._on_readline) + self._serial_device.open() + # wait device ready + if not self._serial_device.is_connected: + # calling the "device ready" callback with a delay + timer = Timer(2, self._on_device_ready) + timer.daemon = True + timer.start() + + def close(self): + """ + Close the comunication with the device + """ + with self._mutex: + self._serial_device.close() + + def emergency_stop(self): + """ + Stop the device immediately + + This method must be implemented in the child class + """ + ... + + def reset_status(self): + """ + Method that should be called when a job is stopped or finished + + Allow to reset the current status or variables and have a clean start for the next job + """ + with self._mutex: + self.line_number = 0 + if self._priority_mutex.locked(): + self._priority_mutex.release() + + def _parse_macro(self, command): + """ + Parse a macro + + Macros are defined by the MACRO_CHAR. + The method substitute the formula in the macro with the correct value + """ + if not MACRO_CHAR in command: + return command + macros = self.macro_regex.findall(command) + for m in macros: + try: + # see https://pypi.org/project/py-expression-eval/ for more info about the parser + pos = self.estimator.get_last_commanded_position() + res = self.macro_parser.parse(m).evaluate( + {"X": pos.x, "Y": pos.y, "F": self.estimator.feedrate} + ) + command = command.replace(MACRO_CHAR + m + MACRO_CHAR, str(res)) + except Exception as exception: + # TODO handle this in a better way + self._logger.error("Error while parsing macro: " + m) + self._logger.error(exception) + return command + + def _prepare_command(self, command): + """ + Clean and prepare the current command to be sent to the device + + Args: + command: the gcode command to use + + Returns: + a string with the cleaned command + """ + with self._mutex: + command = command.replace("\n", "").replace("\r", "").upper() + return self._parse_macro(command) + + def _handle_send_command(self, command, hide_command=False): + """ + Send the gcode command to the device and handle the buffer + """ + with self._mutex: + + # send the command after parsing the content + # need to use the mutex here because it is changing also the line number + line = self._generate_line(command) + + self._serial_device.send(line) # send line + self._buffer.push_command(line, self.line_number) + self._logger.log(settings_utils.LINE_SENT, line.replace("\n", "")) + + if not hide_command: + self.event_handler.on_line_sent(line) # uses the handler callback for the new line + + def _generate_line(self, command, n=None): + """ + Handles the line before sending it + """ + with self._mutex: + line = command + # if is using fast mode need to reduce numbers resolution and remove spaces + + if self.fast_mode: + line = command.split(" ") + new_line = [] + for l in line: + if l.startswith("X"): + l = "X" + self._command_resolution.format(float(l[1:])).rstrip("0").rstrip( + "." + ) + elif l.startswith("Y"): + l = "Y" + self._command_resolution.format(float(l[1:])).rstrip("0").rstrip( + "." + ) + new_line.append(l) + line = "".join(new_line) + line += "\n" + + self.line_number += 1 + return line + + def _on_timeout(self): + """ + Callback for when the timeout is expired + """ + with self._mutex: + if ( + self._buffer.get_buffer_wait_mutex().locked + and self.line_number == self._timeout_last_line + ): + # self.logger.warning("!Buffer timeout. Trying to clean the buffer!") + # to clean the buffer try to send a buffer update message. In this way will trigger the buffer cleaning mechanism + command = self.force_ack_command + line = self._generate_line(command) + self._logger.log(settings_utils.LINE_SERVICE, line) + self._serial_device.send(line) + else: + self._update_timeout() + + def _update_timeout(self): + """ + Update the timeout object in such a way that the interval is restored + """ + self._timeout_last_line = self.line_number + self._timeout.update() + + def _on_readline(self, line): + """ + Parse a received line from the hw device + + Returns: + True if the readline has done correctly + """ + if line is None: + return False + if self.ack in line: + self._buffer.ack_received() + return True + + def _on_device_ready(self): + """ + Called when the connected device is ready to receive commands + """ + with self._mutex: + self.event_handler.on_device_ready() + self._is_ready = True + + def _log_received_line(self, line, hide_line=False): + """ + Log the line received from the device + Not called automatically in the _on_readline + + Args: + line: the line received from the device + hide_line: if True will not send the line to the frontend + """ + line = line.rstrip("\r").rstrip("\n") + self._logger.log(settings_utils.LINE_RECEIVED, line) + if not hide_line: + self.event_handler.on_line_received(line) + + def __str__(self) -> str: + return f"Device:\n - firmware type: {type(self).__name__}\n - fast mode: {self.fast_mode}\n - {self.estimator}" diff --git a/server/hardware/device/firmwares/grbl.py b/server/hardware/device/firmwares/grbl.py new file mode 100644 index 00000000..cddf9da2 --- /dev/null +++ b/server/hardware/device/firmwares/grbl.py @@ -0,0 +1,88 @@ +from server.hardware.device.firmwares.firmware_event_handler import FirwmareEventHandler +from server.hardware.device.firmwares.generic_firmware import GenericFirmware +from server.utils import settings_utils + + +class Grbl(GenericFirmware): + """ + Handle the comunication with devices running Grbl + """ + + def __init__(self, serial_settings, logger, event_handler: FirwmareEventHandler): + super().__init__(serial_settings, logger, event_handler) + self._logger.info("Setting up coms with a Grbl device") + # command used to update the buffer status or get a free ack + self.force_ack_command = "?" + + def emergency_stop(self): + """ + Stop the device immediately + """ + self.send_gcode_command("!") + + def _on_readline(self, line): + """ + Parse the line received from the device + + Args: + line: line to be parsed, received from the device usually + + Returns: + True if the line is handled correctly + """ + with self._mutex: + # if the line is not valid will return False + if not super()._on_readline(line): + return False + + hide_line = False + if line.startswith("<"): + try: + # interested in the "Bf:xx," part where xx is the content of the buffer + # select buffer content lines + res = line.split("Bf:")[1] + res = int(res.split(",")[0]) + if ( + res == 15 + ): # 15 => buffer is empty on the device (should include also 14 to make it more flexible?) + self.buffer.clear() + if res != 0: # 0 -> buffer is full + if len(self.buffer) > 0: + self.buffer.popleft() + hide_line = True + self.buffer.check_buffer_mutex_status() + + self._logger.log(settings_utils.LINE_SERVICE, line) + except: # sometimes may not receive the entire line thus it may throw an error + pass # FIXME + return False + # if the device is connected and ready will send a "Grbl" line + elif "Grbl" in line: + self._on_device_ready() + + # errors + elif "error:22" in line: + self.buffer.clear() + self._logger.error("Grbl error: {}".format(line)) + elif "error:" in line: + self._logger.error("Grbl error: {}".format(line)) + # TODO check/parse error types and give some hint about the problem? + + self._log_received_line(line, hide_line) + return True + + def _on_device_ready(self): + """ + Run some commands when the device is ready + """ + # grbl status report mask setup + # sandypi need to check the buffer to see if the machine has cleaned the buffer + # setup grbl to show the buffer status with the $10 command + # Grbl 1.1 https://github.com/gnea/grbl/wiki/Grbl-v1.1-Configuration + # Grbl 0.9 https://github.com/grbl/grbl/wiki/Configuring-Grbl-v0.9 + # to be compatible with both will send $10=6 (4(for v0.9) + 2(for v1.1)) + # the status will then be prompted with the "?" command when necessary + # the buffer will contain Bf:"usage of the buffer" + with self._mutex: + self.send_gcode_command("$10=6") + super()._on_device_ready() diff --git a/server/hardware/device/firmwares/marlin.py b/server/hardware/device/firmwares/marlin.py new file mode 100644 index 00000000..5bef66eb --- /dev/null +++ b/server/hardware/device/firmwares/marlin.py @@ -0,0 +1,234 @@ +from copy import deepcopy +from threading import Timer, Lock + +from server.hardware.device.firmwares.firmware_event_handler import FirwmareEventHandler +from server.hardware.device.firmwares.generic_firmware import GenericFirmware + + +class Marlin(GenericFirmware): + """ + Handle the comunication with devices running Marlin + """ + + def __init__(self, serial_settings, logger, event_handler: FirwmareEventHandler): + super().__init__(serial_settings, logger, event_handler) + self._logger.info("Setting up coms with a Marlin device") + # marlin specific values + self._command_resolution = "{:.1f}" + # command used to update the buffer status or get a free ack + self.force_ack_command = "M114" + # tolerance position (needed because the marlin rounding for the actual position is not the usual rounding) + self.position_tolerance = 0.01 + # this variable is used to keep track if is already resending commands or not + self._resending_commands = Lock() + + def emergency_stop(self): + """ + Stop the device immediately + """ + self.send_gcode_command("M112") + + def reset_status(self): + """ + To be called when a job is stopped/finished + + With Marlin, will also reset the line number + + """ + super().reset_status() + self._reset_line_number() + + def _on_readline(self, line): + """ + Parse the line received from the device + + Args: + line: line to be parse, received from the device usually + + Returns: + True if the line is handled correctly + """ + # cannot use the mutex here because the callback is used inside the serial which is also used to read back the data + # if the mutex is used, the send command will block the readline command which is prioritized + + # if the line is not valid will return False + if not super()._on_readline(line): + return False + + hide_line = False + + # Parsing the received command + # Resend + # This is used both when the checksum mismatch and also when the line number is wrong + if "Resend:" in line: + line_number = int(line.replace("\r", "").replace("\n", "").split(" ")[-1]) + hide_line = self._resend(line_number) + + # M114 response contains the "Count" word + # 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: + hide_line = self._count(line) + + # the device send a "start" line when ready + elif "start" in line: + # adding delay otherwise there is a collision most of the time (n seconds) + timer = Timer(2, self._on_device_ready) + timer.daemon = True + timer.start() + + # unknow command + elif "echo:Unknown command:" in line: + self._logger.error("Error: command not found. Can also be a communication error") + # resend the last command sent + self._serial_device.send(self.buffer.last) + + # TODO check feedrate response for M220 and set feedrate + # elif "_______" in line: # must see the real output from marlin + # self.feedrate = .... # must see the real output from marlin + + self._log_received_line(line, hide_line) + return True + + def _count(self, line): + """ + Synchronize the estimated position with the real buffer position + + Args: + line [str]: the line received from the device which should be using the correct format + + Returns: + True if the line must be hidden in the printout of received commands + """ + 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) + + commanded_position = self.estimator.get_last_commanded_position() + # 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(commanded_position.x) - x) < self.position_tolerance) and ( + abs(float(commanded_position.y) - y) < self.position_tolerance + ): + if not self.buffer.is_empty(): + self.buffer.ack_received() + else: + self.buffer.clear() + self.buffer.check_buffer_mutex_status() + + return not self.buffer.is_empty() + + def _resend(self, line_number): + """ + Handle a resend command + + Args: + line [str]: the line received with the error (should contain the line number to send back) + + Returns: + True if the received line should be hidden in the printout of received commands + """ + line_found = False + # need to resend the commands outside the mutex of the feeder -> store the commands in a list and use a different thread to send them + if not self._priority_mutex.locked(): + self._priority_mutex.acquire() + self._logger.info(f"Line not received correctly. Resending from N{line_number}") + items = deepcopy(self.buffer._buffer_history) + first_available_line = None + + self.buffer.clear() + + # resend the commands + for command_n, command in items.items(): + n_line_number = int(command_n.strip("N")) + if n_line_number == line_number: + line_found = True + if n_line_number >= line_number: + if first_available_line is None: + first_available_line = line_number + # All the lines after the required one must be resent. Cannot break the loop now + self._serial_device.send(command) + self.buffer.push_command(command, n_line_number) + + # clear the serial queue from other "resend callbacks" + self._serial_device.filter_callbacks_queue( + lambda x: not ("Resend:" in x or "Error:Line Number" in x) + ) + + if not line_found: + self._logger.error("No line was found for the number required. Restart numeration.") + # will reset the buffer and restart the numeration + self.reset_status() + + self._priority_mutex.release() + + # if (not line_found) and not (first_available_line is None): + # for i in range(line_number, first_available_line): + # self._serial_device.send(self._generate_line(self.force_ack_command, n=i)) + + return False + + def _generate_line(self, command, n=None): + """ + Clean the command, substitute the macro values and add checksum + + Args: + command: command to be generated + n: line number to use + + Returns: + the generated line with the checksum + """ + line = super()._generate_line(command) + line = line.replace("\n", "") + + # check if the command contain a "reset line number" (M110) + if "M110" in command: + cs = command.split(" ") + for c in cs: + if c[0] == "N": + self.line_number = int(c[1:]) - 1 + self.buffer.clear() + # to set the line number just need "Nn M110" + # the correct N value will be added automatically during the command creation + line = "M110" + + # add checksum + if n is None: + n = self.line_number + if self.fast_mode: + line = f"N{n}{line}" + else: + line = f"N{n} {line} " + # calculate marlin checksum according to the wiki + cs = 0 + for i in line: + cs = cs ^ ord(i) + cs &= 0xFF + + line += f"*{cs}\n" # add checksum to the line + return line + + def _on_device_ready(self): + """ + Run some commands when the device is ready + """ + with self._mutex: + self._reset_line_number() + super()._on_device_ready() + + def _reset_line_number(self, line_number=3): + """ + Send a gcode command to reset the line numbering on the device + + Args: + line_number (optional): the line number that should start counting from + """ + with self._mutex: + self._logger.info("Clearing buffer and resetting line number") + self.buffer.clear() + self.send_gcode_command(f"M110 N{line_number}") diff --git a/server/hw_controller/gcode_rescalers.py b/server/hardware/device/gcode_rescalers.py similarity index 100% rename from server/hw_controller/gcode_rescalers.py rename to server/hardware/device/gcode_rescalers.py diff --git a/server/hw_controller/feeder_event_manager.py b/server/hardware/feeder_event_manager.py similarity index 81% rename from server/hw_controller/feeder_event_manager.py rename to server/hardware/feeder_event_manager.py index 6a7bd4ab..cdc1e447 100644 --- a/server/hw_controller/feeder_event_manager.py +++ b/server/hardware/feeder_event_manager.py @@ -1,8 +1,12 @@ -from server.database.playlist_elements import DrawingElement -from server.hw_controller.feeder import FeederEventHandler +from server.hardware.device.feeder_event_handler import FeederEventHandler import time + class FeederEventManager(FeederEventHandler): + """ + Handle the events from the feeder + """ + def __init__(self, app): super().__init__() self.app = app @@ -13,19 +17,21 @@ def on_element_ended(self, element): self.app.logger.info("Drawing ended") self.app.semits.show_toast_on_UI("Element ended") self.app.qmanager.set_element_ended() - self.app.smanager.drawing_ended(element.get_path_lenght_done()) # using path_lenght_done to take into account also the "stop drawing" cases + self.app.smanager.drawing_ended( + element.get_path_lenght_done() + ) # using path_lenght_done to take into account also the "stop drawing" cases if self.app.qmanager.is_queue_empty(): self.app.qmanager.send_queue_status() def on_element_started(self, element): - self.app.qmanager.set_element(element) + self.app.qmanager.element = element self.app.smanager.drawing_started() 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 self.last_send_time = time.time() - + def on_message_received(self, line): # Send the line to the server self.app.semits.hw_command_line_message(line) @@ -43,4 +49,4 @@ def on_new_line(self, line): def on_device_ready(self): self.app.qmanager.check_autostart() - self.app.qmanager.send_queue_status() \ No newline at end of file + self.app.qmanager.send_queue_status() diff --git a/server/hw_controller/leds/__init__.py b/server/hardware/leds/__init__.py similarity index 100% rename from server/hw_controller/leds/__init__.py rename to server/hardware/leds/__init__.py diff --git a/server/hw_controller/leds/leds_animators/__init__.py b/server/hardware/leds/leds_animators/__init__.py similarity index 100% rename from server/hw_controller/leds/leds_animators/__init__.py rename to server/hardware/leds/leds_animators/__init__.py diff --git a/server/hw_controller/leds/leds_controller.py b/server/hardware/leds/leds_controller.py similarity index 82% rename from server/hw_controller/leds/leds_controller.py rename to server/hardware/leds/leds_controller.py index 092c82e1..955e7af4 100644 --- a/server/hw_controller/leds/leds_controller.py +++ b/server/hardware/leds/leds_controller.py @@ -4,11 +4,12 @@ from server.utils import settings_utils -from server.hw_controller.leds.leds_types.dimmable import Dimmable -from server.hw_controller.leds.leds_types.RGB_neopixels import RGBNeopixels -from server.hw_controller.leds.leds_types.RGBW_neopixels import RGBWNeopixels -from server.hw_controller.leds.leds_types.WWA_neopixels import WWANeopixels -from server.hw_controller.leds.light_sensors.tsl2591 import TSL2591 +from server.hardware.leds.leds_types.dimmable import Dimmable +from server.hardware.leds.leds_types.RGB_neopixels import RGBNeopixels +from server.hardware.leds.leds_types.RGBW_neopixels import RGBWNeopixels +from server.hardware.leds.leds_types.WWA_neopixels import WWANeopixels +from server.hardware.leds.light_sensors.tsl2591 import TSL2591 + class LedsController: def __init__(self, app): @@ -19,7 +20,7 @@ def __init__(self, app): self._mutex = Lock() self._should_update = False self._running = False - self._color = (0,0,0,0) + self._color = (0, 0, 0, 0) self._brightness = 0 self._just_turned_on = True self.update_settings(settings_utils.load_settings()) @@ -30,16 +31,16 @@ def is_available(self): def has_light_sensor(self): if not self.sensor is None: - return self.sensor.is_connected() + return self.sensor.is_connected return False def start(self): if not self.driver is None: self._running = True - self._th = Thread(target = self._thf, daemon=True) + self._th = Thread(target=self._thf, daemon=True) self._th.name = "leds_controller" self._th.start() - + def stop(self): if not self.driver is None: with self._mutex: @@ -53,7 +54,7 @@ def decrease_brightness(self): def increase_brightness(self): if not self.driver is None: self.driver.increase_brightness() - + def fill(self, color): if not self.driver is None: self.driver.fill(color) @@ -63,11 +64,11 @@ def reset_lights(self): self.set_brightness(0) self.driver.fill_white() self._just_turned_on = True - + def _thf(self): self.app.logger.info("Leds controller started") try: - while(True): + while True: with self._mutex: if self._should_update: self.driver.fill(self._color) @@ -88,7 +89,7 @@ def set_color(self, color): g = int(color[3:5], 16) b = int(color[5:7], 16) w = 0 - if len(color)>7: + if len(color) > 7: w = int(color[7:9], 16) with self._mutex: self._color = (r, g, b, w) @@ -121,7 +122,11 @@ def update_settings(self, settings): self.stop() restart = True settings = DotMap(settings_utils.get_only_values(settings)) - dims = (int(settings.leds.width), int(settings.leds.height), int(settings.leds.circumference)) + dims = ( + int(settings.leds.width), + int(settings.leds.height), + int(settings.leds.circumference), + ) if self.dimensions != dims: self.dimensions = dims self.leds_type = None @@ -130,9 +135,13 @@ def update_settings(self, settings): self.pin = settings.leds.pin1 self.leds_type = settings.leds.type try: - # the leds number calculation depends on the type of table. + # the leds number calculation depends on the type of table. # If is square or rectangular should use a base and height, for round tables will use the total number of leds directly - leds_number = (int(self.dimensions[0]) + int(self.dimensions[1]))*2 if settings.device.type == "Cartesian" else int(self.dimensions[2]) + leds_number = ( + (int(self.dimensions[0]) + int(self.dimensions[1])) * 2 + if settings.device.type == "Cartesian" + else int(self.dimensions[2]) + ) leds_class = Dimmable if self.leds_type == "RGB": leds_class = RGBNeopixels @@ -140,14 +149,15 @@ def update_settings(self, settings): leds_class = RGBWNeopixels elif self.leds_type == "WWA": leds_class = WWANeopixels - + self.driver = leds_class(leds_number, settings.leds.pin1, logger=self.app.logger) - except Exception as e: + except Exception as e: self.driver = None self.app.semits.show_toast_on_UI("Led driver type not compatible with current HW") self.app.logger.exception(e) self.app.logger.error("Cannot initialize leds controller") - try: + return + try: if settings.leds.light_sensor == "TSL2591": self.sensor = TSL2591(self.app) else: @@ -155,11 +165,12 @@ def update_settings(self, settings): self.sensor.deinit() except Exception as e: if self.is_available(): - self.app.semits.show_toast_on_UI("The select sensor is not compatible with the current setup") + self.app.semits.show_toast_on_UI( + "The select sensor is not compatible with the current setup" + ) self.app.logger.error("Cannot initialize leds light sensor") self.app.logger.exception(e) if restart: self.start() self.reset_lights() - \ No newline at end of file diff --git a/server/hw_controller/leds/leds_types/RGBW_neopixels.py b/server/hardware/leds/leds_types/RGBW_neopixels.py similarity index 64% rename from server/hw_controller/leds/leds_types/RGBW_neopixels.py rename to server/hardware/leds/leds_types/RGBW_neopixels.py index 8ccf8d27..d5b6e19a 100644 --- a/server/hw_controller/leds/leds_types/RGBW_neopixels.py +++ b/server/hardware/leds/leds_types/RGBW_neopixels.py @@ -1,17 +1,18 @@ -from server.hw_controller.leds.leds_types.generic_LED_driver import GenericLedDriver +from server.hardware.leds.leds_types.generic_LED_driver import GenericLedDriver + class RGBWNeopixels(GenericLedDriver): def __init__(self, leds_number, bcm_pin, *argvs, **kargvs): kargvs["colors"] = 4 super().__init__(leds_number, bcm_pin, *argvs, **kargvs) - + def fill(self, color): - self._original_colors[:] = [color]*self.leds_number + self._original_colors[:] = [color] * self.leds_number self.pixels.fill(self._normalize_color(color)) - + def fill_white(self): - self.fill((0,0,0,255)) - + self.fill((0, 0, 0, 255)) + # abstract methods overwrites def deinit(self): @@ -23,7 +24,10 @@ def init_pixels(self): import board import neopixel from adafruit_blinka.microcontroller.bcm283x.pin import Pin - self.pixels = neopixel.NeoPixel(Pin(self.pin), self.leds_number, pixel_order = neopixel.GRBW) + + self.pixels = neopixel.NeoPixel( + Pin(self.pin), self.leds_number, pixel_order=neopixel.GRBW + ) # turn off all leds self.clear() except: @@ -32,11 +36,12 @@ def init_pixels(self): if __name__ == "__main__": from time import sleep - leds = RGBWNeopixels(5,18) - leds.fill((100,0,0,0)) - leds[0] = (0,10,0,0) - leds[1] = (0,0,10,0) - leds[2] = (0,0,0,10) + + leds = RGBWNeopixels(5, 18) + leds.fill((100, 0, 0, 0)) + leds[0] = (0, 10, 0, 0) + leds[1] = (0, 0, 10, 0) + leds[2] = (0, 0, 0, 10) sleep(2) leds.deinit() diff --git a/server/hw_controller/leds/leds_types/RGB_neopixels.py b/server/hardware/leds/leds_types/RGB_neopixels.py similarity index 72% rename from server/hw_controller/leds/leds_types/RGB_neopixels.py rename to server/hardware/leds/leds_types/RGB_neopixels.py index 686318b0..83dfdbd1 100644 --- a/server/hw_controller/leds/leds_types/RGB_neopixels.py +++ b/server/hardware/leds/leds_types/RGB_neopixels.py @@ -1,14 +1,15 @@ -from server.hw_controller.leds.leds_types.generic_LED_driver import GenericLedDriver +from server.hardware.leds.leds_types.generic_LED_driver import GenericLedDriver + class RGBNeopixels(GenericLedDriver): def __init__(self, leds_number, bcm_pin, *argvs, **kargvs): kargvs["colors"] = 3 - super().__init__(leds_number, bcm_pin, *argvs, **kargvs) + super().__init__(leds_number, bcm_pin, *argvs, **kargvs) def fill(self, color): - self._original_colors[:] = [color]*self.leds_number + self._original_colors[:] = [color] * self.leds_number self.pixels.fill(self._normalize_color(color)) - + # abstract methods overwrite def deinit(self): @@ -20,20 +21,22 @@ def init_pixels(self): import board import neopixel from adafruit_blinka.microcontroller.bcm283x.pin import Pin + self.pixels = neopixel.NeoPixel(Pin(self.pin), self.leds_number) # turn off all leds self.clear() except: - raise ModuleNotFoundError("Cannot find the libraries to control the selected hardware") + raise ModuleNotFoundError("Cannot find the libraries to control the selected hardware") if __name__ == "__main__": from time import sleep - leds = RGBNeopixels(5,18) - leds.fill((100,0,0)) - leds[0] = (10,0,0) - leds[1] = (0,10,0) - leds[2] = (0,0,10) + + leds = RGBNeopixels(5, 18) + leds.fill((100, 0, 0)) + leds[0] = (10, 0, 0) + leds[1] = (0, 10, 0) + leds[2] = (0, 0, 10) sleep(2) leds.deinit() diff --git a/server/hardware/leds/leds_types/WWA_neopixels.py b/server/hardware/leds/leds_types/WWA_neopixels.py new file mode 100644 index 00000000..ba6e5665 --- /dev/null +++ b/server/hardware/leds/leds_types/WWA_neopixels.py @@ -0,0 +1,19 @@ +from server.hardware.leds.leds_types.generic_LED_driver import GenericLedDriver +from server.hardware.leds.leds_types.RGB_neopixels import RGBNeopixels + +# WWA leds are RGB leds with different colors (R -> amber, G -> cold white, B -> warm white) +class WWANeopixels(RGBNeopixels): + pass + + +if __name__ == "__main__": + from time import sleep + + leds = WWANeopixels(5, 18) + leds.fill((100, 0, 0)) + leds[0] = (10, 0, 0) + leds[1] = (0, 10, 0) + leds[2] = (0, 0, 10) + sleep(2) + + leds.deinit() diff --git a/server/hw_controller/leds/leds_types/__init__.py b/server/hardware/leds/leds_types/__init__.py similarity index 100% rename from server/hw_controller/leds/leds_types/__init__.py rename to server/hardware/leds/leds_types/__init__.py diff --git a/server/hw_controller/leds/leds_types/dimmable.py b/server/hardware/leds/leds_types/dimmable.py similarity index 63% rename from server/hw_controller/leds/leds_types/dimmable.py rename to server/hardware/leds/leds_types/dimmable.py index 0a685d5f..cd02f70f 100644 --- a/server/hw_controller/leds/leds_types/dimmable.py +++ b/server/hardware/leds/leds_types/dimmable.py @@ -1,22 +1,23 @@ from statistics import mean -from server.hw_controller.leds.leds_types.generic_LED_driver import GenericLedDriver +from server.hardware.leds.leds_types.generic_LED_driver import GenericLedDriver + class Dimmable(GenericLedDriver): def __init__(self, leds_number, bcm_pin, *argvs, **kargvs): super().__init__(leds_number, bcm_pin, colors=1, *argvs, **kargvs) - + def fill(self, color): - self._original_colors[:] = [color]*self.leds_number - val = int(mean(color)/2.55) # (mean/255)*100 + self._original_colors[:] = [color] * self.leds_number + val = int(mean(color) / 2.55) # (mean/255)*100 self.pwm.ChangeDutyCycle(val) self.pixels[:] = color - + def __setitem__(self, key, color): - val = int(mean(color)/2.55) # (mean/255)*100 + val = int(mean(color) / 2.55) # (mean/255)*100 self.pwm.ChangeDutyCycle(val) self.pixels[key] = color - self._original_colors[:] = [color]*self.leds_number - + self._original_colors[:] = [color] * self.leds_number + # abstract methods overrides def deinit(self): @@ -25,9 +26,10 @@ def deinit(self): def init_pixels(self): try: import RPi.GPIO as GPIO + GPIO.setmode(GPIO.BCM) - GPIO.setup(self.pin, GPIO.OUT) + GPIO.setup(self.pin, GPIO.OUT) self.pwm = GPIO.PWM(self.pin, 100) self.pwm.start(0) except (RuntimeError, ModuleNotFoundError) as e: - raise \ No newline at end of file + raise diff --git a/server/hw_controller/leds/leds_types/generic_LED_driver.py b/server/hardware/leds/leds_types/generic_LED_driver.py similarity index 100% rename from server/hw_controller/leds/leds_types/generic_LED_driver.py rename to server/hardware/leds/leds_types/generic_LED_driver.py diff --git a/server/hw_controller/leds/light_sensors/__init__.py b/server/hardware/leds/light_sensors/__init__.py similarity index 100% rename from server/hw_controller/leds/light_sensors/__init__.py rename to server/hardware/leds/light_sensors/__init__.py diff --git a/server/hw_controller/leds/light_sensors/generic_light_sensor.py b/server/hardware/leds/light_sensors/generic_light_sensor.py similarity index 70% rename from server/hw_controller/leds/light_sensors/generic_light_sensor.py rename to server/hardware/leds/light_sensors/generic_light_sensor.py index 19dfa2d5..4be5902e 100644 --- a/server/hw_controller/leds/light_sensors/generic_light_sensor.py +++ b/server/hardware/leds/light_sensors/generic_light_sensor.py @@ -2,27 +2,27 @@ from threading import Thread from time import sleep -BRIGHTNESS_MOV_AVE_SAMPLES = 20 # number of samples used in the moving average for the brightness (response time [s] ~ samples_number*sample_interval) -BRIGHTNESS_SAMPLE_INTERVAL = 0.5 # period in s for the brightness sampling with the sensor +BRIGHTNESS_MOV_AVE_SAMPLES = 20 # number of samples used in the moving average for the brightness (response time [s] ~ samples_number*sample_interval) +BRIGHTNESS_SAMPLE_INTERVAL = 0.5 # period in s for the brightness sampling with the sensor -class GenericLightSensor(ABC): +class GenericLightSensor(ABC): def __init__(self, app): self.app = app self._is_running = False self._check_interval = BRIGHTNESS_SAMPLE_INTERVAL self._history = [] - + def start(self): """Starts the light sensor - + When the light sensor is started, will control the brightness of the LEDs automatically. Will change it according to the last given color (can only dim)""" self._is_running = True - self._th = Thread(target = self._thf) + self._th = Thread(target=self._thf, daemon=True) self._th.name = "light_sensor" self._th.start() - + def stop(self): """Stops the light sensor from controlling the LED strip""" self._is_running = False @@ -36,12 +36,11 @@ def _thf(self): if len(self._history) == BRIGHTNESS_MOV_AVE_SAMPLES: self._history.pop(0) self._history.append(brightness) - brightness = sum(self._history)/float(len(self._history)) + brightness = sum(self._history) / float(len(self._history)) - self.app.logger.info("Averaged brightness: {}".format(brightness)) # FIXME remove this self.app.lmanager.set_brightness(brightness) self.app.lmanager.set_brightness(1) - + def deinit(self): """Deinitializes the sensor hw""" @@ -51,6 +50,7 @@ def deinit(self): def get_brightness(self): """Returns the actual level of brightness to use""" + @property @abstractmethod def is_connected(self): - """Returns true if the sensor is connected correctly""" \ No newline at end of file + """Returns true if the sensor is connected correctly""" diff --git a/server/hw_controller/leds/light_sensors/tsl2591.py b/server/hardware/leds/light_sensors/tsl2591.py similarity index 57% rename from server/hw_controller/leds/light_sensors/tsl2591.py rename to server/hardware/leds/light_sensors/tsl2591.py index 4bd6fb11..31bc1393 100644 --- a/server/hw_controller/leds/light_sensors/tsl2591.py +++ b/server/hardware/leds/light_sensors/tsl2591.py @@ -1,17 +1,19 @@ -from server.hw_controller.leds.light_sensors.generic_light_sensor import GenericLightSensor +from server.hardware.leds.light_sensors.generic_light_sensor import GenericLightSensor from math import sqrt LUX_MAX = 30 BRIGHTNESS_MIN = 0.05 + class TSL2591(GenericLightSensor): """Light sensor based on TSL2519 (I2C) sensor""" def __init__(self, app): super().__init__(app) - try: + try: import board import adafruit_tsl2591 + i2c = board.I2C() self._sensor = adafruit_tsl2591.TSL2591(i2c) except: @@ -19,10 +21,11 @@ def __init__(self, app): def get_brightness(self): lux = self._sensor.lux - tmp = max(sqrt(min(lux, LUX_MAX)/LUX_MAX), BRIGHTNESS_MIN) # calculating the brightness to use - self.app.logger.info("Sensor light intensity: {} lux".format(lux)) # FIXME remove this - self.app.logger.info("Sensor current brightness: {}".format(tmp)) # FIXME remove this - return tmp + tmp = max( + sqrt(min(lux, LUX_MAX) / LUX_MAX), BRIGHTNESS_MIN + ) # calculating the brightness to use + return tmp + @property def is_connected(self): return not self._sensor is None diff --git a/server/hardware/queue_manager.py b/server/hardware/queue_manager.py new file mode 100644 index 00000000..a2a33aae --- /dev/null +++ b/server/hardware/queue_manager.py @@ -0,0 +1,488 @@ +from queue import Queue +from json import dumps +from threading import RLock, Thread +import time +from random import randrange +from dotmap import DotMap + +from server.utils import settings_utils +from server.database.playlist_elements import ShuffleElement, TimeElement + +TIME_CONVERSION_FACTOR = 60 * 60 # hours to seconds +QUEUE_UPDATE_INTERVAL = 30 # send the updated queue status every # seconds + + +class QueueStatusUpdater(Thread): + """ + Keep updating the queue status every given seconds + """ + + def __init__( + self, timeout, queue_manager, group=None, target=None, name=None, args=(), kwargs=None + ): + """ + Args: + timeout: the interval between the calls + queue_manager: the queue manager of which the status should be sent + """ + super(QueueStatusUpdater, self).__init__(group=group, target=target, name=name) + self.name = "queue_status_updater" + self.timeout = timeout + self.queue_manager = queue_manager + self.setDaemon(True) + + def run(self): + while True: + # updates the queue status every 30 seconds but only while is drawing + time.sleep(self.timeout) + if self.queue_manager.is_drawing(): + self.queue_manager.send_queue_status() + + +class QueueManager: + """ + This class manages the queue of elements that must be used + Can be filled one element at a time or by a playlist + """ + + def __init__(self, app, socketio): + """ + Args: + * app + * socketio istance + """ + # uses RLock to allow recursive call of functions + self.app = app + self.socketio = socketio + self._mutex = RLock() + + self.q = Queue() + self._element = None + # timestamp of the end of the last drawing + # used to understand if can start a new drawing or should put a delay element in between in the case an interval is choosen + self._last_time = 0 + # _is_force_stop is used to understand if the drawing_ended event was called because the drawing is ended or because of the stop button was used + self._is_force_stop = False + # play_random is "True" if the device was started with a "start a random drawing" commands + self._started_as_play_random_drawing = False + + # queue controls status + self._controls = DotMap() + self._controls._repeat = False # true -> doesn't delete the current element from the queue + self._controls._shuffle = False # true -> shuffle the queue + self._controls._interval = 0.0 # pause between drawing in repeat mode + + # status timer setup (keep updating the queue status every given seconds) + self._updater = QueueStatusUpdater(QUEUE_UPDATE_INTERVAL, self) + self._updater.start() + + @property + def element(self): + """ + Returns: + the current element being drawn + """ + with self._mutex: + return self._element + + @element.setter + def element(self, element): + """ + Set the current queue element + + Args: + el: the element to use + """ + with self._mutex: + self.app.logger.info("Now running: {}".format(element)) + self._element = element + + @property + def repeat(self): + """ + The repeat option is used to keep a drawing in the queue. + If the value is False, at the end of the drawing the element is removed from the feeder queue. + If the value is True, the current drawing is put at the end of the queue + + Returns: + True if the repeat option is enabled, False otherwise + """ + with self._mutex: + return self._controls._repeat + + @repeat.setter + def repeat(self, val): + """ + Set the "repeat" value + + Args: + val: True if must keep the current drawing in the queue after is finished + + Raises: + ValueError: the argument must be boolean + """ + with self._mutex: + if not type(val) == type(True): + raise ValueError("The argument must be boolean") + self._controls._repeat = val + if val and (len(self.q.queue) > 0) and self._started_as_play_random_drawing: + self._put_random_element_in_queue() + self.send_queue_status() + else: + if self._started_as_play_random_drawing: + self.clear_queue() + self.reset_random_queue() + + @property + def shuffle(self): + """ + The shuffle flag is used to play the elements in the queue in a random order + + Returns: + True if the elements in the queue are played in a random order + """ + with self._mutex: + return self._controls._shuffle + + @shuffle.setter + def shuffle(self, val): + """ + Args: + val: True to play the queue in a random order, False to follow the queue order + + Raises: + ValueError: the argument must be boolean + """ + with self._mutex: + if not (type(val) == type(True)): + raise ValueError("The argument must be boolean") + self._controls._shuffle = val + + @property + def interval(self): + """ + If the waiting time between drawings is different than 0, the queue manager will wait + the interval time before running the next element in the queue + Returns: + the waiting time between drawings. + """ + with self._mutex: + return self._controls._interval + + @interval.setter + def interval(self, interval): + """ + If an interval is set, a waiting time will be observed between elements + + Args: + interval: waiting time between drawings + """ + with self._mutex: + self._controls._interval = interval + + def is_queue_empty(self): + """ + Check if the queue is empty and the feeder is not running + + Returns: + True if the queue is empty and it is not drawing, False otherwise + """ + with self._mutex: + return not self.is_drawing() and len(self.q.queue) == 0 + + def is_paused(self): + """ + Check if the device is paused or not + + Returns: + True if the device is paused + """ + with self._mutex: + return self.app.feeder.status.paused + + def is_drawing(self): + """ + Check if there is a drawing being done + + Returns: + True if there is a drawing running in the feeder + """ + with self._mutex: + return self.app.feeder.status.running + + def pause(self): + """ + Pause the feeder + """ + with self._mutex: + self.app.feeder.pause() + self.send_queue_status() + self.app.logger.info("Drawing paused") + + def resume(self): + """ + Resume the feeder to the "drawing" status + """ + with self._mutex: + self.app.feeder.resume() + self.send_queue_status() + self.app.logger.info("Drawing resumed") + + def stop(self): + """ + Stop the current element + """ + with self._mutex: + self._started_as_play_random_drawing = False + self._is_force_stop = True + self.app.feeder.stop() + + def reset_random_queue(self): + """ + Stop the queue manager to keep on using random drawings + + This is necessary only when a manual change is done to the queue and the start command was given with the "play random drawing" button + """ + with self._mutex: + self._started_as_play_random_drawing = False + + def clear_queue(self): + """ + Clear the current queue + """ + with self._mutex: + self.q.queue.clear() + self.send_queue_status() + + def get_queue_len(self): + """ + Return the queue length + + Returns: + the queue length + """ + with self._mutex: + return self.q.qsize() + + def start_random_drawing(self, repeat=False): + """ + Start playing random drawings from the full uploaded list + Will work only if the queue is empty + + Args: + repeat: True if should keep drawing after the current drawing is finished + Will set/reset the repeat flag, can be changed from the UI with the "repeat" button + """ + with self._mutex: + if self.is_queue_empty(): + # keep track that we started with a random drawing request + self._started_as_play_random_drawing = True + self.shuffle = True + self.repeat = repeat + self.clear_queue() + self._put_random_element_in_queue() + self.start_next() + + def queue_element(self, element, show_toast=True): + """ + Add an element to the queue + If the queue is empty, directly start the drawing + + Args: + element: the element to add/start + show_toast: (default) True if should show on the UI a toast that the drawing has been added to the queue + """ + with self._mutex: + # if the queue is empty, instead of adding the element to the queue, will start it directly + if self.is_queue_empty(): + self._start_element(element) + return + self.app.logger.info("Adding {} to the queue".format(element)) + self.q.put(element) # adding the element to the queue + if show_toast: # emitting the socket only if the show_toast flag is True + self.app.semits.show_toast_on_UI("Element added to the queue") + # refresh the queue status + self.send_queue_status() + + def set_element_ended(self): + """ + Set the current element as ended and start the next element if the queue is not empty + """ + with self._mutex: + # 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: + # avoid setting the time and reset the flag + self._is_force_stop = False + else: + # the drawing was ended correctly (not stopped manually) and thus need to store the end time in case a delay element must be used + self._last_time = time.time() + # start the next element in the queue if necessary + self.start_next() + + def set_new_order(self, elements): + """ + Set the new queue order + + Args: + elements: list of elements with the correct order + """ + # Overwrite the queue completely thus first need to clear it completely + with self._mutex: + # avoid using the self.clear_queue in order not to send the status for nothing + self.q.queue.clear() + # fill the queue with the new elements + for el in elements: + if el != 0: + self.q.put(el) + # now can send back the new queue status + self.send_queue_status() + + def start_next(self, force_stop=False): + """ + Start the next drawing in the queue + + By default will start only if not already drawing something + + Args: + force_stop: if True, force the current drawing/element stop and start the next one + + """ + with self._mutex: + if self.is_drawing(): + if not force_stop: + # if should not force the stop will exit the function + return + 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() + return + + try: + # should not remove the element from the queue if repeat is active. Should just add it back at the end of the queue + # avoid putting back interval delay element thanks to the "_repeat_off" property added when the delay element is created + if ( + (not self._element is None) + and (self.repeat) + and (not hasattr(self._element, "_repeat_off")) + ): + # check if the last element was generated by a "shuffle element" + # in that case must use again a shuffle element instead of the same element + if hasattr(self._element, "was_shuffle"): + self._put_random_element_in_queue() + else: + self.q.put(self._element) + + self._element = None + # if the queue is empty should just exit because there is no next element to start + if self.get_queue_len() == 0: + return + + # if the interval value is set and different than 0 may need to put a delay in between drawings + if self.interval != 0: + # add the interval to the timestap of the last drawing end and check if it bigger than the current timestamp to check if need to insert a delay element + 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", + ) + # set a flag on the delay element to distinguish if it was created because there is a interval set + # the same flag is checked when the current element is check in order to add it back to the queue if the repeat flag is set + element._repeat_off = True + # if the element is a forced delay start it directly and exit the current method + self._start_element(element) + return + + next_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) + next_element = elements.pop(randrange(len(elements))) + elements.append(tmp) + self.set_new_order(elements) + else: + next_element = self.q.queue.popleft() + # start the choosen element + self._start_element(next_element) + self.app.logger.info("Starting next element: {}".format(next_element)) + + except RecursionError as exception: + self.app.logger.exception(exception) + return + except Exception as exception: + self.app.logger.exception(exception) + self.app.logger.error( + "An error occured while starting a new drawing from the queue:\n{}".format( + str(exception) + ) + ) + if self.get_queue_len() != 0: + self.start_next() + + def send_queue_status(self): + """ + Send the queue status to the frontend + """ + with self._mutex: + els = [i for i in self.q.queue if not i is None] + elements = ( + list(map(lambda x: str(x), els)) if len(els) > 0 else [] + ) # converts elements to json + res = { + "current_element": str(self._element), + "elements": elements, + "status": self.app.feeder.status.toDict(), + "repeat": self.repeat, + "shuffle": self.shuffle, + "interval": self.interval, + } + self.app.semits.emit("queue_status", dumps(res)) + + def _start_element(self, element): + """ + Start the given element + """ + with self._mutex: + # check if a new element must be generated from the given element (like for a shuffle element) + if not element is None: + element = element.before_start(self.app) + if not element is None: + self.app.logger.info("Sending gcode start command") + self.app.feeder.start_element(element) + else: + self.start_next() + + def _put_random_element_in_queue(self): + """ + Queue a new random element from the full list of drawings + """ + with self._mutex: + # add the element to the queue without calling "queue_element" because this method is called also inside the start_next drawing and will be called twice + self.q.put(ShuffleElement(shuffle_type="0")) + + # TODO move this method into an "startup manager" which will need to initialize queue manager and initial status + + def check_autostart(self): + """ + Checks if should start drawing after the server is started and ready (can be set in the settings page) + """ + with self._mutex: + autostart = settings_utils.get_only_values(settings_utils.load_settings()["autostart"]) + + if autostart["on_ready"]: + self.start_random_drawing(repeat=True) + self.repeat = True + + try: + if autostart["interval"]: + self.interval = float(autostart["interval"]) + except Exception as e: + self.app.logger.exception(e) diff --git a/server/hw_controller/device_serial.py b/server/hw_controller/device_serial.py deleted file mode 100644 index 8a1cd65f..00000000 --- a/server/hw_controller/device_serial.py +++ /dev/null @@ -1,136 +0,0 @@ -from enum import auto -from threading import Thread, Lock -import serial.tools.list_ports -import serial -import time -import traceback -import sys -import logging -from server.hw_controller.emulator import Emulator -import glob - -# This class connects to a serial device -# If the serial device request is not available it will create a virtual serial device - -class DeviceSerial(): - def __init__(self, serialname = None, baudrate = 115200, logger_name = None, autostart = False): - self.logger = logging.getLogger(logger_name) if not logger_name is None else logging.getLogger() - self.serialname = serialname - self.baudrate = baudrate - self.is_fake = False - self._buffer = bytearray() - self.echo = "" - self._emulator = Emulator() - - # opening serial - try: - args = dict( - baudrate = self.baudrate, - timeout = 0, - write_timeout = 0 - ) - self.serial = serial.Serial(**args) - self.serial.port = self.serialname - self.serial.open() - self.logger.info("Serial device connected") - except Exception as e: - self.logger.exception(e) - # TODO should add a check to see if the problem is that cannot use the Serial module because it is not installed correctly on raspberry - self.is_fake = True - self.logger.error("Serial not available. Are you sure the device is connected and is not in use by other softwares? (Will use the fake serial)") - - # empty callback function - def useless(arg): - pass - - # setting up the read thread - self._th = Thread(target=self._thf, daemon=True) - self._mutex = Lock() - self._th.name = "serial_read" - self._running = False - self.set_onreadline_callback(useless) - if autostart: - self.start_reading() - - - # starts the reading thread - def start_reading(self): - self._th.start() - - # this method is used to set a callback for the "new line available" event - def set_onreadline_callback(self, callback): - self._on_readline = callback - - # check if the reading thread is working - def is_running(self): - return self._running - - # stops the serial read thread - def stop(self): - self._running = False - - # sends a line to the device - def send(self, obj): - if self.is_fake: - self._emulator.send(obj) - else: - if self.serial.is_open: - try: - with self._mutex: - while self.serial.out_waiting: - pass # TODO should add a sort of timeout - self._readline() - self.serial.write(str(obj).encode()) - # TODO try to send byte by byte instead of a full line? (to reduce the risk of sending commands with missing digits or wrong values that may lead to a wrong position value) - except: - self.close() - self.logger.error("Error while sending a command") - - # return a list of available serial ports - def serial_port_list(self): - if sys.platform.startswith('win'): - plist = serial.tools.list_ports.comports() - ports = [port.device for port in plist] - elif sys.platform.startswith('linux') or sys.platform.startswith('cygwin'): - # this excludes your current terminal "/dev/tty" - ports = glob.glob('/dev/tty[A-Za-z]*') - else: - raise EnvironmentError('Unsupported platform') - return ports - - # check if is connected to a real device - def is_connected(self): - if(self.is_fake): - return False - return self.serial.is_open - - # close the connection with the serial device - def close(self): - self.stop() - try: - self.serial.close() - self.logger.info("Serial port closed") - except: - self.logger.error("Error: serial already closed or not available") - - # private functions - - # reads a line from the device - def _readline(self): - if not self.is_fake: - if self.serial.is_open: - while self.serial.in_waiting>0: - line = self.serial.readline() - return line.decode(encoding="UTF-8") - else: - return self._emulator.readline() - - # thread function - def _thf(self): - self._running = True - next_line = "" - while(self.is_running()): - with self._mutex: - next_line = self._readline() - # cannot use the callback inside the mutex otherwise may run into a deadlock with the mutex if the serial.send is called in the parsing method - self._on_readline(next_line) \ No newline at end of file diff --git a/server/hw_controller/feeder.py b/server/hw_controller/feeder.py deleted file mode 100644 index 1ed1e510..00000000 --- a/server/hw_controller/feeder.py +++ /dev/null @@ -1,688 +0,0 @@ -from threading import Thread, Lock -import os -import time -import traceback -from collections import deque -from copy import deepcopy -import re -import logging -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, 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 handle single commands as well as elements. - - -""" - - -class FeederEventHandler(): - # called when the drawing is finished - def on_element_ended(self, element): - pass - - # called when a new drawing is started - def on_element_started(self, element): - pass - - # called when the feeder receives a message from the hw that must be sent to the frontend - def on_message_received(self, line): - pass - - # called when a new line is sent through serial (real or fake) - def on_new_line(self, line): - pass - - def on_device_ready(self): - pass - - - -# List of commands that are buffered by the controller -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): - - # logger setup - self.logger = logging.getLogger(__name__) - self.logger.handlers = [] # remove all handlers - 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_SERVICE) # set to logger lowest level - - # create file logging handler - 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) - - # load sterr logging level from environment variables - load_dotenv() - level = os.getenv("FEEDER_LEVEL") - if not level is None: - level = int(level) - else: - level = 0 - - # create stream handler - stream_handler = logging.StreamHandler() - stream_handler.setLevel(level) - stream_handler.setFormatter(formatter) - self.logger.addHandler(stream_handler) - - settings_utils.print_level(level, __name__.split(".")[-1]) - - - # variables setup - - self._current_element = None - self._is_running = False - self._stopped = False - self._is_paused = False - self._th = None - self.serial_mutex = Lock() - self.status_mutex = Lock() - if handler is None: - self.handler = FeederEventHandler() - else: self.handler = handler - self.serial = DeviceSerial(logger_name = __name__) - self.line_number = 0 - self._timeout_last_line = self.line_number - self.feedrate = 0 - self.last_commanded_position = DotMap({"x":0, "y":0}) - - # commands parser - 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() - self.command_buffer_mutex = Lock() # mutex used to modify the command buffer - self.command_send_mutex = Lock() # mutex used to pause the thread when the buffer is full - self.command_buffer_max_length = 8 - self.command_buffer_history = limited_size_dict.LimitedSizeDict(size_limit = self.command_buffer_max_length+40) # keep saved the last n commands - self._buffered_line = "" - - self._timeout = buffered_timeout.BufferTimeout(30, self._on_timeout) - self._timeout.start() - - # device specific options - self.update_settings(settings_utils.load_settings()) - - - def update_settings(self, settings): - self.settings = settings - self._firmware = settings["device"]["firmware"]["value"] - self._ACK = firmware.get_ACK(self._firmware) - self._timeout.set_timeout_period(firmware.get_buffer_timeout(self._firmware)) - self.is_fast_mode = settings["serial"]["fast_mode"]["value"] - if self.is_fast_mode: - if settings["device"]["type"]["value"] == "Cartesian": - self.command_resolution = "{:.1f}" # Cartesian do not need extra resolution because already using mm as units. (TODO maybe with inches can have problems? needs to check) - else: self.command_resolution = "{:.3f}" # Polar and scara use smaller numbers, will need also decimals - - def close(self): - self.serial.close() - - def get_status(self): - with self.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...") - with self.serial_mutex: - if not self.serial is None: - self.serial.close() - try: - self.serial = DeviceSerial(self.settings['serial']['port']["value"], self.settings['serial']['baud']["value"], logger_name = __name__) - self.serial.set_onreadline_callback(self.on_serial_read) - self.serial.start_reading() - self.logger.info("Connection successfull") - except: - self.logger.info("Error during device connection") - self.logger.info(traceback.print_exc()) - self.serial = DeviceSerial(logger_name = __name__) - self.serial.set_onreadline_callback(self.on_serial_read) - self.serial.start_reading() - - self.device_ready = False # this line is set to true as soon as the board sends a message - - - def set_event_handler(self, handler): - self.handler = handler - - # starts to send gcode to the machine - def start_element(self, element, force_stop=False): - if((not force_stop) and self.is_running()): - return False # if a file is already being sent it will not start a new one - else: - if self.is_running(): - self.stop() # stop -> blocking function: wait until the thread is stopped for real - with self.serial_mutex: - self._th = Thread(target = self._thf, args=(element,), daemon=True) - self._th.name = "drawing_feeder" - self._is_running = True - self._stopped = False - self._is_paused = False - self._current_element = element - if self.command_send_mutex.locked(): - self.command_send_mutex.release() - with self.command_buffer_mutex: - self.command_buffer.clear() - self._th.start() - self.handler.on_element_started(element) - - # ask if the feeder is already sending a file - def is_running(self): - with self.status_mutex: - return self._is_running - - # ask if the feeder is paused - def is_paused(self): - with self.status_mutex: - 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 - def stop(self): - if(self.is_running()): - tmp = self._current_element - with self.status_mutex: - if not self._stopped: - self.logger.info("Stopping drawing") - 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) - while True: - with self.status_mutex: - if self._stopped: - break - - # waiting command buffer to be clear before calling the "drawing ended" event - while True: - self.send_gcode_command(firmware.get_buffer_command(self._firmware), hide_command=True) - time.sleep(3) # wait 3 second to get the time to the board to answer. If the time here is reduced too much will fill the buffer history with buffer_commands and may loose the needed line in a resend command for marlin - # the "buffer_command" will raise a response from the board that will be handled by the parser to empty the buffer - - # wait until the buffer is empty to know that the job is done - with self.command_buffer_mutex: - if len(self.command_buffer) == 0: - break - # resetting line number between drawings - self._reset_line_number() - # calling "drawing ended" event - self.handler.on_element_ended(tmp) - - - # pauses the drawing - # can resume with "resume()" - def pause(self): - with self.status_mutex: - 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._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 - # TODO add G92 check for the positioning - - # clean the command a little - command = command.replace("\n", "").replace("\r", "").upper() - if command == " " or command == "": - return - - # some commands require to update the feeder status - # parse the command if necessary - if "M110" in command: - cs = command.split(" ") - for c in cs: - if c[0]=="N": - self.line_number = int(c[1:]) -1 - self.command_buffer.clear() - - # check if the command is in the "BUFFERED_COMMANDS" list and stops if the buffer is full - 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) - # 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 - - # send the command after parsing the content - # need to use the mutex here because it is changing also the line number - with self.serial_mutex: - line = self._generate_line(command) - - self.serial.send(line) # send line - self.logger.log(settings_utils.LINE_SENT, line.replace("\n", "")) - - # TODO fix the problem with small geometries may be with the serial port being to slow. For long (straight) segments the problem is not evident. Do not understand why it is happening - - with self.command_buffer_mutex: - if(len(self.command_buffer)>=self.command_buffer_max_length and not self.command_send_mutex.locked()): - self.command_send_mutex.acquire() # if the buffer is full acquire the lock so that cannot send new lines until the reception of an ack. Notice that this will stop only buffered commands. The other commands will be sent anyway - - if not hide_command: - self.handler.on_new_line(line) # uses the handler callback for the new line - - if firmware.is_marlin(self._firmware): # updating the command only for marlin because grbl check periodically the buffer status with the status report command - self._update_timeout() # update the timeout because a new command has been sent - - - - # Send a multiline script - def send_script(self, script): - self.logger.info("Sending script") - script = script.split("\n") - for s in script: - if s != "" and s != " ": - self.send_gcode_command(s) - - def serial_ports_list(self): - result = [] - if not self.serial is None: - result = self.serial.serial_port_list() - return result - - def is_connected(self): - with self.serial_mutex: - return self.serial.is_connected() - - # stops immediately the device - def emergency_stop(self): - self.send_gcode_command(firmware.get_emergency_stop_command(self._firmware)) - # TODO add self.close() ? - - # ----- PRIVATE METHODS ----- - - # prepares the board - def _on_device_ready(self): - if firmware.is_marlin(self._firmware): - self._reset_line_number() - - # grbl status report mask setup - # sandypi need to check the buffer to see if the machine has cleaned the buffer - # setup grbl to show the buffer status with the $10 command - # Grbl 1.1 https://github.com/gnea/grbl/wiki/Grbl-v1.1-Configuration - # Grbl 0.9 https://github.com/grbl/grbl/wiki/Configuring-Grbl-v0.9 - # to be compatible with both will send $10=6 (4(for v0.9) + 2(for v1.1)) - # the status will then be prompted with the "?" command when necessary - # the buffer will contain Bf:"usage of the buffer" - if firmware.is_grbl(self._firmware): - self.send_gcode_command("$10=6") - - # send the "on connection" script from the settings - self.send_script(self.settings['scripts']['connected']["value"]) - - # device ready event - self.handler.on_device_ready() - - # run the "_on_device_ready" method with a delay - def _on_device_ready_delay(self): - def delay(): - time.sleep(5) - self._on_device_ready() - th = Thread(target = delay, daemon=True) - th.name = "waiting_device_ready" - th.start() - - # thread function - # TODO move this function in a different class? - def _thf(self, element): - # 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)) - - # 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} - - filter = Fit(dims) - - 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 - - if line is None: # if the line is none there is no command to send, will continue with the next element execution (for example, within the delay element it will sleep 1s at a time and return None until the timeout passed. TODO Not really an efficient way, may change it in the future) - continue - - line = line.upper() - - self.send_gcode_command(line) - - while self.is_paused(): - time.sleep(0.1) - # if a "stop" command is raised must exit the pause and stop the drawing - if not self.is_running(): - break - - # 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 - - # 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 - def on_serial_read(self, l): - if not l is None: - # readline is not returning the full line but only a buffer - # must break the line on "\n" to correctly parse the result - self._buffered_line += l - if "\n" in self._buffered_line: - self._buffered_line = self._buffered_line.replace("\r", "").split("\n") - if len(self._buffered_line) >1: - for l in self._buffered_line[0:-1]: # parse single lines if multiple \n are detected - self._parse_device_line(l) - self._buffered_line = str(self._buffered_line[-1]) - - - def _update_timeout(self): - self._timeout_last_line = self.line_number - self._timeout.update() - - - # function called when the buffer has not been updated for some time (controlled by the buffered timeou) - def _on_timeout(self): - if (self.command_buffer_mutex.locked and self.line_number == self._timeout_last_line 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) - line = self._generate_line(command, no_buffer=True) # use the no_buffer to clean one position of the buffer after adding the command - self.logger.log(settings_utils.LINE_SERVICE, line) - with self.serial_mutex: - self.serial.send(line) - else: - self._update_timeout() - - def _ack_received(self, safe_line_number=None, append_left_extra=False): - if safe_line_number is None: - with self.command_buffer_mutex: - if len(self.command_buffer) != 0: - self.command_buffer.popleft() - else: - with self.command_buffer_mutex: - while True: - # Remove the numbers lower than the specified safe_line_number (used in the resend line command: lines older than the one required can be deleted safely) - if len(self.command_buffer) != 0: - line_number = self.command_buffer.popleft() - if line_number >= safe_line_number: - self.command_buffer.appendleft(line_number) - break - if append_left_extra: - self.command_buffer.appendleft(safe_line_number-1) - - self._check_buffer_mutex_status() - - - # check if the buffer of the device is full or can accept more commands - def _check_buffer_mutex_status(self): - with self.command_buffer_mutex: - if self.command_send_mutex.locked() and len(self.command_buffer) < self.command_buffer_max_length: - self.command_send_mutex.release() - - - # parse a line coming from the device - def _parse_device_line(self, line): - # setting to avoid sending the message to the frontend in particular situations (i.e. status checking in grbl) - # will still print the status in the command line - hide_line = False - - if firmware.get_ACK(self._firmware) in line: # when an "ack" is received free one place in the buffer - self._ack_received() - - # check if the received line is for the device being ready - if firmware.get_ready_message(self._firmware) in line: - if self.serial.is_fake: - self._on_device_ready() - else: - self._on_device_ready_delay() # if the device is ready will allow the communication after a small delay - - # check marlin specific messages - if firmware.is_grbl(self._firmware): - if line.startswith("<"): - try: - # interested in the "Bf:xx," part where xx is the content of the buffer - # select buffer content lines - res = line.split("Bf:")[1] - res = int(res.split(",")[0]) - if res == 15: # 15 => buffer is empty on the device (should include also 14 to make it more flexible?) - with self.command_buffer_mutex: - self.command_buffer.clear() - if res!= 0: # 0 -> buffer is full - with self.command_buffer_mutex: - if len(self.command_buffer) > 0 and self.is_running(): - self.command_buffer.popleft() - self._check_buffer_mutex_status() - - if (self.is_running() or self.is_paused()): - hide_line = True - self.logger.log(settings_utils.LINE_SERVICE, line) - except: # sometimes may not receive the entire line thus it may throw an error - pass - return - - # errors - elif "error:22" in line: - self.stop() - with self.command_buffer_mutex: - self.command_buffer.clear() - elif "error:" in line: - self.logger.error("Grbl error: {}".format(line)) - # TODO check/parse error types and give some hint about the problem? - - - # TODO divide parser between firmwares? - # TODO set firmware type automatically on connection - # TODO add feedrate control with something like a knob on the user interface to make the drawing slower or faster - - # Marlin messages - else: - # Marlin resend command if a message is not received correctly - # Quick note: if the buffer_command is sent too often will fill the buffer with "M114" and if a line is request will not be able to send it back - # TODO Should add some sort of filter that if the requested line number is older than the requested ones can send from that number to the first an empty command or the buffer_command - # Otherwise should not put a buffer_command in the buffer and if a line with the requested number should send the buffer_command - if "Resend: " in line: - line_found = False - line_number = int(line.replace("Resend: ", "").replace("\r\n", "")) - items = deepcopy(self.command_buffer_history) - missing_lines = True - first_available_line = None - for n, c in items.items(): - n_line_number = int(n.strip("N")) - if n_line_number == line_number: - line_found = True - if n_line_number >= line_number: - if first_available_line is None: - first_available_line = line_number - # All the lines after the required one must be resent. Cannot break the loop now - self.serial.send(c) - self.logger.error("Line not received correctly. Resending: {}".format(c.strip("\n"))) - - if (not line_found) and not(first_available_line is None): - for i in range(line_number, first_available_line): - self.serial.send(self._generate_line(firmware.MARLIN.buffer_command, no_buffer=True, n=i)) - - self._ack_received(safe_line_number=line_number-1, append_left_extra=True) - # the resend command is sending an ack. should add an entry to the buffer to keep the right lenght (because the line has been sent 2 times) - if not line_found: - self.logger.error("No line was found for the number required. Restart numeration.") - self._reset_line_number() - - # Marlin "unknow command" - elif "echo:Unknown command:" in line: - self.logger.error("Error: command not found. Can also be a communication error") - # M114 response contains the "Count" word - # 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: - 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) amber, G -> cold white, B -> warm white) -class WWANeopixels(RGBNeopixels): - pass - - -if __name__ == "__main__": - from time import sleep - leds = WWANeopixels(5,18) - leds.fill((100,0,0)) - leds[0] = (10,0,0) - leds[1] = (0,10,0) - leds[2] = (0,0,10) - sleep(2) - - leds.deinit() diff --git a/server/hw_controller/queue_manager.py b/server/hw_controller/queue_manager.py deleted file mode 100644 index 03bf1373..00000000 --- a/server/hw_controller/queue_manager.py +++ /dev/null @@ -1,270 +0,0 @@ -from queue import Queue -import json -from threading import Thread -import time -import random - -from server.utils import settings_utils -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.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._play_random = False # True if the device was started with a "start a random drawing" commands - - # 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 - - def is_paused(self): - return self.app.feeder.get_status()["is_paused"] - - # 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("Now running: {}".format(element)) - self._element = element - - # stop the current drawing and start the next - def stop(self): - self._play_random = False - self._is_force_stop = True - self.app.feeder.stop() - - def reset_play_random(self): - self._play_random = False - - # set the repeat flag - def set_repeat(self, val): - if type(val) == type(True): - self.repeat = val - if val and (len(self.q.queue) > 0) and self._play_random: - self._put_random_element_in_queue() - self.send_queue_status() - else: - if self._play_random: - self.clear_queue() - self.reset_play_random() - else: - raise ValueError("The argument must be boolean") - - # set the shuffle flag - def set_shuffle(self, val): - 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 - - def _put_random_element_in_queue(self): - self.q.put(ShuffleElement(shuffle_type="0")) # queue a new random element drawing - - # starts a random drawing from the uploaded files - def start_random_drawing(self, repeat=False): - self._play_random = True - self.set_shuffle(True) - if self.q.empty(): - self._put_random_element_in_queue() - else: - if not self.is_drawing(): - self._put_random_element_in_queue() - self.start_next() - - - # add an element to the queue - def queue_element(self, element, show_toast=True): - if self.q.empty() and not self.is_drawing(): - self.start_element(element) - return - 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") - self.send_queue_status() - - # return the content of the queue as a string - def queue_str(self): - return str(self.q.queue) - - def get_queue(self): - return self.q.queue - - def set_element_ended(self): - self.set_is_drawing(False) - # 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.start_next() - - # clear the queue - def clear_queue(self): - self.q.queue.clear() - self.send_queue_status() - - def set_new_order(self, elements): - self.clear_queue() - for el in elements: - if el!= 0: - self.q.put(el) - self.send_queue_status() - - # remove the first element with the given code - def remove(self, code): - tmp = Queue() - is_first = True - for c in self.q.queue: - if c == code and is_first: - is_first = False - else: - tmp.put(c) - self.q = tmp - - # queue length - def queue_length(self): - return self.q.qsize() - - # 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 - 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() - 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")): - if hasattr(self._element, "was_random"): - self._put_random_element_in_queue() - else: - 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: - return False - 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() - if element is None: - return False - # starts the choosen element - self.start_element(element) - self.app.logger.info("Starting next element: {}".format(element)) - return True - except Exception as 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() - - # This method send a "start" command to the bot with the element - 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): - els = [i for i in self.q.queue if not i is None] - elements = list(map(lambda x: str(x), els)) if len(els) > 0 else [] # converts elements to json - res = { - "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)) - - # 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"]) - - if autostart["on_ready"]: - 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 f5df0733..1d07a731 100644 --- a/server/preprocessing/drawing_creator.py +++ b/server/preprocessing/drawing_creator.py @@ -10,6 +10,7 @@ import os import shutil + def preprocess_drawing(filename, file): # TODO add support to thr files @@ -19,31 +20,31 @@ def preprocess_drawing(filename, file): # 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) + + new_file = UploadedFiles(id=id, filename=filename) db.session.add(new_file) db.session.commit() factory = ImageFactory(settings_utils.get_only_values(settings["device"])) # create a folder for each drawing. The folder will contain the .gcode file, the preview and additionally some settings for the drawing - folder = app.config["UPLOAD_FOLDER"] +"/" + str(new_file.id) +"/" + folder = app.config["UPLOAD_FOLDER"] + "/" + str(new_file.id) + "/" try: os.mkdir(folder) except: app.logger.error("The folder for '{}' already exists".format(new_file.id)) if hasattr(file, "save"): - file.save(os.path.join(folder, str(new_file.id)+".gcode")) + file.save(os.path.join(folder, str(new_file.id) + ".gcode")) else: - with open(os.path.join(folder, str(new_file.id)+".gcode"), "w") as f: + with open(os.path.join(folder, str(new_file.id) + ".gcode"), "w") as f: for l in file.readlines(): f.write(l) # create the preview image try: - with open(os.path.join(folder, str(new_file.id)+".gcode")) as file: + with open(os.path.join(folder, str(new_file.id) + ".gcode")) as 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")) - + 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"] @@ -52,9 +53,13 @@ def preprocess_drawing(filename, file): except: app.logger.error("Error during image creation") app.logger.error(traceback.print_exc()) - shutil.copy2(app.config["UPLOAD_FOLDER"]+"/placeholder.jpg", os.path.join(folder, str(new_file.id)+".jpg")) + shutil.copy2( + app.config["UPLOAD_FOLDER"] + "/placeholder.jpg", + os.path.join(folder, str(new_file.id) + ".jpg"), + ) + # TODO create a better placeholder? or add a routine to fix missing images? app.logger.info("File added") - return new_file.id \ No newline at end of file + return new_file.id diff --git a/server/preprocessing/file_observer.py b/server/preprocessing/file_observer.py index 36c97365..c7bdae55 100644 --- a/server/preprocessing/file_observer.py +++ b/server/preprocessing/file_observer.py @@ -9,12 +9,17 @@ from server.preprocessing.drawing_creator import preprocess_drawing from server.sockets_interface.socketio_callbacks import drawings_refresh + class GcodeObserverManager: + """ + This class is used to observe a folder and upload every gcode file that is found inside it + """ + def __init__(self, path=".", logger=None): if logger is None: logger_name = __name__ self._logger = logging.getLogger(logger_name) - else: + else: self._logger = logger self._path = path self._observer = Observer() @@ -24,35 +29,44 @@ def __init__(self, path=".", logger=None): self.check_current_files() def start(self): + """Start the observer""" self._observer.start() - + def stop(self): + """Stop the observer""" self._observer.stop() self._observer.join() def check_current_files(self): + """Check the files that are in the folder""" files = fnmatch.filter(os.listdir(self._path), "*.gcode") - if len(files)>0: + if len(files) > 0: self._logger.info("Found some files to load in the autodetect folder") for name in files: self._handler.init_drawing(os.path.join(self._path, name)) class GcodeEventHandler(PatternMatchingEventHandler): + """Handle the file once is detected by the observer""" + def __init__(self, logger): super().__init__(patterns=["*.gcode"]) self._logger = logger - def on_created(self, evt): - self.handle_event(evt) - - def on_moved(self, evt): - self.handle_event(evt) + def on_created(self, event): + """Handle the creation of a new file""" + self.handle_event(event) + + def on_moved(self, event): + """Handle the copy of file inside the folder""" + self.handle_event(event) + + def handle_event(self, event): + """Handle a generic event""" + self.init_drawing(event.src_path) - def handle_event(self, evt): - self.init_drawing(evt.src_path) - def init_drawing(self, filename): + """Save the file in the database and create the preview""" self._logger.info("Uploading autodetected file: {}".format(filename)) try: id = "" diff --git a/server/saves/default_settings.json b/server/saves/default_settings.json index 3c74f6c2..0bfd2b0c 100644 --- a/server/saves/default_settings.json +++ b/server/saves/default_settings.json @@ -3,9 +3,11 @@ "port": { "name": "serial.port", "type": "select", - "value": "FAKE", + "value": "Virtual", "label": "Serial port", - "available_values": ["FAKE"], + "available_values": [ + "Virtual" + ], "tip": "Select the serial port" }, "baud": { @@ -13,7 +15,19 @@ "type": "select", "value": "115200", "label": "Serial baudrate", - "available_values": ["2400", "4800", "9600", "19200", "38400", "57600", "115200", "230400", "250000", "460800", "921600"], + "available_values": [ + "2400", + "4800", + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "250000", + "460800", + "921600" + ], "tip": "Select the correct serial baudrate" }, "fast_mode": { @@ -48,22 +62,26 @@ "label": "Select device type", "tip": "Select the type of mechanism used by the device" }, - "width":{ + "width": { "name": "device.width", "type": "input", "value": 100, "label": "Device width", "depends_on": "device.type", - "depends_values": ["Cartesian"], + "depends_values": [ + "Cartesian" + ], "tip": "Maximum X extension" }, - "height":{ + "height": { "name": "device.height", "type": "input", "value": 100, "label": "Device height", "depends_on": "device.type", - "depends_values": ["Cartesian"], + "depends_values": [ + "Cartesian" + ], "tip": "Maximum Y extension" }, "radius": { @@ -72,7 +90,10 @@ "value": 200, "label": "Device radius", "depends_on": "device.type", - "depends_values": ["Polar", "Scara"], + "depends_values": [ + "Polar", + "Scara" + ], "tip": "Device maximum radius" }, "angle_conversion_factor": { @@ -81,7 +102,10 @@ "value": 6, "label": "Angle conversion factor", "depends_on": "device.type", - "depends_values": ["Polar", "Scara"], + "depends_values": [ + "Polar", + "Scara" + ], "tip": "The value that makes the arm to turn one full turn" }, "offset_angle_1": { @@ -90,7 +114,10 @@ "value": -1.5, "label": "Insert angular position homing offset", "depends_on": "device.type", - "depends_values": ["Polar", "Scara"], + "depends_values": [ + "Polar", + "Scara" + ], "tip": "Angle for the home position of the arm (uses the values from the conversion factor, not rad: if angle_conversion_factor is 6 and must shift the homing by half turn must put 1.5" }, "offset_angle_2": { @@ -99,7 +126,9 @@ "value": 1.5, "label": "Insert second arm homing position offset", "depends_on": "device.type", - "depends_values": ["Scara"], + "depends_values": [ + "Scara" + ], "tip": "Angle for the home position of the second arm (uses the values from the conversion factor, not rad: if angle_conversion_factor is 6 and must shift the homing by half turn must put 1.5" } }, diff --git a/server/sockets_interface/socketio_callbacks.py b/server/sockets_interface/socketio_callbacks.py index 5b6cc5b8..337264fa 100644 --- a/server/sockets_interface/socketio_callbacks.py +++ b/server/sockets_interface/socketio_callbacks.py @@ -1,30 +1,37 @@ +# pylint: disable=E1101 +# pylint: disable=missing-function-docstring + import json import shutil import os from server import socketio, app, db -from server.utils import settings_utils from server.database.elements_factory import ElementsFactory from server.database.models import UploadedFiles, Playlists from server.database.playlist_elements import DrawingElement +from server.hardware.device.comunication.device_serial import DeviceSerial +from server.utils import settings_utils -@socketio.on('connect') + +@socketio.on("connect") def on_client_connected(): - app.qmanager.send_queue_status() # sending queue status - settings_request() # sending updated settings + app.qmanager.send_queue_status() # sending queue status + settings_request() # sending updated settings + -# TODO split in multiple files? +# TODO split in multiple files? # --------------------------------------------------------------- UPDATES ------------------------------------------------------------------------------------- -# request to check if a new version of the software is available -@socketio.on('updates_toggle_auto_enabled') +# request to check if a new version of the software is available +@socketio.on("updates_toggle_auto_enabled") def toggle_autoupdate_enabled(): app.umanager.toggle_autoupdate() settings_request() + # --------------------------------------------------------- PLAYLISTS CALLBACKS ------------------------------------------------------------------------------- # delete a playlist @@ -37,14 +44,19 @@ def playlist_delete(playlist_id): app.logger.error("'Delete playlist code {}' error".format(playlist_id)) playlist_refresh() + # save the changes to the playlist @socketio.on("playlist_save") def playlist_save(playlist): playlist = json.loads(playlist) - pl = Playlists.create_playlist() if ((not "id" in playlist) or (playlist["id"] == 0)) else Playlists.get_playlist(playlist['id']) + pl = ( + Playlists.create_playlist() + if ((not "id" in playlist) or (playlist["id"] == 0)) + else Playlists.get_playlist(playlist["id"]) + ) pl.clear_elements() - pl.name = playlist['name'] - pl.add_element(playlist['elements']) + pl.name = playlist["name"] + pl.add_element(playlist["elements"]) pl.save() app.logger.info("Playlist saved") playlist_refresh_single(pl.id) @@ -53,10 +65,10 @@ def playlist_save(playlist): # adds a playlist to the drawings queue @socketio.on("playlist_queue") def playlist_queue(code): - item = db.session.query(Playlists).filter(Playlists.id==code).one() + item = db.session.query(Playlists).filter(Playlists.id == code).one() elements = item.get_elements() for i in elements: - app.qmanager.queue_element(i, show_toast = False) + app.qmanager.queue_element(i, show_toast=False) @socketio.on("playlist_create_new") @@ -78,6 +90,7 @@ def playlist_refresh_single(playlist_id): playlist = db.session.query(Playlists).filter(Playlists.id == playlist_id).first() app.semits.emit("playlists_refresh_single_response", playlist.to_json()) + # --------------------------------------------------------- SETTINGS CALLBACKS ------------------------------------------------------------------------------- # settings callbacks @@ -85,7 +98,7 @@ def playlist_refresh_single(playlist_id): def settings_save(data, is_connect): settings_utils.save_settings(data) settings = settings_utils.load_settings() - app.feeder.update_settings(settings) + app.feeder.init_device(settings) app.bmanager.update_settings(settings) app.lmanager.update_settings(settings) app.semits.show_toast_on_UI("Settings saved") @@ -93,25 +106,34 @@ def settings_save(data, is_connect): # updating feeder if is_connect: app.logger.info("Connecting device") - - app.feeder.connect() - if app.feeder.is_connected(): + + app.feeder.init_device(settings) + if app.feeder.is_connected: app.semits.show_toast_on_UI("Connection to device successful") else: - app.semits.show_toast_on_UI("Device not connected. Opening a fake serial port.") + app.semits.show_toast_on_UI("Device not connected. Opening a virtual serial port.") + @socketio.on("settings_request") def settings_request(): settings = settings_utils.load_settings() - settings["buttons"]["available_values"] = app.bmanager.get_buttons_options() - settings["buttons"]["available"] = app.bmanager.gpio_is_available() or int(os.getenv("DEV_HWBUTTONS", default="0")) - settings["leds"]["available"]["value"] = app.lmanager.is_available() or int(os.getenv("DEV_HWLEDS", default="0")) - settings["leds"]["has_light_sensor"]["value"] = app.lmanager.has_light_sensor() or int(os.getenv("DEV_HWLEDS", default="0")) - settings["serial"]["port"]["available_values"] = app.feeder.serial_ports_list() - settings["serial"]["port"]["available_values"].append("FAKE") - settings["updates"]["hash"] = app.umanager.short_hash - settings["updates"]["docker_compose_latest_version"] = app.umanager.docker_compose_latest_version - settings["updates"]["autoupdate"] = app.umanager.is_autoupdate_enabled() + settings["buttons"]["available_values"] = app.bmanager.get_buttons_options() + settings["buttons"]["available"] = app.bmanager.gpio_is_available() or int( + os.getenv("DEV_HWBUTTONS", default="0") + ) + settings["leds"]["available"]["value"] = app.lmanager.is_available() or int( + os.getenv("DEV_HWLEDS", default="0") + ) + settings["leds"]["has_light_sensor"]["value"] = app.lmanager.has_light_sensor() or int( + os.getenv("DEV_HWLEDS", default="0") + ) + settings["serial"]["port"]["available_values"] = DeviceSerial.get_serial_port_list() + settings["serial"]["port"]["available_values"].append("Virtual") + settings["updates"]["hash"] = app.umanager.short_hash + settings["updates"][ + "docker_compose_latest_version" + ] = app.umanager.docker_compose_latest_version + settings["updates"]["autoupdate"] = app.umanager.is_autoupdate_enabled() tmp = [] labels = [v["label"] for v in settings["buttons"]["available_values"]] for b in settings["buttons"]["buttons"]: @@ -121,53 +143,62 @@ def settings_request(): settings["buttons"]["buttons"] = tmp app.semits.emit("settings_now", json.dumps(settings)) + @socketio.on("send_gcode_command") def send_gcode_command(command): app.feeder.send_gcode_command(command) + @socketio.on("settings_shutdown_system") def settings_shutdown_system(): app.semits.show_toast_on_UI("Shutting down the device") app.feeder.stop() app.lmanager.stop() - os.system("/sbin/shutdown now") # in order to shutdown inside a docker container + os.system("/sbin/shutdown now") # in order to shutdown inside a docker container + @socketio.on("settings_reboot_system") def settings_reboot_system(): app.semits.show_toast_on_UI("Rebooting system...") - os.system("/sbin/reboot") # in order to reboot inside a docker container + os.system("/sbin/reboot") # in order to reboot inside a docker container + # --------------------------------------------------------- DRAWINGS CALLBACKS ------------------------------------------------------------------------------- + @socketio.on("drawing_queue") def drawing_queue(code): element = DrawingElement(drawing_id=code) - app.qmanager.reset_play_random() + app.qmanager.reset_random_queue() 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() # TODO should delete the drawing also from every playlist - + try: if not item is None: db.session.delete(item) db.session.commit() - shutil.rmtree(app.config["UPLOAD_FOLDER"] +"/" + str(code) +"/") + shutil.rmtree(app.config["UPLOAD_FOLDER"] + "/" + str(code) + "/") app.logger.info("Drawing code {} deleted".format(code)) app.semits.show_toast_on_UI("Drawing deleted") except Exception as e: app.logger.error("'Delete drawing code {}' error".format(code)) + @socketio.on("drawings_refresh") def drawings_refresh(): rows = db.session.query(UploadedFiles).order_by(UploadedFiles.edit_date.desc()) @@ -179,25 +210,33 @@ def drawings_refresh(): # --------------------------------------------------------- QUEUE CALLBACKS ------------------------------------------------------------------------------- + @socketio.on("queue_get_status") def queue_get_status(): app.qmanager.send_queue_status() + @socketio.on("queue_set_order") def queue_set_order(elements): if elements == "": app.qmanager.clear_queue() else: - app.qmanager.set_new_order(map(lambda e: ElementsFactory.create_element_from_dict(e), json.loads(elements))) + app.qmanager.set_new_order( + map(lambda e: ElementsFactory.create_element_from_dict(e), json.loads(elements)) + ) + # stops only the current element @socketio.on("queue_next_drawing") def queue_next_drawing(): - app.semits.show_toast_on_UI("Stopping drawing...") + app.semits.show_toast_on_UI("Stopping drawing...") 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 + 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(): @@ -206,34 +245,41 @@ def queue_stop_all(): 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.qmanager.repeat = val app.logger.info("repeat: {}".format(val)) + # sets the shuffle flag for the queue @socketio.on("queue_set_shuffle") def queue_set_shuffle(val): - app.qmanager.set_shuffle(val) + app.qmanager.shuffle = val app.logger.info("shuffle: {}".format(val)) + # sets the queue interval @socketio.on("queue_set_interval") def queue_set_interval(val): - app.qmanager.set_interval(float(val)) + app.qmanager.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 ------------------------------------------------------------------------------- + @socketio.on("leds_set_color") def leds_set_color(color): - app.lmanager.set_color(color) + app.lmanager.set_color(color) + @socketio.on("leds_auto_dim") def leds_set_autodim(val): @@ -243,8 +289,10 @@ def leds_set_autodim(val): else: app.lmanager.sensor.stop() + # --------------------------------------------------------- MANUAL CONTROL ------------------------------------------------------------------------------- + @socketio.on("control_emergency_stop") def control_emergency_stop(): - app.feeder.emergency_stop() \ No newline at end of file + app.feeder.emergency_stop() diff --git a/server/sockets_interface/socketio_emits.py b/server/sockets_interface/socketio_emits.py index 5eba2293..1c31c7f2 100644 --- a/server/sockets_interface/socketio_emits.py +++ b/server/sockets_interface/socketio_emits.py @@ -1,5 +1,4 @@ - -class SocketioEmits(): +class SocketioEmits: def __init__(self, app, socketio, db): self.app = app self.socketio = socketio @@ -9,16 +8,14 @@ def __init__(self, app, socketio, db): def show_toast_on_UI(self, message): self.emit("toast_show_message", message) - # shows a line coming from the hw device on the manual control panel def hw_command_line_message(self, line): self.emit("command_line_show", line) - # sends the last position to update the preview box def update_hw_preview(self, line): self.emit("preview_new_position", line) # general emit def emit(self, topic, line): - self.socketio.emit(topic, line) \ No newline at end of file + self.socketio.emit(topic, line) diff --git a/server/static/.gitignore b/server/static/.gitignore new file mode 100644 index 00000000..d4ffee2c --- /dev/null +++ b/server/static/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore +!placeholder.jpg \ No newline at end of file diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 8e595d5e..0cdf9cf9 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -18,16 +18,17 @@ @pytest.fixture(scope="session") def client(): db_fd, db_fu = tempfile.mkstemp() - server.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' - server.app.config['TESTING'] = True + server.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" + server.app.config["TESTING"] = True with server.app.test_client() as client: with server.app.app_context(): - db.create_all() + db.create_all() yield client db.drop_all() + # stopping feeder + server.app.feeder._device.close() + os.close(db_fd) os.unlink(db_fu) - - diff --git a/server/tests/test_buttons.py b/server/tests/test_buttons.py index 1f3619af..ea0c008d 100644 --- a/server/tests/test_buttons.py +++ b/server/tests/test_buttons.py @@ -1,24 +1,32 @@ import inspect from server import app -from server.hw_controller.buttons.generic_button_event import GenericButtonAction -import server.hw_controller.buttons.actions as button_actions +from server.hardware.buttons.generic_button_event import GenericButtonAction +import server.hardware.buttons.actions as button_actions + def test_buttons_get_options(): + """Check that all the actions have the correct information to be sent to the frontend""" options = app.bmanager.get_buttons_options() fields = ["description", "label", "name", "usage"] for o in options: for f in fields: if not f in o.keys(): - assert(False) - if o["description"] == GenericButtonAction.description or o["label"] == GenericButtonAction.label: - assert(False) + assert False + if ( + o["description"] == GenericButtonAction.description + or o["label"] == GenericButtonAction.label + ): + assert False + def test_buttons_gpio_available(): - assert(not app.bmanager.gpio_is_available()) # the test must pass on a linux device not using real hw + """This test must pass on a linux device that is not using real hw""" + assert not app.bmanager.gpio_is_available() + -# checking if the button actions are created correctly and also if the execute method has been overwritten def test_buttons_action_has_execute(): + """checking if the button actions are created correctly and also if the execute method has been overwritten""" for cl in inspect.getmembers(button_actions, inspect.isclass): if not cl[1] is GenericButtonAction: print(cl[0]) diff --git a/server/tests/test_leds.py b/server/tests/test_leds.py index 8db6cd84..ae744799 100644 --- a/server/tests/test_leds.py +++ b/server/tests/test_leds.py @@ -1,26 +1,33 @@ -from server.hw_controller.leds.leds_controller import LedsController +from server.hardware.leds.leds_controller import LedsController from server import app # cannot really test if the leds are working # just checking that even without the correct hw the module is not breaking the sw + def test_led_driver_available(): - assert(not app.lmanager.is_available()) # the test should work on a linux server without hw leds + assert not app.lmanager.is_available() # the test should work on a linux server without hw leds + def test_led_driver_start(): app.lmanager.start() + def test_led_driver_stop(): app.lmanager.stop() + def test_led_driver_has_light_sensor(): - assert(not app.lmanager.has_light_sensor()) + assert not app.lmanager.has_light_sensor() + def test_led_reset_lights(): app.lmanager.reset_lights() + def test_led_increase_brightness(): app.lmanager.increase_brightness() + def test_led_decrease_brightness(): - app.lmanager.decrease_brightness() \ No newline at end of file + app.lmanager.decrease_brightness() diff --git a/server/tests/test_pages.py b/server/tests/test_pages.py index 608a53a3..3a65d2e6 100644 --- a/server/tests/test_pages.py +++ b/server/tests/test_pages.py @@ -1,5 +1,4 @@ - def test_index(client): - rv = client.get('/') + rv = client.get("/") assert rv.default_status == 200 diff --git a/server/tests/test_z_firmwares.py b/server/tests/test_z_firmwares.py new file mode 100644 index 00000000..53fc9608 --- /dev/null +++ b/server/tests/test_z_firmwares.py @@ -0,0 +1,66 @@ +""" +Test firmware comunication control classes +""" + +import logging +from time import sleep + +from server.hardware.device.firmwares.grbl import Grbl +from server.hardware.device.firmwares.marlin import Marlin +from server.hardware.device.firmwares.firmware_event_handler import FirwmareEventHandler + + +class EventHandler(FirwmareEventHandler): + def on_line_received(self, line): + pass + + def on_line_sent(self, line): + pass + + def on_device_ready(self): + print("Device ready") + + +settings = {"port": {"value": "COM3"}, "baud": {"value": 115200}} + +logger_name = logging.getLogger().name + + +def run_test(device, fast_mode=False): + device.connect() + device.fast_mode = fast_mode + device.send_gcode_command("G0 X0 Y0 F3000") + for x in range(15): + device.send_gcode_command(f"G0 X{x} Y0") + + device.close() + return True + + +def test_marlin(): + """ + Test Marlin firmware manager + """ + assert run_test( + Marlin(serial_settings=settings, logger=logger_name, event_handler=EventHandler()) + ) + + assert run_test( + Marlin(serial_settings=settings, logger=logger_name, event_handler=EventHandler()), + fast_mode=True, + ) + sleep(3) + + +def test_grbl(): + """ + Test Grbl firmware manager + """ + assert run_test( + Grbl(serial_settings=settings, logger=logger_name, event_handler=EventHandler()) + ) + assert run_test( + Grbl(serial_settings=settings, logger=logger_name, event_handler=EventHandler()), + fast_mode=True, + ) + sleep(3) diff --git a/server/utils/buffered_timeout.py b/server/utils/buffered_timeout.py index 38f23efd..de16fef8 100644 --- a/server/utils/buffered_timeout.py +++ b/server/utils/buffered_timeout.py @@ -1,10 +1,16 @@ from threading import Thread, Lock import time -# this thread calls a function after a timeout but only if the "update" method is not called before that timeout expires class BufferTimeout(Thread): - def __init__(self, timeout_delta, function, group=None, target=None, name=None, args=(), kwargs=None): + """ + this thread calls a function after a timeout but only if the "update" method is not called before that timeout expires + + """ + + def __init__( + self, timeout_delta, function, group=None, target=None, name=None, args=(), kwargs=None + ): super(BufferTimeout, self).__init__(group=group, target=target, name=name) self.name = "buffered_timeout" self.timeout_delta = timeout_delta @@ -13,7 +19,7 @@ def __init__(self, timeout_delta, function, group=None, target=None, name=None, self.is_running = False self.setDaemon(True) self.update() - + def set_timeout_period(self, val): self.timeout_delta = val diff --git a/server/utils/custom_math.py b/server/utils/custom_math.py index f7cf72ac..050abe86 100644 --- a/server/utils/custom_math.py +++ b/server/utils/custom_math.py @@ -1,4 +1,3 @@ - def multiply_tuple(tup, fval): - tup = tuple(i*fval for i in tup) - return tup \ No newline at end of file + tup = tuple(i * fval for i in tup) + return tup diff --git a/server/utils/diagnostic.py b/server/utils/diagnostic.py new file mode 100644 index 00000000..32b43727 --- /dev/null +++ b/server/utils/diagnostic.py @@ -0,0 +1,61 @@ +from zipfile import ZipFile +from datetime import datetime + +from os import path, walk, listdir, remove + +ZIP_PREFIX = "diagnostic_" +DIAGNOSTIC_FILE_PATH = path.join("server", "static") + + +def generate_diagnostic_zip(): + """ + Create a zip file containing log files and saved settings + """ + # remove older zip file if present + for f in listdir(DIAGNOSTIC_FILE_PATH): + if f.startswith(ZIP_PREFIX) and f.endswith(".zip"): + remove(path.join(DIAGNOSTIC_FILE_PATH, f)) + + # create the new zip file + current_date = datetime.today() + str_datetime = current_date.strftime("%Y%m%d_%H%M%S") + zip_path = path.join(DIAGNOSTIC_FILE_PATH, f"{ZIP_PREFIX}{str_datetime}.zip") + + with ZipFile(zip_path, "w") as zip_file: + # fille the zip file with the diagnostic files + for p in get_diagnostic_paths(): + # if the path is directory, add all the files within that folder + if path.isdir(p): + filenames = next(walk(p))[2] + for f in filenames: + file = path.join(p, f) + if validate_diagnostic_file(file): + zip_file.write(file) + # otherwise add the single file + else: + if validate_diagnostic_file(p): + zip_file.write(p) + return zip_path + + +def validate_diagnostic_file(filename): + """ + Filter out the files that are not necessary for the diagnostics + + Returns: True if the given file can be put inside the diagnostics zip + """ + # ignore filenames starting with a "." (hidden files) and the older zip + name = path.split(filename)[-1] + return not name.startswith(".") and not name.startswith("diagnostic_") + + +def get_diagnostic_paths(): + """ + Returns: all the paths of data that should be saved inside the diagnostics zip + """ + return [path.join("server", "saves", "saved_settings.json"), path.join("server", "logs")] + + +if __name__ == "__main__": + print(get_diagnostic_paths()) + generate_diagnostic_zip() diff --git a/server/utils/gcode_converter.py b/server/utils/gcode_converter.py index 62aa214c..f49d57ea 100644 --- a/server/utils/gcode_converter.py +++ b/server/utils/gcode_converter.py @@ -2,27 +2,41 @@ from math import cos, sin, pi, sqrt from dotmap import DotMap + class ImageFactory: # straight lines gcode commands straight_lines = ["G01", "G1", "G0", "G00"] - # Args: - # - device: dict with the following values - # * type: device type (values: "Cartesian", "Polar", "Scara") - # * radius: for polar and scara needs the maximum radius of the device - # * offset_angle_1: for polar and scara needs an offset angle to rotate the view of the drawing (homing position angle) in motor units - # * offset_angle_2: for scara only: homing angle of the second part of the arm with respect to the first arm (alpha offset) in motor units - # * angle_conversion_factor (scara and polar): conversion value between motor units and radians (default for polar is pi, for scara is 6) - # - final_width (default: 800): final image width in px - # - final_height (default: 800): final image height in px - # - bg_color (default: (0,0,0)): tuple of the rgb color for the background - # - final_border_px (default: 20): the border to leave around the picture in px - # - line_width (default: 5): line thickness (px) - # - verbose (boolean) (default: False): if True prints the coordinates and other stuff in the command line - def __init__(self, device, final_width=800, final_height=800, bg_color=(0,0,0), line_color=(255,255,255), final_border_px=20, line_width=1, 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, + ): + """ + Args: + - device: dict with the following values + * type: device type (values: "Cartesian", "Polar", "Scara") + * radius: for polar and scara needs the maximum radius of the device + * offset_angle_1: for polar and scara needs an offset angle to rotate the view of the drawing (homing position angle) in motor units + * offset_angle_2: for scara only: homing angle of the second part of the arm with respect to the first arm (alpha offset) in motor units + * angle_conversion_factor (scara and polar): conversion value between motor units and radians (default for polar is pi, for scara is 6) + - final_width (default: 800): final image width in px + - final_height (default: 800): final image height in px + - bg_color (default: (0,0,0)): tuple of the rgb color for the background + - final_border_px (default: 20): the border to leave around the picture in px + - line_width (default: 5): line thickness (px) + - verbose (boolean) (default: False): if True prints the coordinates and other stuff in the command line + """ 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 + # color argument requires also alpha value + self.bg_color = bg_color if len(bg_color) == 4 else (*bg_color, 0) self.line_color = line_color self.final_border_px = final_border_px self.line_width = line_width @@ -35,12 +49,15 @@ def update_device(self, device): if self.is_scara(): # scara robot conversion factor # should be 2*pi/6 *1/2 (conversion between radians and motor units * 1/2 coming out of the theta alpha semplification) - self.pi_conversion = pi/float(device["angle_conversion_factor"]) # for scara robots follow https://forum.v1engineering.com/t/sandtrails-a-polar-sand-table/16844/61 + # for scara robots follow https://forum.v1engineering.com/t/sandtrails-a-polar-sand-table/16844/61 + self.pi_conversion = pi / float(device["angle_conversion_factor"]) self.device_radius = float(device["radius"]) - self.offset_1 = float(device["offset_angle_1"]) * 2 # *2 for the conversion factor (will spare one operation in the loop) - self.offset_2 = float(device["offset_angle_2"]) * 2 # *2 for the conversion factor (will spare one operation in the loop) + # *2 for the conversion factor (will spare one operation in the loop) + self.offset_1 = float(device["offset_angle_1"]) * 2 + # *2 for the conversion factor (will spare one operation in the loop) + self.offset_2 = float(device["offset_angle_2"]) * 2 elif self.is_polar(): - self.pi_conversion = 2.0*pi/float(device["angle_conversion_factor"]) + self.pi_conversion = 2.0 * pi / float(device["angle_conversion_factor"]) self.device_radius = float(device["radius"]) self.offset_1 = float(device["offset_angle_1"]) @@ -49,82 +66,86 @@ def is_cartesian(self): def is_polar(self): return self.device["type"] == "POLAR" - + def is_scara(self): return self.device["type"] == "SCARA" - # converts a gcode file to an image - # requires: gcode file (not filepath) - # return the image file def gcode_to_coords(self, file): + """ + converts a gcode file to an image + requires: gcode file (not filepath) + return the image file + + """ + total_lenght = 0 coords = [] - xmin = 100000 + xmin = 100000 xmax = -100000 - ymin = 100000 + ymin = 100000 ymax = -100000 old_X = 0 old_Y = 0 for line in file: # skipping comments - if line.startswith(";"): + if line.startswith(";"): continue - + # remove inline comments if ";" in line: line = line.split(";")[0] - if len(line) <3: + if len(line) < 3: continue # parsing line params = line.split(" ") - if not (params[0] in self.straight_lines): # TODO include also G2 and other curves command? - if(self.verbose): - print("Skipping line: "+line) + if not (params[0] in self.straight_lines): + # TODO include also G2 and other curves command? + if self.verbose: + print("Skipping line: " + line) continue - com_X = old_X # command X value - com_Y = old_Y # command Y value + com_X = old_X # command X value + com_Y = old_Y # command Y value # selecting values for p in params: - if p[0]=="X": + if p[0] == "X": com_X = float(p[1:]) - if p[0]=="Y": + if p[0] == "Y": com_Y = float(p[1:]) - + # calculates incremental lenght - total_lenght += sqrt(com_X**2 + com_Y**2) - + 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 if self.is_scara(): # m1 = thehta+alpha - # m2 = theta-alpha - # -> + # m2 = theta-alpha + # -> # theta = (m1 + m2)/2 - # alpha = (m1-m2)/2 + # alpha = (m1-m2)/2 # (moving /2 into the pi_conversion to reduce the number of multiplications) theta = (com_X + com_Y + self.offset_1) * self.pi_conversion - rho = cos((com_X - com_Y + self.offset_2) * self.pi_conversion) * self.device_radius + 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 # uses - to remove preview mirroring + 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 + 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 - - if xxmax: + if x > xmax: xmax = x - if yymax: + if y > ymax: ymax = y - c = (x,y) + c = (x, y) coords.append(c) old_X = com_X old_Y = com_Y @@ -132,60 +153,71 @@ def gcode_to_coords(self, file): print("Coordinates:") print(coords) print("XMIN:{}, XMAX:{}, YMIN:{}, YMAX:{}".format(xmin, xmax, ymin, ymax)) + drawing_infos = { "total_lenght": total_lenght, "xmin": xmin, "xmax": xmax, "ymin": ymin, - "ymax": ymax + "ymax": ymax, } # return the image obtained from the coordinates 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, drawing_infos): + """ + Draws an image with the given coordinates (array of tuple of points) and the extremes of the points + + """ limits = DotMap(drawing_infos) # Make the image larger than needed so can apply antialiasing factor = 5.0 - img_width = self.final_width*factor - img_height = self.final_height*factor - border_px = self.final_border_px*factor - image = Image.new('RGB', (int(img_width), int(img_height)), color=self.bg_color) + img_width = self.final_width * factor + img_height = self.final_height * factor + border_px = self.final_border_px * factor + image = Image.new("RGB", (int(img_width), int(img_height)), color=self.bg_color) d = ImageDraw.Draw(image) - rangex = limits.xmax-limits.xmin - rangey = limits.ymax-limits.ymin - scaleX = float(img_width - border_px*2)/rangex - scaleY = float(img_height - border_px*2)/rangey + rangex = limits.xmax - limits.xmin + rangey = limits.ymax - limits.ymin + scaleX = float(img_width - border_px * 2) / rangex + scaleY = float(img_height - border_px * 2) / rangey scale = min(scaleX, scaleY) def remapx(value): - return int((value-limits.xmin)*scale + border_px) - + return int((value - limits.xmin) * scale + border_px) + def remapy(value): - return int(img_height-((value-limits.ymin)*scale + border_px)) - + return int(img_height - ((value - limits.ymin) * scale + border_px)) + p_1 = coords[0] - self.circle(d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width*factor/2, self.line_color) # draw a circle to make round corners - for p in coords[1:]: # create the line between two consecutive coordinates - d.line([remapx(p_1[0]), remapy(p_1[1]), remapx(p[0]), remapy(p[1])], \ - fill=self.line_color, width=int(self.line_width*factor)) + self.circle( + d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width * factor / 2, self.line_color + ) # draw a circle to make round corners + for p in coords[1:]: # create the line between two consecutive coordinates + d.line( + [remapx(p_1[0]), remapy(p_1[1]), remapx(p[0]), remapy(p[1])], + fill=self.line_color, + width=int(self.line_width * factor), + ) if self.verbose: print("coord: {} _ {}".format(remapx(p_1[0]), remapy(p_1[1]))) p_1 = p - self.circle(d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width*factor/2, self.line_color) # draw a circle to make round corners + self.circle( + d, (remapx(p_1[0]), remapy(p_1[1])), self.line_width * factor / 2, self.line_color + ) # draw a circle to make round corners # Resize the image to the final dimension to use antialiasing image = image.resize((int(self.final_width), int(self.final_height)), Image.ANTIALIAS) return image def circle(self, d, c, r, color): - d.ellipse([c[0]-r, c[1]-r, c[0]+r, c[1]+r], fill=color, outline=None) + d.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], fill=color, outline=None) def thr_to_image(self, file): pass + if __name__ == "__main__": # testing scara device = { @@ -193,18 +225,16 @@ def thr_to_image(self, file): "angle_conversion_factor": 6.0, "radius": 200, "offset_angle": -1.5, - "offset_angle_2": 1.5 + "offset_angle_2": 1.5, } factory = ImageFactory(device, verbose=True) - with open('server/utils/test_scara.gcode') as file: + with open("server/utils/test_scara.gcode") as file: im = factory.gcode_to_image(file) im.show() - + # testing cartesian - device = { - "type": "Cartesian" - } + device = {"type": "Cartesian"} factory = ImageFactory(device, verbose=True) - with open('server/utils/test_cartesian.gcode') as file: + with open("server/utils/test_cartesian.gcode") as file: im = factory.gcode_to_image(file) - im.show() \ No newline at end of file + im.show() diff --git a/server/utils/limited_size_dict.py b/server/utils/limited_size_dict.py index 4d4679e3..08fe981e 100644 --- a/server/utils/limited_size_dict.py +++ b/server/utils/limited_size_dict.py @@ -1,9 +1,13 @@ from collections import OrderedDict -# This dict class can have a size limit -# Every time a new item is added to the dict, the oldest will be removed class LimitedSizeDict(OrderedDict): + """ + This dict class can have a size limit + Every time a new item is added to the dict, the oldest will be removed + + """ + def __init__(self, *args, **kwds): self.size_limit = kwds.pop("size_limit", None) OrderedDict.__init__(self, *args, **kwds) diff --git a/server/utils/logging_utils.py b/server/utils/logging_utils.py index 4bb22844..1899e1db 100644 --- a/server/utils/logging_utils.py +++ b/server/utils/logging_utils.py @@ -1,33 +1,38 @@ from logging import Formatter import logging -from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener -from queue import Queue -from multiprocessing import RLock +from logging.handlers import RotatingFileHandler 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): + """ + Multiprocessing rotating gile handler + https://stackoverflow.com/questions/32099378/python-multiprocessing-logging-queuehandler-with-rotatingfilehandler-file-bein + """ + def __init__(self, *kargs, **kwargs): super(MultiprocessRotatingFileHandler, self).__init__(*kargs, **kwargs) - # 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): + """Rotate to a new file""" + # 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 shutil.copyfile(source, dest) - f = open(source, 'r+') + f = open(source, "r+", encoding="utf-8") 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 = MultiprocessRotatingFileHandler("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) server_stream_handler = logging.StreamHandler() server_stream_handler.setLevel(logging.INFO) -server_stream_handler.setFormatter(formatter) \ No newline at end of file +server_stream_handler.setFormatter(formatter) diff --git a/server/utils/settings_utils.py b/server/utils/settings_utils.py index 2638f826..947e5fd1 100644 --- a/server/utils/settings_utils.py +++ b/server/utils/settings_utils.py @@ -3,7 +3,6 @@ import json import logging import platform -from netifaces import interfaces, ifaddresses, AF_INET # Logging levels (see the documentation of the logging module for more details) LINE_SENT = 6 @@ -14,64 +13,62 @@ settings_path = "./server/saves/saved_settings.json" defaults_path = "./server/saves/default_settings.json" -OVERWRITE_FIELDS = [ - "available_values", - "depends_on", - "depends_values", - "tip", - "label" -] +OVERWRITE_FIELDS = ["available_values", "depends_on", "depends_values", "tip", "label"] + def save_settings(settings): dataj = json.dumps(settings, indent=4) - with open(settings_path,"w") as f: + with open(settings_path, "w") as f: f.write(dataj) + def load_settings(): settings = "" tmp_settings_path = settings_path - if not os.path.isfile(settings_path): # for python tests + if not os.path.isfile(settings_path): # for python tests tmp_settings_path = defaults_path with open(tmp_settings_path) as f: - settings = json.load(f) + settings = json.load(f) settings["system"] = {} settings["system"]["is_linux"] = platform.system() == "Linux" return settings - + + def update_settings_file_version(): logging.info("Updating settings save files") - if(not os.path.exists(settings_path)): + if not os.path.exists(settings_path): shutil.copyfile(defaults_path, settings_path) else: old_settings = load_settings() # compatibility check for older versions of the settings # older format of the settings is not compatible with the newer one, thus it must delete the settings. TODO Should remove this line after a while (I will give 3 months thus until 05/2021)... The first versions will not be installed on many devices - if not type(old_settings["serial"]["port"]) is dict: + if not type(old_settings["serial"]["port"]) is dict: shutil.copyfile(defaults_path, settings_path) - - + def_settings = "" with open(defaults_path) as f: def_settings = json.load(f) new_settings = match_dict(old_settings, def_settings) save_settings(new_settings) - + + def match_dict(mod_dict, ref_dict): if type(ref_dict) is dict: if not type(mod_dict) is dict: - return ref_dict # if the old field was not a dict but a single value must return the new dict because cannot convert a single value into a dict - - new_dict = dict(mod_dict) # clone object + return ref_dict # if the old field was not a dict but a single value must return the new dict because cannot convert a single value into a dict + + new_dict = dict(mod_dict) # clone object for k in ref_dict.keys(): if (not k in new_dict) or (k in OVERWRITE_FIELDS): - new_dict[k] = ref_dict[k] # if key is not set, adds the key as an empty dict + new_dict[k] = ref_dict[k] # if key is not set, adds the key as an empty dict else: new_dict[k] = match_dict(new_dict[k], ref_dict[k]) return new_dict else: return mod_dict + def get_only_values(ref_dict): res = {} if not type(ref_dict) is dict: @@ -84,8 +81,10 @@ def get_only_values(ref_dict): res[i] = get_only_values(ref_dict[i]) return res -# print the level of the logger selected + def print_level(level, logger_name): + """Print the level of the logger selected""" + description = "" if level < LINE_SERVICE: description = "NOT SET" @@ -107,35 +106,22 @@ def print_level(level, logger_name): description = "CRITICAL" print("Logger '{}' level: {} ({})".format(logger_name, level, description)) -def get_ip4_addresses(): - ip_list = [] - for interface in interfaces(): - try: - for link in ifaddresses(interface)[AF_INET]: - ip_list.append(link['addr']) - except: - # if the interface is whitout ipv4 adresses can just pass - pass - - return ip_list - # To run it must be in "$(env) server>" and use "python utils/settings_utils.py" if __name__ == "__main__": # testing update_settings_file_version - settings_path = "../"+settings_path - defaults_path = "../"+defaults_path + settings_path = "../" + settings_path + defaults_path = "../" + defaults_path - a = {"a":0, "b":{"c":2, "d":4}, "d":5} - b = {"a":1, "b":{"c":1, "e":5}, "c":3} - c = match_dict(a,b) + a = {"a": 0, "b": {"c": 2, "d": 4}, "d": 5} + b = {"a": 1, "b": {"c": 1, "e": 5}, "c": 3} + c = match_dict(a, b) print(a) print(b) print(c) - print(c=={"a":0, "b":{"c":2, "d":4, "e":5}, "d":5, "c":3}) + print(c == {"a": 0, "b": {"c": 2, "d": 4, "e": 5}, "d": 5, "c": 3}) update_settings_file_version() - print(get_ip4_addresses()) - d = {"a":500, "b":{"asf":3, "value":10}, "c":{"d":{"fds":29, "value":32}}} - print(get_only_values(d)) \ No newline at end of file + d = {"a": 500, "b": {"asf": 3, "value": 10}, "c": {"d": {"fds": 29, "value": 32}}} + print(get_only_values(d)) diff --git a/server/utils/software_updates.py b/server/utils/software_updates.py index 0bcfdd90..8255828c 100644 --- a/server/utils/software_updates.py +++ b/server/utils/software_updates.py @@ -3,38 +3,45 @@ from dotenv import load_dotenv + def get_commit_shash(): res = {} with open("git_shash.json", "r") as f: res = json.load(f) return res["shash"] + # Checks if the docker compose file is at the latest version def check_docker_compose_latest_version(): load_dotenv() if not os.getenv("IS_DOCKER", default=None) is None: - return os.getenv("DOCKER_COMPOSE_FILE_VERSION") == os.getenv("DOCKER_COMPOSE_FILE_EXPECTED_VERSION") + return os.getenv("DOCKER_COMPOSE_FILE_VERSION") == os.getenv( + "DOCKER_COMPOSE_FILE_EXPECTED_VERSION" + ) return True + AUTOUPDATE_FILE_PATH = "./server/saves/autoupdate.txt" -class UpdatesManager(): + +class UpdatesManager: def __init__(self): self.short_hash = get_commit_shash() self.docker_compose_latest_version = check_docker_compose_latest_version() - + def autoupdate(self, enabled=True): if enabled and not self.is_autoupdate_enabled(): with open(AUTOUPDATE_FILE_PATH, "w"): pass if not enabled and self.is_autoupdate_enabled(): os.remove(AUTOUPDATE_FILE_PATH) - + def toggle_autoupdate(self): self.autoupdate(not self.is_autoupdate_enabled()) - + def is_autoupdate_enabled(self): return os.path.exists(AUTOUPDATE_FILE_PATH) + if __name__ == "__main__": - pass \ No newline at end of file + pass diff --git a/server/utils/stats.py b/server/utils/stats.py index 1850ce07..d76abe97 100644 --- a/server/utils/stats.py +++ b/server/utils/stats.py @@ -4,38 +4,40 @@ import os STATS_PATH = "./server/saves/stats.json" -SLEEP_TIME = 60 # will keep updating the "on_time" every n seconds +SLEEP_TIME = 60 # will keep updating the "on_time" every n seconds INIT_DICT = { - "last_on": 0.0, # last timestamp at wich the device was on [s] - "total_length": 0.0, # total lenght run by the sphere [mm] - "run_time": 0.0, # motors run time [s] - "on_time": 0.0 # total device on time [s] + "last_on": 0.0, # last timestamp at wich the device was on [s] + "total_length": 0.0, # total lenght run by the sphere [mm] + "run_time": 0.0, # motors run time [s] + "on_time": 0.0, # total device on time [s] } + def load_stats(): - if not os.path.isfile(STATS_PATH): + if not os.path.isfile(STATS_PATH): stats = INIT_DICT - else: + else: with open(STATS_PATH) as f: - stats = json.load(f) + stats = json.load(f) return stats + def save_stats(stats): dataj = json.dumps(stats, indent=4) with open(STATS_PATH, "w") as f: f.write(dataj) -class StatsManager(): + +class StatsManager: def __init__(self): self.stats = load_stats() self.stats["last_on"] = time() self.start_time = 0 self._mutex = Lock() - self._th = Thread(target = self._thf) + self._th = Thread(target=self._thf, daemon=True) self._th.name = "stats_manager" - self._th.daemon = True self._th.start() - + def drawing_started(self): with self._mutex: self.start_time = time() @@ -61,6 +63,7 @@ def _thf(self): self._update_stats() sleep(SLEEP_TIME) + if __name__ == "__main__": sm = StatsManager() sm.drawing_started() @@ -68,4 +71,4 @@ def _thf(self): for i in range(WAIT_SECONDS): print(f"Waiting {WAIT_SECONDS-i} more seconds") sleep(1) - sm.drawing_ended(10.4) \ No newline at end of file + sm.drawing_ended(10.4) diff --git a/todos.md b/todos.md index 015cbdc6..fb8ed2f6 100644 --- a/todos.md +++ b/todos.md @@ -3,12 +3,10 @@ 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. * 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) * add the possibility to select playlists instead of drawings in the autostart settings -* add the possibility to start/stop/pause with hw buttons (something like: in the settings page possibility to add pins and let select what the pin does from a select list) * add leds control animations * add an leds control element for the playlists * create some automatic tests (both python and js) to validate the software before merging