Repository: prusa3d/Prusa-Link Branch: master Commit: 85746ebf2fab Files: 142 Total size: 1.1 MB Directory structure: gitextract_3glbe_4j/ ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── pages.yml │ └── python-tests.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .pylintrc ├── CONTRIBUTION.md ├── ChangeLog ├── MANIFEST.in ├── MULTIINSTANCE.md ├── README.md ├── config.custom.js ├── docs/ │ ├── Makefile │ ├── prusalink_states.txt │ └── wizard.txt ├── image_builder/ │ ├── __init__.py │ └── image_builder.py ├── prusa/ │ └── link/ │ ├── __init__.py │ ├── __main__.py │ ├── camera_governor.py │ ├── cameras/ │ │ ├── __init__.py │ │ ├── encoders.py │ │ ├── picamera_driver.py │ │ ├── v4l2.py │ │ └── v4l2_driver.py │ ├── conditions.py │ ├── config.py │ ├── const.py │ ├── daemon.py │ ├── data/ │ │ ├── image_builder/ │ │ │ ├── boot-message.service │ │ │ ├── first-boot.sh │ │ │ ├── manager-start-script.sh │ │ │ └── prusalink-start-script.sh │ │ └── prusalink.ini │ ├── interesting_logger.py │ ├── multi_instance/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── config_component.py │ │ ├── const.py │ │ ├── controller.py │ │ ├── ipc_queue_adapter.py │ │ ├── runner_component.py │ │ └── web.py │ ├── printer_adapter/ │ │ ├── __init__.py │ │ ├── auto_telemetry.py │ │ ├── command.py │ │ ├── command_handlers.py │ │ ├── command_queue.py │ │ ├── file_printer.py │ │ ├── filesystem/ │ │ │ ├── __init__.py │ │ │ ├── sd_card.py │ │ │ ├── storage.py │ │ │ └── storage_controller.py │ │ ├── ip_updater.py │ │ ├── job.py │ │ ├── keepalive.py │ │ ├── lcd_printer.py │ │ ├── mmu_observer.py │ │ ├── model.py │ │ ├── print_stat_doubler.py │ │ ├── print_stats.py │ │ ├── printer_polling.py │ │ ├── prusa_link.py │ │ ├── py.typed │ │ ├── special_commands.py │ │ ├── state_manager.py │ │ ├── structures/ │ │ │ ├── __init__.py │ │ │ ├── carousel.py │ │ │ ├── heap.py │ │ │ ├── item_updater.py │ │ │ ├── mc_singleton.py │ │ │ ├── model_classes.py │ │ │ ├── module_data_classes.py │ │ │ └── regular_expressions.py │ │ ├── telemetry_passer.py │ │ └── updatable.py │ ├── sdk_augmentation/ │ │ ├── __init__.py │ │ ├── command_handler.py │ │ ├── file.py │ │ └── printer.py │ ├── serial/ │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── instruction.py │ │ ├── is_planner_fed.py │ │ ├── serial.py │ │ ├── serial_adapter.py │ │ ├── serial_parser.py │ │ └── serial_queue.py │ ├── service_discovery.py │ ├── static/ │ │ ├── css/ │ │ │ ├── bootstrap.connect.css │ │ │ └── bootstrap.prusa-link.css │ │ ├── index.html │ │ ├── main.9b8dc0068f6e6508dfd4.js │ │ └── main.b3e029296dd89863b3f2.css │ ├── templates/ │ │ ├── _footer.html │ │ ├── _header.html │ │ ├── _wizard.html │ │ ├── error-gone.html │ │ ├── error-internal-server-error.html │ │ ├── error.html │ │ ├── index.html │ │ ├── link_info.html │ │ ├── multi-instance.html │ │ ├── wizard.html │ │ ├── wizard_credentials.html │ │ ├── wizard_finish.html │ │ ├── wizard_printer.html │ │ ├── wizard_restore.html │ │ └── wizard_serial.html │ ├── util.py │ └── web/ │ ├── __init__.py │ ├── cameras.py │ ├── connection.py │ ├── controls.py │ ├── errors.py │ ├── files.py │ ├── files_legacy.py │ ├── lib/ │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── classes.py │ │ ├── core.py │ │ ├── files.py │ │ ├── view.py │ │ └── wizard.py │ ├── link_info.py │ ├── main.py │ ├── settings.py │ └── wizard.py ├── prusalink-boot ├── public/ │ └── prusalink.json ├── requirements-multi.txt ├── requirements-pi.txt ├── requirements.txt ├── ruff.toml ├── setup.py └── tests/ ├── __init__.py ├── test_carousel.py ├── test_ipc_queue.py ├── test_item_updater.py ├── test_serial_parser.py └── util.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig root = true # elementary defaults [*] charset = utf-8 end_of_line = lf indent_size = tab indent_style = space insert_final_newline = true max_line_length = 80 tab_width = 4 # Markup files [{*.html,*.xml,*.yaml,*.yml}] tab_width = 2 ================================================ FILE: .github/workflows/pages.yml ================================================ name: Deploy to GitHub Pages on: push: branches: [master] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - uses: actions/configure-pages@v4 - uses: actions/upload-pages-artifact@v3 with: path: public - id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/python-tests.yml ================================================ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python tests on: [push] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.9" - "3.11" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install system dependencies run: | sudo apt-get update && sudo apt-get install -y libcap-dev libturbojpeg - name: Install dependencies run: | python -m pip install --upgrade pip pip install -U -r requirements.txt pip install -U -r requirements-multi.txt pip install pylint~=2.17.7 pip install -U ruff pytest pytest-doctestplus pytest-pylint pytest-mypy mock pip install --force-reinstall git+https://github.com/prusa3d/prusa-connect-sdk-printer.git pip install --force-reinstall git+https://github.com/prusa3d/gcode-metadata.git - name: Lint with ruff run: | ruff check . - name: Lint with pylit run: | PYTHONPATH=`pwd` pytest -v --mypy --pylint --doctest-plus --doctest-rst prusa/link - name: Tests run: | PYTHONPATH=`pwd` pytest -v --mypy --pylint --doctest-plus --doctest-rst tests ================================================ FILE: .gitignore ================================================ venv .idea .env __pycache__ *.pyc build dist *.egg-info *.orig .hypothesis *.coverage htmlcov docs/*.png ================================================ FILE: .gitmodules ================================================ [submodule "prusa-link-web"] path = Prusa-Link-Web url = https://github.com/prusa3d/Prusa-Link-Web.git [submodule "PiShrink"] path = PiShrink url = https://github.com/Drewsif/PiShrink ================================================ FILE: .pre-commit-config.yaml ================================================ --- repos: # - repo: https://github.com/pre-commit/mirrors-yapf # rev: 'v0.32.0' # Use the sha / tag you want to point at # hooks: # - id: yapf - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace # - repo: https://github.com/pre-commit/mirrors-pylint # rev: v2.4.4 # hooks: # - id: pylint - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.3.5' hooks: - id: ruff args: [--fix] - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 ================================================ FILE: .pylintrc ================================================ [BASIC] good-names=i,l,f,sn,ip [TYPECHECK] generated-members=prctl [MASTER] extension-pkg-whitelist=pydantic ignore-patterns=.*/v4l2.py [MESSAGES CONTROL] disable= fixme, unsubscriptable-object, too-few-public-methods, too-many-instance-attributes, too-many-public-methods, wrong-import-order ================================================ FILE: CONTRIBUTION.md ================================================ # Contribution ## Developement **On Rasbian**: Running on foreground without install: ```sh python3 -m prusa.link -f ``` **From desktop**: `/dev/ttyACM0` can be USB <-> UART printer port ```sh python3 -m prusa.link -f -s /dev/ttyACM0 ``` When you install `socat` tool to RaspberryPi Zero, you can create use virtual TCP <-> UART port. ``` socat PTY,link=$HOME/ttyAMA0,raw,wait-slave EXEC:'"ssh pi@prusa-link socat - /dev/ttyAMA0,nonblock,raw"' # in another terminal python3 -m prusa.link -f -s $HOME/ttyAMA0 ``` **Own static files**: ```sh PRUSA_LINK_STATIC=./my_static python3 -m prusa.link -f ``` **Communication debug**: prusalink -f -I -i -l urllib3.connectionpool=DEBUG -l connect-printer=DEBUG ================================================ FILE: ChangeLog ================================================ # ChangeLog 0.8.2 (2024-12-18) * Fix crc issue 0.8.1 (2024-06-28) * Add a v4l2 workaround so broken camera handles are disqualified from scan 0.8.0 (2024-06-27) * Rpi5 first boot fix * Fix power panic stuck at Resend * Fix startup issues on RPi 5 * Wait a bit for printer to finish moves after waking up from power panic * Sync files upon Upload finish * Support newer "//action" without the space * Fix the new image missing the libcamera dependency * Improve message shown when preparing a power panic recovery * Fix print stats not ignoring the skipped part of gcode after PP * Ensure power panic info is written to persistent storage * Fix active tool not re-setting to None when told to * Use printer mid-movement power panic recovery trick * Fix the multi-instance proxy. Again * Stop showing the tune menu after connecting to the printer * Use the 2023-10-10 Raspberry Pi OS base image for the image builder * Decrease the severity of web server access log messages * Add a way to download logs even on systemd journal based systems * Bump the API version so it is the same as the xBuddy reported one * Support the new multi-level telemetry data structure * Don't send over serial when temperature calibration is running * Periodically send a keepalive gcode to keep the printer in PrusaLink mode * Support the set ready and cancel ready LCD menu toggle * Add gcodes that flag the state of a usb print for the printer statistics to get saved * Handle the new re-print LCD menu item * Add the initial support for the MMU * Add Power Panic support * Make ->Ready to recover dissapear sooner from the LCD * The minimum firmwre version has been increased to 3.14.0 * Fix the multi-instance proxy 0.7.2 (2023-10-11) * Add an automatic PrusaLink image builder script * Add multi-instance documentation * Telemetry improvement * Attempt to turn the RPi wifi power management off in the images 0.7.1rc1 (2023-08-10) * Fixed HTTP response status on HEAD request for non-existing files * Attribute ro renamed to read_only * Fix printer returning to READY instead of IDLE * Respect the X-Forwarded-Prefix header in Wizard * Add focus support for the v4l2 cameras * Add a multi-instance web waypoint, use a reverse proxy to navigate to the correct instances * Automatically redirect from the multi-instance waypoint if exactly one printer is configured * Fix missing error detail pages * Fix an error when dosconnecting from a cammera that failed to connect * Fix an intermittent error that was killing instances for like two years * Differentiate between multi instance printers by showing the printer number on the LCD screen * Static Web update - Fix behavior on SD card ejection - Add simple camera focus control if supported - Add default value for connect hostname - Update translations - Add tooltips for error status texts in telemetry - Add confirmation modal to overwrite a file by upload - Fix undefined printer states displaying - Hide zero temperatures in telemetry - Add support to be running behind the proxy - Fix folder deletion button - Fix prusa connect port handling in URL builder * Hold the STOPPED and FINISHED state for at least 11s * Fix MK2(.5)S SN being broken on multi-instance images * Implemented UPGRADE high lvl Connect command 0.7.0 (2023-05-19) * Fixed printer sends info about api key change to Connect after change * Added the network error beep setting to the web API * Support renaming gcodes directory (cfg and API) * Added a multi-instace auto config and starter utility * Disable error beeps during prints * Static Web update - Using v1 endpoints for job and transfer - Creating and Deleting folders - Translation update - Network error chime control - Default storage names - Upgrade procedure rework 0.7.0rc3 (2023-03-09) * Added v1 endpoints for flat filesystem structure, old struct is moved to files_legacy file * Added api/v1/update/ GET endpoint * Printer name and location are added to register url as query parameters, if available * Static Web update - Apply UI/UX refactoring - Add support for max-age cache control - Add control of last-modified header for snapshots - Add drop zone to files storage - Remove manual camera connection dialog - Migrate to files api v1 - PrusaLink update * Added Force header to api/v1/files// DELETE endpoint for deletion of non-empty folder * Changed Print-After-Upload header value check for PUT * Added endpoint to start printing file * Removed the original picamera driver * Raspberry Pi Camera support utilizing libcamera directly * Hardware encoding support. (Pi Zero W manages FullHD snapshots without issues) * Fix "unicam" appearing when a Raspberry Pi camera is connected * Fix not following the configured resolution * Added api/v1/status endpoint * Added new endpoint for updating prusalink python package * Added api/v1/transfer endpoint 0.7.0rc2 (2022-12-09) * Support thermal model errors (FW 3.12) * USB Camera * SD Card fixes * Fix MBL data for MK2.5(S) * Wizard refactoring * Static web update - Cameras - File sorting - Stop dialog fixed - Connect status * API Settings moved from Wizard to Settings * Raspberry Pi Camera support * Added cache control headers for cameras snap endpoints * Fixed PUT upload when folders within the path does not exist * Cameras! Support for: - V4L2 cameras (webcams - MJPEG and YUYV formats supported) - picamera2 (libcamera stack) (slow) - Changing the resolution - Camera auto-detection - Triggering on layer change (PrusaSlicer sliced files only!) * Fix files with uppercase extensions not showing up locally * Support "hotend fan" = "extruder fan" * Re-send the complete telemetry every five minutes * Fix stats missing for prints of gcodes without M73 * Fix pause being able to double print time reported * Fixed error when trying to get space info of SD Card 0.7.0rc1 (2022-09-13) * Work around a bug: printer in serial print mode while wizard is shown * READY state changed to IDLE, PREPARED state changed to READY * New status display - notifies about setup wizard, - shows upload progress - shows the name of a file being printed - notifies about errors - shows an idle screen with the IP address after 30min - add idle screen and show transfers during print pauses * Name and location of printer value validation * Fix negative timeout being possible in serial read * Additional Connect (un)registration support * File and Directory name validation refactoring * Fixed transfer and print in ATTENTION error * New Connect API support * Fix PrusaLink IP not getting reset from printer on shutdown * Fix the serial_number step in wizard * Fix unicode characters in file names breaking lcd printer * Make RESET_PRINTER clear the command queue and have priority that way * Made the app stop itself faster * Use M400 instead of G4 for printer queue syncing * Reworked validation of correct S/N write * Modified username length and password length and format validation * Use "Sync|->:" and "Sync->|:" to signify which way is the current transfer going * Add DNS service discovery compatible with PrusaSlicer * Support file upload cancels from PrusaSlicer * Static web update: - Fix big log files displaying - Decrease display log file size limit to 1M - Change temperature controls widget number format to display integers - Add stop/resume print button - Add protection from steppers disabling when printing - Fix sidebar width - Replace PNG icons with SVG - Fix router, telemetry graph dinmensions and page layout - Update error handling to avoid duplicates of popups - Add support for file extensions provided by printer profiles from API - Fix display names of origins - New application design - New field to rename project file uploaded by URL - New widget displaying used/free size (not-connected to printer yet) - New Rename and Copy actions (hidden) - New tool to unify icons colors - Updated free space logic - Fixed storage tabs behavior - Avoid unnecessary requests to BE for file metadata - Hardcode storages list to printers - Removed page `Temperatures` - Fix formatting of percentages - Project preview is now not dependent on `/api/job` endpoint - Confirm dialog after uploade via drag zone - Nozzle diameter - Offline mode - Connect Like icons - Translaction fallbacks * Differentiate between FW and ID errors in the wizard, update texts * Fixed download ref, added total storage space info * Added storage space info to api/printer * Added function for save file with custom name * Add dynamic download throttling when printing * Added caching for thumbnail images * Send printer info on printer reset / info invalidation event * Fixed error handling for PrusaLink Web * Reset print stats after a print ends * Fix print fail from a unchecked print buffer underflow * Report mesh bed levelling data * Use the print mode to report the right print stats row to connect * Make sure fan errors send reason, improve their behavior a little * Fix SD Card module race conditions * Make it possible to hide certain loggers from interesting log * Filter telemetry, send only what's "significantly" changed * Fixed maximum temperature check for nozzle and heatbed * Api-Key is implicitly None, can be set in wizard or using endpoint * Start PrusaLink even without a connection to the printer * Start sending telemetry slowly after a period of inactivity * Files can be printed without selecting first, fixed job printTime info in api/job * Don't wait for a printer to boot when running through the EINSY pins * Added api/v1/info endpoint * Add printer statistics tracking * Add time to filament change tracking * Add sheet settings tracking * Return a better reason when print of a non existent file is requested * Make printer settings reflect the actual printer type * Fixed doubled gcode extensions when custom name is used * Added nozzle diameter info to api/v1/info * TLS is changed from int to bool * Added endpoint for capture an image from a camera * Fixed check for negative temperatures of nozzle and bed * Add a special SD menu to set the printer to READY from the LCD * Add boot partition config copy script (for RPis) * Added endpoint api/v1/storage with storage info * Round auto guessed preheat temps to the nearest five * Remove any irrelevant telemetry right on state change * Added endpoint api/v1// * Add automatic serial port scan * Use USB S/N if available (fixes MK2.5 SN issues) * Added endpoint with a list of available ports * Added capabilities flag to api/version * Added min extrusion temp to api/v1/info endpoint, fixed value * Added optional ro parameter to api/files and api/v1/{storage}/{path} endpoints * Added link_state parameter to api/printer endpoint * Fixed item updater allowing invalidation of a disabled item * Fixed upload PUT Print-After-Upload if already printing error * Added api/v1// delete endpoint * Fixed a semicolon in a filename being printed breaking everything * Fixed a bronken RESET_PRINTER for raspis connected through USB * API key option removed from wizard * Added endpoint for deletion of API key 0.6.0 (2021-12-17) * Added endpoint for control of extruder * Added endpoint for heatbed temperature control * Static web update - Add debug outputs to investigate project picture collision - Removed unnecessary colon after hostname in Dashboard - Switched from data.link to data.printer for settings end point - Add advanced upload widget - Printer Control Page - Add target temperatures to the left sidebar - Add possibility to send control values by Enter key press - Add serial number setting - Prevent api polling when previous requests were not handled - Prevent error messages flood in case of a connection problem - Optimize application loop - Add serial, CONNECT and communication state to the left side bar * Added size and date attributes to api/logs GET endpoint * Removed m_time file attribute * Added restriction for forbidden characters in uploaded file name * Added download and basic upload info to link-info page * Added and implemented JSON file with HW limits * Added api/printer/printhead GET endpoint * Changed variable firmware_version to firmware * Added LOAD/UNLOAD filament commands * Added disable_steppers command to api/printer/printhead * Implementation of farm_mode into api/settings endpoints * New Upload errors - check Content-Length header - check if file is uploaded complete - check storage free space first - errors refactoring - simple html errors * Changed args to kwargs for high level commands * HTTP Request handling improvement * own Serial class implementation (speed improvement) * Changed args to kwargs for execute_gcode command * log thread stack on interesting events * move job_id into the EEPROM * make STOP_PRINT wait for any of the READY, STOPPED or FINISHED states * Implementation of new Transfer object from SDK * Make a centralised wizard activation condition * Download finished callback implementation * Fix SD initialising always as PRESENT even when ABSENT 0.5.1 (2021-07-16) * Implementation of print after upload endpoint * Minimal suported firmware is 3.10.0 * Sort files directory first, newest first * Faster checking and processing when uploading gcode * Static web update - Upload gcode fixes - File browser is available when printer printes - Files are sort by printers API - Printed file widget rework - Fixed progress bar behaviour when printing is finished - Fixed error handling for periodic requests - Page heading is sticky now - Telemetry sidebar is sticky now - Added frontend version to the Settings page - Fixed 'undefined' error pop up heading in some cases - Log viewer - Login and password can be changed in settings - All not available project properties are hidden - Toaster messages are now sticky to window bottom - Fixed printing time estimations missmatching - Printer name and location can be changed in settings - Files with size above 100MB won't be loaded into textarea * React to thermal runaway by going into the error state * Use daemon type WSGI threads * Removed temporary gcode copy for printing * Support the new M20 attributes and their order * Fix progress equal to -1 not being supported * Fixed upload from local web * SEND_INFO hostname fixed * Fix SD Card file selection * Log HTTP requests and errors over Python Logger * Improve FW error message support * Work around print head returning to the print after Stop print * Added endpoint for download file from url * Password in plain text form is not stored in memory * Added endpoint for gettting info about the file currently being downloaded * Added endpoint for abort current download process * Require user attention after each print, even failed ones (if enabled) * Added checked and finished flags to api/printer * Added states structure to api/connection endpoint * Added Connect configuration info to api/connection endpoint * Added connection.py with api/connection GET, POST endpoints * Added api/settings GET endpoint * Added m_timestamp to SDCard files properties * Added api/settings POST endpoint, fix settings.py name * Fixed /api/printer flags * Implementation of gcode download endpoint * Added api/logs endpoint * Added api/logs/ endpoint * Added wizard/serial endpoints and page for setup S/N of the printer * Updated metadata for selected file * Require two "Not SD printing" to work around a SD printing bug * Added username and password change functionality to api/settings POST, fixed ChangeLog * Fixed SD Card metadata read * Go into the ERROR state when the printer stops responding for aprox. one minute * Added endpoint for regenerate api-key * Added api/settings/sn endpoint for setup S/N of the printer * Wizard is locked after successful configuration * Added endpoint for control of printhead movement * Added endpoint GET api/settings/sn 0.4.0 (2021-04-13) * getting IP refactoring * fix firmware version reading * working download gcode endpoint * command argument for profiling application * connection over VPN fix * Added additional network info 0.3.0 (2021-03-30) * Fixed broken command resends * Fixed state changed handler * Added new endpoint /api/connections with JSON response * Skipped pidfile when process is not alive * Added new endpoint /api/printer with JSON response * Fixed complaint in wizard about `api-key` when `username` was too short * Fix printer.sn being unset in the wizard by waiting for it * Fixed some telemetry being sent basically at random * Enabled the RESET_PRINTER command * Fixed printer resetting multiple times when it gets reset mid-print * Fixed accidentally hogging CPU when displaying LCD messages * Set log levels by module name in config or as command arguments * Report whether the current job is from the SD or not * Support long file names in the upcoming 3.10 release (file explorer only) * Added new endpoint /api/files with JSON response * Added new endpoint /api/job with JSON response * Added support for the new C parameter in M155 * Modification of /api/connection, files, printer and job endpoints * All files in data_dir (user's home by default) * Parse print info from the file name (for SD files) * Introduce ErrorState(s) from SDK * Modify `LCDPrinter` to show IP and status based on SDK error states * Support the nem M27 P * Fix not being able to print from root of SD when in a folder in LCD menu * Send 0% when a new print start is observed * Fix no progress being sent when SD print has no stats in its gcode * Support fan errors. Send reason for ATTENTION state in state change data * Support showing the IP address in the support menu using "M552 P" * /link-info debug page * SN is obtained always through the FW and isn't stored in a file anymore * Ensure M20 won't be sent during print. Ever * Start faster when already printing from SD * Don't store password in plaintext but use digest * Stop Wizard on printer errors. * Support the new STOPPED state * Use X-Api-Key or HTTP Digest for /api endpoints * hostname in /api/version * Fix /api/endpoints * Fix /api/files and /api/job endpoints * Nicer messages for Wlan errors; LCDPrinter now accesses the model for IP * Statics generated from submodule * Support pausing, resuming and stopping of serial prints from the LCD * Implementation of metadata into /api/files endpoint * Process all commands in a single thread -> racing avoided * Uploading from local web * Prusa Link version in INFO event * G-code preview and download endpoints * Thread names via prctl - can be show in htop * Shutdown fix * Report build number alongside firmware version * Added api commands for pause, stop and resume print job * If-Modified-Since and If-None-Match headers support for /api/files * Additional version info * Files in hidden folder are ignored * Report file names of SD prints better * Added endpoint for start print * LCD message modification (GO: ) * Fix connection errors causing the printer to report being in ERROR state * Add the possibility to log at debug level around interesting events * Distinguish wifi from lan * Implementation of select/print file functions from local web * File resource endpoint * working `start / stop / pause / resume` job * job info refactoring * endpoint for deleting file * fix job get / set 0.2.0 (2020-12-14) * JOB_INFO fix * Service must be start using daemon script prusa-link * Implicit config path is /etc/Prusa-Link/prusa-link.ini * Implicit settings path is {HOME}/prusa_printer_settings.ini * Wizard - part II * Api-Key in INFO event * Wizard redesign 0.1.3 (2020-12-01) * Report at least a file name for SD prints * Wizard - part I * Fix command handling and re-undo fw double-ok workarounds (FW commit gd167b3bd or newer is required) 0.1.2 (2020-11-23) * New FW (3.9.2.3566) required * local web service on http://IP-address:8080 * file upload from Prusa Slicer (use `PrusaSlicer` Api-Key) ================================================ FILE: MANIFEST.in ================================================ include *.py include MANIFEST.in include requirements.txt include requirements-pi.txt graft prusa/link/data graft prusa/link/templates graft prusa/link/static graft image_builder global-exclude *~ global-exclude *.swp prune venv ================================================ FILE: MULTIINSTANCE.md ================================================ # PrusaLink multi instance In this mode, an instance of PrusaLink is created for each new printer detected on any of the USB ports of the host system, allowing the user to connect multiple printers using a single Raspberry Pi. ## Setup The multi instance image requires the same setup as the regular one, but there are some differences 1) The multi-instance manager does not connect to printers on the GPIO pins as the device udev auto-detection in Linux does not work on those 2) Cameras automatically connect to the first instance only. If you wish to use for example a camera for each printer, you'll need to manually copy over relevant configuration 3) In this image, the manager of these PrusaLink instances is run as root. However web interface of the instance manager is run under the user account. ### Cameras The temporary process of connecting multiple cameras is not user friendly and requires manual work. This will change in the future. The process is as follows: 1) Connect all cameras you wish to use and let them connect to the first instance 2) Open the web interface of the first instance and under cameras, save every camera manually. This will create a configuration section for each camera in `prusa_printer_settings.ini` of the first instance 3) Using ssh, navigate to `/etc/prusalink/prusalink1.ini` and open it 4) Turn off the camera auto-detection in the first instance by adding this section into the file ``` [cameras] auto_detect = False ``` 5) Navigate to `/home//PrusaLink1` and open `prusa_printer_settings.ini` 6) Move the section corresponding to each camera over to the instance in which you wish to use it. The camera sections have hashes as names, the order of which is noted in the section `[camera_order]` 7) Move the camera order entry for each camera as well. A camera order section with a single camera in it looks like this ``` [camera_order] 1 = asdfghjkl ``` 8) After a reboot, the cameras should be connected to the correct instances ## Running the manager To run PrusaLink in the multi-instance mode run `prusalink-manager start` as root. There are other options allowing you to specify which user to run the instances and web under. The default is UID = 1000 Here's the help output of prusalink-manager ``` Multi-instance suite for PrusaLink positional arguments: {start,stop,clean,rescan} Available commands start Start the instance managing daemon (needs root privileges) stop Stop any manager daemon running (needs root privileges) clean Danger! cleans all PrusaLink multi-instance configuration rescan Notify the daemon a printer has been connected options: -h, --help show this help message and exit -i, --info include log messages up to the INFO level -d, --debug include log messages up to the INFO level -u USERNAME, --username USERNAME Which users to use for running and storing everything -p PREPEND_EXECUTABLES_WITH, --prepend-executables-with PREPEND_EXECUTABLES_WITH Environment variables and path to the executables directory ``` ================================================ FILE: README.md ================================================ # PrusaLink PrusaLink is a compatibility layer between 8-bit Prusa 3D printers (MK2.5, MK2.5S, MK3, MK3S and MK3S+) and PrusaConnect, which lets you control and monitor your 3D printer from anywhere. Get more info at [connect.prusa3d.com](https://connect.prusa3d.com/) PrusaLink also provides a local web interface: [Prusa-Link-Web](https://github.com/prusa3d/Prusa-Link-Web) ## Setup To use PrusaLink please follow our [Setup Guide](https://help.prusa3d.com/guide/prusalink-and-prusa-connect-mk3-s-_221744) ### Login If you wish to log into the console environment and haven't changed the credentials, you'll need these default ones: ``` username: jo password: raspberry ``` ## Dev Setup If using the Raspberry Pi pins, follow the guide above for the hardware preparation. Pins can be used even on regular (non-Zero) Pis through Dupont jumper cables. Just make sure those make proper contact with the Einsy board. A connection over USB is also possible, making PrusaLink compatible with pretty much any Linux system, but since the RPi has been used as a reference, please excuse the Debian specific instructions. If using the Pi, create your micro SD card the usual way, a Lite image will do nicely. Just in case, here's a guide: https://www.youtube.com/watch?v=ntaXWS8Lk34 ### UART over GPIO pins On some RPis, the main UART is handling Bluetooth, so the printer communication would get handled by a miniUART, which doesn't work for us. To disable Bluetooth, add these lines into `config.txt` which is located in the Pi's boot partition. ```ini [all] enable_uart=1 dtoverlay=disable-bt ``` ### Installation PrusaLink needs libpcap headers installed to name its OS threads. Git and Pip are needed for installation, while pigpio is only needed if the RPi GPIO pins are to be used. ```bash sudo apt install git python3-pip pigpio libcap-dev libmagic1 libturbojpeg0 libatlas-base-dev python3-numpy libffi-dev libopenblas0 # If you are using different distro (e.g. Ubuntu), use libturbojpeg library # instead of libturbojpeg0 # for the Raspberry Pi camera module support # pre-installed on the newer Raspberry Pi OS images post September 2022 sudo apt install -y python3-libcamera --no-install-recommends pip install PrusaLink # Or install straight from GitHub pip install git+https://github.com/prusa3d/gcode-metadata.git pip install git+https://github.com/prusa3d/Prusa-Connect-SDK-Printer.git pip install git+https://github.com/prusa3d/Prusa-Link.git ``` ## Config PrusaLink behavior can be altered using command arguments and configuration files. The default configuration path is `/etc/prusalink/prusalink.ini` and does not get created automatically. The configuration documentation can be found under `prusa/link/data/prusalink.ini`. The executable argument documentation is provided in the standard help text screen shown after running `prusalink --help` The `prusa_printer_settings.ini` file is created by the PrusaLink wizard, and can be downloaded from the PrusaConnect settings page once you register your printer. ### Configuring PrusaLink on the SD card If you need to manually configure PrusaLink on the SD created from our image, it now comes with an auto-copy script. Put your `prusalink.ini` or `prusa_printer_settings.ini` files into the boot portion of the SD, *That's the only one that shows up under Windows or Mac,* and they will get copied over to their default locations on the next boot. ### Permission denied Make sure the user you're running PrusaLink under is a member of the group **dialout**. To add it, run ```sudo usermod -a -G dialout ``` then log out and in with that user. ### Access on port 80 PrusaLink has a local web interface, to make it accessible on the default port 80, either start it as root and configure the user to which it should de-elevate itself after the web server is up, or start it as a normal user on port 8080 - or any other, then redirect the port 80 to the port PrusaLink is listening on using these commands. ### Running behind a reverse-proxy If you got a proxy that changes the URI path, add the X-Forwarded-Prefix header. PrusaLink will use it to construct the correct URLs for the web interface. ```bash # use -i to specify the interface affected iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080 ``` PrusaLink advertises itself on the local network. This makes it visible in PrusaSlicer under Physical Printers -> Browse. To advertise port 80, the instance has to be able to ping itself. This can be done by setting up a similar redirect on the loopback interface ```bash iptables -t nat -I OUTPUT -p tcp -o -d localhost --dport 80 -j REDIRECT --to-ports 8080 ``` ### Multi-instance If you want to connect multiple printers to a single pi, have a look at [MULTIINSTANCE.md](MULTIINSTANCE.md) ## Usage By default, the executable starts the daemon process and exits. The executable is called `prusalink` and can be used to control the daemon, if you want to run it in your terminal instead, use the `-f` option To get the most recent help screen use `prusalink --help`, here's what it says in 0.7.0 ``` usage: prusalink [-h] [-f] [-c ] [-p ] [-a
] [-t ] [-I] [-s ] [-i] [-d] [-l MODULE_LOG_LEVEL] [--profile] [command] PrusaLink daemon. positional arguments: command daemon action (start|stop|restart|status) (default: start) options: -h, --help show this help message and exit -f, --foreground run as script on foreground -c , --config path to config file (default: /etc/prusalink/prusalink.ini) -p , --pidfile path to pid file -a
, --address
IP listening address (host or IP) -t , --tcp-port TCP/IP listening port -I, --link-info /link-info debug page -s , --serial-port Serial (printer's) port or 'auto' -i, --info more verbose logging level INFO is set -d, --debug DEBUG logging level is set -l MODULE_LOG_LEVEL, --module-log-level MODULE_LOG_LEVEL sets the log level of any submodule(s). use = --profile Use cProfile for profiling application. ``` ================================================ FILE: config.custom.js ================================================ const webpackConfig = require("./webpack.config"); module.exports = (env, args) => { const config = { PRINTER_NAME: "Original Prusa i3", PRINTER_TYPE: "fdm", WITH_SETTINGS: true, WITH_CONTROLS: true, WITH_PROJECTS: true, WITH_LOGS: true, WITH_FONT: false, WITH_PRINT_BUTTON: true, WITH_V1_API: true, WITH_CAMERAS: true, WITH_DOWNLOAD_BUTTON: true, WITH_TELEMETRY_NOZZLE_DIAMETER: true, WITH_API_KEY_AUTH: false, WITH_API_KEY_SETTING: true, WITH_NAME_SORTING_ONLY: false, WITH_SYSTEM_UPDATES: true, WITH_SYSTEM_VERSION: true, WITH_PRINTER_SETTINGS: true, WITH_USER_SETTINGS: true, WITH_SERIAL: true, ...env, }; return webpackConfig(config, args); } ================================================ FILE: docs/Makefile ================================================ # # Makefile # Martin Užák, 2021-01-14 14:55 # UMLFILES = prusalink_states.txt wizard.txt uml: $(UMLFILES) plantuml $(UMLFILES) clean: rm -fv *png # vim:ft=make # ================================================ FILE: docs/prusalink_states.txt ================================================ @startuml Serial --> RPIenabled Serial: Serial Port exists RPIenabled --> IDPrinter RPIenabled: RPI Port is enabled / Device on serial port RPIenabled --> LCD LCD: Messages on LCD IDPrinter --> GoodFW IDPrinter: Identify allowed Prusa Printer GoodFW --> ReadSN GoodFW: Firwmare is up-to-date ReadSN --> ValidSN ReadSN: SN can be read ValidSN --> PrinterOk ValidSN: Obtained SN is valid PrinterOk: Printer detected right Device --> Phy Device: Ethernet or WiFi device exists Phy --> Lan Phy: Eth|Wifi device connected Lan --> Internet Lan: Device has assigned IP Lan --> Local Local: Messages on printer Web Internet --> HTTP Internet: DNS is working. Internet: There is no problem communicating Internet: to other hosts in the internet. HTTP --> Token HTTP: HTTP traffic to Connect is OK, no 5XX statuses Token --> API Token: Token is set and valid API: There are no 4XX problems while communicating to Connect API --> Connect Connect: Messages on Connect (with printer token) note "Error output to: Connect, Printer Display and Printer Web" as ErrorOutput state Internet #white state HTTP #white state Token #white state "Printer OK" as PrinterOk #lightgreen state "API OK" as API #lightgreen state "Printer Web" as Local #lightblue state LCD #lightblue state Connect #lightblue @enduml ================================================ FILE: docs/wizard.txt ================================================ @startuml state "Add Printer" as Add state "PrusaLink Wizard" as Wizard #lavender state "Use config" as WConfig #lavender state "Network settings" as WiFi #lavender state Services #lavender state Printer #lavender state "Name" as PName #lavender state "Connect" as PConnect #lavender state Recapitulation #lavender state "Add Printer Form" as AConnect state "PrusaLink Web" as LBoard #grey state "Printer Overview" as Overview state "PrusaLink Web" as Link #gray state Settings #gray state Code #salmon state "Network settings" as Network state Overview state Config #lightgreen [*] -> Connect Connect: Printers Connect -> Add Add: Select Printer Add -up-> Wizard: Go to Printer Add -> Name Name: Name and Location Name: Team Name -> Network Network: WiFi Settings Network: IP Settings Network: Link Username and Password Network: SSH Network: NTP server Network-> Code [*] -up-> Wizard Wizard -up-> WiFi Wizard -up-> WConfig Wizard -> Printer WConfig: Use downloaded WConfig: prusa-printer-settings.ini WConfig --> LBoard WiFi: WiFi setting WiFi: IP Settings WiFi --> Wizard Printer: Printer Detection Printer: FW Check Printer -> PName PName -> Services PName: Name and Location Services: Username and Password Services: SSH Services: NTP server Services -> PConnect Services -> Recapitulation PConnect: SN Check PConnect: Connect Registration PConnect -[dotted]-> Code Code -up[dotted]-> PConnect Code: NO UI FORM Code: Generate Code Code -> Overview PConnect -> Recapitulation Recapitulation -> LBoard Recapitulation --> AConnect AConnect: Name and Location AConnect: Team AConnect: Registration Code AConnect -> Overview Overview -up-> LBoard Overview: Detect Link on LAN Overview -> Config Config: prusa-printer-settings.ini Config: Download and add to medium Config: Download and add to Wizard Config -up-> WConfig Config --> Settings [*] --> Link Link: Already configured Link -> Settings Settings: Additional (Re)registration Settings: Unregistration Settings -up[dotted]-> Code Code -[dotted]-> Settings legend right | Color | Type | |<#lavender>| Wizard on PrusaLink | |<#fefece>| Wizard on PrusaConnect | |<#gray>| PrusaLink | |<#salmon>| Hidden action on PrusaConnect | |<#lightgreen>| File download | endlegend @enduml ================================================ FILE: image_builder/__init__.py ================================================ ================================================ FILE: image_builder/image_builder.py ================================================ """Following a writeup from here: https://blog.grandtrunk.net/2023/03/raspberry-pi-4-emulation-with-qemu/""" import argparse import os import re import shlex import subprocess import threading from functools import partial from importlib.resources import files from os.path import join from time import sleep from urllib.request import urlretrieve KERNEL_URL_REGEX = re.compile( r".*/(?Plinux-image-(?P(?P" r"\d+\.\d+\.\d+-\d+)-armmp-lpae)_\d+\.\d+\.\d+-\d+_armhf.deb)") KERNEL_URL = ("http://security.debian.org/debian-security/pool/updates/main/l/" "linux/linux-image-6.1.0-21-armmp-lpae_6.1.90-1_armhf.deb") match = KERNEL_URL_REGEX.match(KERNEL_URL) if match is None: raise RuntimeError("Invalid kernel URL") from None KERNEL_VERSION = match.group("version") KERNEL_VERSION_NAME = match.group("version_name") KERNEL_FILE_NAME = match.group("file_name") INITRD_NAME = f"initrd.img-{KERNEL_VERSION_NAME}" VMLINUZ_NAME = f"vmlinuz-{KERNEL_VERSION_NAME}" IMAGE_URL = ("https://downloads.raspberrypi.org/raspios_lite_armhf/images/" "raspios_lite_armhf-2024-03-15/" "2024-03-15-raspios-bookworm-armhf-lite.img.xz") DATA_FILE = "data.json" COMPRESSED_IMAGE_NAME = "source_image.img.xz" SOURCE_IMAGE_NAME = "source_image.img" SACRIFICIAL_IMAGE_NAME = "sacrificial_image.img" IMAGE_NAME = "image.img" SHRUNK_IMAGE_NAME = "shrunk_image.img" OUTPUT_IMAGE_PATTERN = "prusalink{mode}{version}.img" BOOTFS_MOUNT = "image_bootfs" ROOTFS_MOUNT = "image_rootfs" KERNEL_NAME = "kernel8.img" DTB_NAME = "bcm2710-rpi-3-b-plus.dtb" EMULATOR_CONNECT_RETRIES = 200 EMULATOR_SHUTDOWN_TIMEOUT = 20 BUILDER_DATA_PATH = str(files("prusa.link") / "data" / "image_builder") RPI_EMULATOR_COMMAND = ( "qemu-system-aarch64 " "-machine raspi3b " "-cpu cortex-a72 " "-m 1G " "-smp 4 " "-serial stdio " f"-dtb {DTB_NAME} " f"-kernel {KERNEL_NAME} " "-drive file=./{image_name},format=raw,if=sd " "-append \"rw dwc_otg.lpm_enable=0 root=/dev/mmcblk0p2 rootdelay=1\" " "-netdev user,id=ulan,hostfwd=tcp::2222-:22 " "-device usb-net,netdev=ulan " ) VIRT_EMULATOR_COMMAND = ( "qemu-system-arm " "-nographic " "-machine virt " "-cpu cortex-a7 " "-m 2G " "-smp 4 " "-kernel {vmlinuz} " "-initrd {initrd} " "-drive file={image_name},format=raw,id=hd,if=none,media=disk " "-device virtio-scsi-device -device scsi-hd,drive=hd " "-append \"root=/dev/sda2 console=ttyAMA0,115200\" " "-netdev user,id=net0,hostfwd=tcp::2222-:22 " "-device virtio-net-device,netdev=net0 " ) SSH_COMMAND = "sshpass -p raspberry ssh -o StrictHostKeyChecking=no " \ "-o UserKnownHostsFile=/dev/null -q -p 2222 jo@127.0.0.1 " DATA_DIRECTORY = "imager_data" OUTPUT_DIRECTORY = "generated_images" def reporthook(chunk_number, chunk_size, total_size): """A hook for urlretrieve to report progress""" percent = min(int(chunk_number * chunk_size * 100 / total_size), 100) print(f"\rDownloaded {percent}%", end="") def ensure_directory(directory): """If missing, makes directories, along the supplied path""" if not os.path.exists(directory): os.makedirs(directory) def run_emulator(command): """Runs a given command as if it is an emulator with expected settings""" emulator_thread = threading.Thread(target=run_command, args=(command,)) emulator_thread.start() print("Waiting for the emulator to boot") success = False for _ in range(EMULATOR_CONNECT_RETRIES): try: run_over_ssh("echo Connected to the emulator") except subprocess.CalledProcessError: sleep(1) continue else: success = True break if not success: raise RuntimeError("The emulator did not boot in time") return emulator_thread def retry(call, retries=3, sleep_time=1): """Retry a function call a number of times""" if retries < 0: raise ValueError("Number of retries must be higher or equal zero") repetitions = retries + 1 for i in range(repetitions): try: return call() except Exception: # pylint: disable=broad-except if i == repetitions - 1: raise sleep(sleep_time) return None def run_command(command, check=True, retries=1): """Run command and print output""" to_run = partial(subprocess.run, shlex.split(command), check=check) retry(to_run, retries=retries) def run_over_ssh(command, check=True, retries=1): """Runs a command over ssh, checks for errors and retries once""" run_command(SSH_COMMAND + command, check=check, retries=retries) def check_binary(binary_name): """Checks if a binary is installed""" print(f"Checking if {binary_name} is installed") try: subprocess.run(shlex.split(f"which {binary_name}"), check=True) except subprocess.CalledProcessError as err: raise RuntimeError(f"{binary_name} is not installed") from err def insert_from_file_before_line(to_file, from_file, search=None, index=None): """Inserts the contents of a file into another file before a given line""" if search is None and index is None: raise RuntimeError("Either search or index must be specified") with open(to_file, "r", encoding="utf-8") as file: lines = file.readlines() with open(from_file, "r", encoding="utf-8") as file: lines_to_insert = file.readlines() split_on = 0 if search is not None and index is None: for i, line in enumerate(lines): if line.strip() == search: split_on = i break else: split_on = index result = lines[:split_on] + lines_to_insert + lines[split_on:] with open(to_file, "w", encoding="utf-8") as file: file.writelines(result) def mount_image(image_name, expand=False): """Mounts the image and returns the loop device part""" print(f"Creating loop device for {image_name}") losetup_result = subprocess.run( shlex.split(f"sudo losetup --partscan --find --show {image_name}"), check=True, capture_output=True) loop_device = losetup_result.stdout.decode("utf-8").strip() if expand: print(f"Resizing {image_name}") run_command(f"parted {loop_device} resizepart 2 100%") run_command(f"e2fsck -f {loop_device}p2") run_command(f"resize2fs {loop_device}p2") ensure_directory(BOOTFS_MOUNT) ensure_directory(ROOTFS_MOUNT) print(f"Mounting {image_name}") run_command(f"mount {loop_device}p1 {BOOTFS_MOUNT}") run_command(f"mount {loop_device}p2 {ROOTFS_MOUNT}") return loop_device def unmount_image(loop_device): """Unmounts the image and removes the loop device""" print("Unmounting image") retry(partial(run_command, f"umount {BOOTFS_MOUNT}")) retry(partial(run_command, f"umount {ROOTFS_MOUNT}")) print(f"Removing loop device {loop_device}") retry(partial(run_command, f"losetup -d {loop_device}")) def basic_image_setup(): """Sets up the image with ssh and a user jo with password raspberry""" print("Write userconf.txt") userconf_path = join(BOOTFS_MOUNT, "userconf.txt") with open(userconf_path, "w", encoding="utf-8") as userconf: userconf.write( "jo:$6$Jy4tV1H40VvfLZcX$hh/728SqdBocM2FTZ3fJh9Fx1u2FIJD/" "8U075tyNewDDVEDS3e9.Miz213qujfnJ967Zs.43VRRhC4d/FDuKn0") print("Enable SSH") ssh_file_path = join(BOOTFS_MOUNT, "ssh") with open(ssh_file_path, "w", encoding="utf-8") as _: ... # pylint: disable=too-many-locals, too-many-statements def build_image(): """Builds the requested image""" ensure_directory(DATA_DIRECTORY) ensure_directory(OUTPUT_DIRECTORY) os.chdir(DATA_DIRECTORY) if os.getuid() != 0: raise RuntimeError("This script must be run as root") check_binary("qemu-system-aarch64") check_binary("sshpass") check_binary("ssh") check_binary("wget") check_binary("parted") parser = argparse.ArgumentParser( description="PrusaLink RPi image generator") parser.add_argument("-d", "--dev", action="store_true", help="Build the image from master (for development)") parser.add_argument("-r", "--refresh", action="store_true", help="Re-do everything from scratch") parser.add_argument("-m", "--multi-instance", action="store_true", help="Build the multi-instance image") parser.add_argument("-b", "--branch-or-hash", help="Specify a commit branch name or a hash of " "PrusaLink to get") args = parser.parse_args() try: check_binary("pishrink.sh") except Exception: # pylint: disable=broad-except print("pishrink is not installed, downloading") run_command("wget https://raw.githubusercontent.com/" "Drewsif/PiShrink/master/pishrink.sh") run_command("chmod +x pishrink.sh") # --- Get source image --- if not os.path.exists(SOURCE_IMAGE_NAME) or args.refresh: print("Cleaning up old image files") run_command(f"rm {COMPRESSED_IMAGE_NAME}", check=False) run_command(f"rm {SOURCE_IMAGE_NAME}", check=False) run_command(f"rm {IMAGE_NAME}", check=False) print(f"Downloading {IMAGE_URL}") urlretrieve(IMAGE_URL, COMPRESSED_IMAGE_NAME, reporthook=reporthook) print("") print("Decompressing image") run_command(f"xz --decompress -T0 {COMPRESSED_IMAGE_NAME}") print("Resize to 4GB") run_command(f"qemu-img resize -f raw {SOURCE_IMAGE_NAME} 4G") # --- Get kernel --- regenerate_initramfs = False if args.refresh: regenerate_initramfs = True if not os.path.exists(KERNEL_VERSION_NAME): regenerate_initramfs = True if not os.path.exists(INITRD_NAME): regenerate_initramfs = True if not os.path.exists(VMLINUZ_NAME): regenerate_initramfs = True if regenerate_initramfs: print("Cleaning up old kernel files") run_command("rm linux-image-*", check=False) run_command("rm initrd.img-*", check=False) run_command("rm vmlinuz-*", check=False) print(f"Downloading {KERNEL_URL}") urlretrieve(KERNEL_URL, KERNEL_FILE_NAME, reporthook=reporthook) print("") print("Copying sacrificial image") run_command(f"cp {SOURCE_IMAGE_NAME} {SACRIFICIAL_IMAGE_NAME}") sacrificial_loop = mount_image(SACRIFICIAL_IMAGE_NAME, expand=True) print("Copying the kernel package into the image") run_command(f"cp {KERNEL_FILE_NAME} {ROOTFS_MOUNT}/.") print("Extracting kernel and dtb files") run_command(f"cp {BOOTFS_MOUNT}/{KERNEL_NAME} .") run_command(f"cp {BOOTFS_MOUNT}/{DTB_NAME} .") basic_image_setup() print("Unmounting sacrificial image") unmount_image(sacrificial_loop) emulator_command = RPI_EMULATOR_COMMAND.format( image_name=SACRIFICIAL_IMAGE_NAME) print("Run the initrd generating emulator") emulator_thread = run_emulator(emulator_command) print("Generating vmlinuz and initrd") run_over_ssh(f"sudo dpkg -i /{KERNEL_FILE_NAME}") run_over_ssh("sudo poweroff", check=False) print("Waiting for the initrd generating emulator to shut down") emulator_thread.join() print("Copying the generated vmlinuz and initrd") initrd_loop = mount_image(SACRIFICIAL_IMAGE_NAME, expand=False) run_command(f"cp {ROOTFS_MOUNT}/boot/{VMLINUZ_NAME} .") run_command(f"cp {ROOTFS_MOUNT}/boot/{INITRD_NAME} .") run_command(f"cp -r {ROOTFS_MOUNT}/lib/modules/" f"{KERNEL_VERSION_NAME} .") unmount_image(initrd_loop) print("Cleaning up") run_command(f"rm {SACRIFICIAL_IMAGE_NAME}") run_command(f"rm {KERNEL_NAME}") run_command(f"rm {DTB_NAME}") print("Copying source image") run_command(f"cp {SOURCE_IMAGE_NAME} {IMAGE_NAME}") raw_loop = mount_image(IMAGE_NAME, expand=True) basic_image_setup() print("Write boot-message.service") message_service_path = join( ROOTFS_MOUNT, "etc/systemd/system/boot-message.service") boot_message_path = join(BUILDER_DATA_PATH, "boot-message.service") run_command(f"cp {boot_message_path} {message_service_path}") print("Write additional temporary modules") run_command(f"cp -r {KERNEL_VERSION_NAME} " f"{ROOTFS_MOUNT}/lib/modules/{KERNEL_VERSION_NAME}") config_txt_path = join(BOOTFS_MOUNT, "config.txt") with open(config_txt_path, "a", encoding="utf-8") as config_txt: config_txt.write("dtoverlay=disable-bt\n") unmount_image(raw_loop) print("Run the emulator") emulator_command = VIRT_EMULATOR_COMMAND.format( image_name=IMAGE_NAME, vmlinuz=VMLINUZ_NAME, initrd=INITRD_NAME) emulator_thread = run_emulator(emulator_command) print("Enabling boot-message.service") run_over_ssh("sudo systemctl enable boot-message.service") print("Disabling bluetooth service") run_over_ssh("sudo systemctl disable hciuart.service") print("Disabling console over serial") run_over_ssh("sudo raspi-config nonint do_serial_hw 0") run_over_ssh("sudo raspi-config nonint do_serial_cons 1") print("Changing hostname to prusalink") run_over_ssh("sudo raspi-config nonint do_hostname prusalink") print("Waiting for NTP to sync, TODO: make this smarter") sleep(20) print("Updating system") run_over_ssh("sudo apt-get update -y") run_over_ssh("sudo apt-get upgrade -y") print("Installing dependencies") # I guess we need this for the wi-fi setting to get applied normally run_over_ssh("sudo apt-get install -y uuid") run_over_ssh("sudo apt-get install -y git python3-pip pigpio libcap-dev " "libmagic1 libturbojpeg0 libffi-dev python3-numpy " "cmake iptables python3-libcamera") print("Installing PrusaLink") # Caution: not tied to requirements-pi.txt run_over_ssh("pip install --break-system-packages wiringpi") if args.multi_instance: run_over_ssh("pip install --break-system-packages ipcqueue") if args.dev or args.branch_or_hash is not None: hash_part = "" if args.branch_or_hash is not None: hash_part = f"@{args.branch_or_hash}" run_over_ssh("pip install --break-system-packages git+https://" "github.com/prusa3d/gcode-metadata.git") run_over_ssh("pip install --break-system-packages git+https://" "github.com/prusa3d/Prusa-Connect-SDK-Printer.git") run_over_ssh("pip install --break-system-packages git+https://" f"github.com/prusa3d/Prusa-Link.git{hash_part}") else: run_over_ssh("pip install --break-system-packages prusalink") output = subprocess.run( shlex.split(SSH_COMMAND + ".local/bin/prusalink --version"), capture_output=True, check=False) version_text = output.stdout.decode("utf-8").split("\n")[0] prusalink_version = version_text.split(": ")[1] print("Removing traces of the installation") run_over_ssh("sudo systemctl disable ssh") run_over_ssh("sudo logrotate -f /etc/logrotate.conf") run_over_ssh("sudo rm /var/log/*.1", check=False) run_over_ssh("sudo rm /var/log/*.gz", check=False) run_over_ssh("sudo cat /dev/null | sudo tee /var/log/lastlog") run_over_ssh("rm ~/.bash_history", check=False) print("Shutting down the emulator") run_over_ssh("sudo poweroff", check=False) emulator_thread.join(timeout=EMULATOR_SHUTDOWN_TIMEOUT) print("Shrinking image") run_command(f"pishrink.sh -p {IMAGE_NAME} {SHRUNK_IMAGE_NAME} ") shrunk_loop = mount_image(SHRUNK_IMAGE_NAME) print("Adding the first boot script") rc_local_path = join(ROOTFS_MOUNT, "etc/rc.local") insert_from_file_before_line( to_file=rc_local_path, from_file=join(BUILDER_DATA_PATH, "first-boot.sh"), index=1) print("Adding the start script") if args.multi_instance: rc_local_bak_path = join(ROOTFS_MOUNT, "etc/rc.local.bak") insert_from_file_before_line( to_file=rc_local_bak_path, from_file=join(BUILDER_DATA_PATH, "manager-start-script.sh"), search="exit 0") else: rc_local_bak_path = join(ROOTFS_MOUNT, "etc/rc.local.bak") insert_from_file_before_line( to_file=rc_local_bak_path, from_file=join(BUILDER_DATA_PATH, "prusalink-start-script.sh"), search="exit 0") print("Removing modules needed for virtio") run_command(f"rm -r {ROOTFS_MOUNT}/lib/modules/" f"{KERNEL_VERSION_NAME}") run_command(f"rm -r {ROOTFS_MOUNT}/var/cache/*", check=False) run_command(f"rm -r {ROOTFS_MOUNT}/home/jo/.cache/*", check=False) unmount_image(shrunk_loop) output_image_name = OUTPUT_IMAGE_PATTERN.format( mode="-multi-instance" if args.multi_instance else "", version=f"-{prusalink_version}") run_command(f"mv {SHRUNK_IMAGE_NAME} {output_image_name}") print("Removing old compressed image") run_command(f"rm {output_image_name}.xz", check=False) print("Compressing image") run_command(f"xz --compress --keep -6 -T0 {output_image_name}") print("Cleaning up") run_command(f"rm {IMAGE_NAME}") run_command(f"mv {output_image_name}.xz ../{OUTPUT_DIRECTORY}/") run_command(f"mv {output_image_name} ../{OUTPUT_DIRECTORY}/") os.chdir("..") print("Done") def main(): """Main function, if the build fails, tries to kill the emulator""" try: build_image() except Exception: # pylint: disable=broad-except run_command("killall qemu-system-aarch64", check=False) run_command("killall qemu-system-arm", check=False) raise if __name__ == '__main__': main() ================================================ FILE: prusa/link/__init__.py ================================================ """Original PrusaLink printer adapter. Copyright (C) 2024 PrusaResearch """ __application__ = "PrusaLink" __vendor__ = "Prusa Research" __version__ = "0.8.2" __date__ = "18 Dec 2024" __copyright__ = "(c) 2024 Prusa 3D" __author_name__ = "PrusaLink Developers" __author_email__ = "link@prusa3d.cz" __author__ = f"{__author_name__} <{__author_email__}>" __description__ = f"{__application__} for MK3 host software" __credits__ = "Tomáš Jozífek, Ondřej Tůma, Michal Zoubek" __url__ = "https://github.com/prusa3d/Prusa-Link" ================================================ FILE: prusa/link/__main__.py ================================================ """main() command line function.""" import logging import sys import threading from argparse import ArgumentParser, ArgumentTypeError from cProfile import Profile from grp import getgrnam from os import chmod, geteuid, kill, mkdir, path from pwd import getpwnam from signal import SIGKILL, SIGTERM from time import sleep from daemon import DaemonContext # type: ignore from lockfile.pidlockfile import PIDLockFile # type: ignore from prusa.connect.printer import __version__ as sdk_version from . import __version__ as link_version from .config import Config from .const import EXIT_TIMEOUT from .interesting_logger import InterestingLogger, InterestingLogRotator from .printer_adapter.updatable import Thread # pylint: disable=wrong-import-position, wrong-import-order # Pop this singleton into existence before importing prusalink InterestingLogRotator() logging.setLoggerClass(InterestingLogger) from .daemon import Daemon # noqa: E402 log = logging.getLogger(__name__) # pylint: disable=too-many-return-statements # pylint: disable=too-many-statements CONFIG_FILE = '/etc/prusalink/prusalink.ini' def excepthook(exception_arguments, args, argv): """If running as a daemon, restarts the app on unhandled exceptions""" assert exception_arguments is not None InterestingLogRotator.trigger("exception in a thread") log.exception("Caught an exception at top level!") if args is None: log.fatal("Exception during startup, cannot restart") if args.foreground: log.fatal("This instance is now broken. Will not restart " "because we're running in the foreground mode") else: log.warning("Caught unhandled exception, restarting PrusaLink") Daemon.restart(argv) # excepthook has the global exception set, besides even if we failed # here, it will literally affect nothing # pylint: disable=misplaced-bare-raise # ruff: noqa: PLE0704 raise def set_log_levels(config: Config): """Set log level for each defined module.""" for module, level in config.log_settings.items(): logging.getLogger(module).setLevel(level) class LogLevel(str): """Log level type with __call__ checker method.""" def __new__(cls, level): if len(level.split("=")) != 2: raise ArgumentTypeError("log level needs to be specified in format" "=") return super().__new__(cls, level) def check_process(pid): """Check if process with pid is alive.""" try: kill(pid, 0) return True except OSError: return False def wait_process(pid, timeout=1): """Wait for process with timeout. Return True if process was terminated.""" sleep_amount = 0.1 for _ in range(int(timeout / sleep_amount)): if not check_process(pid): return True sleep(sleep_amount) return False def stop(pid): """Tries to stop PrusaLink nicely, if it times out, uses SIGKILL""" kill(pid, SIGTERM) if wait_process(pid, EXIT_TIMEOUT): return log.warning("Failed to stop - SIGKIL will be used!") try: kill(pid, SIGKILL) except ProcessLookupError: log.warning("Could not find a process with pid %s to kill", pid) wait_process(pid, EXIT_TIMEOUT) def main(): """Standard main function.""" # pylint: disable=too-many-branches parser = ArgumentParser(prog="prusalink", description="PrusaLink daemon.") parser.add_argument( "command", nargs='?', default="start", type=str, help="daemon action (start|stop|restart|status) (default: start)") parser.add_argument("-f", "--foreground", action="store_true", help="run as script on foreground") parser.add_argument("-c", "--config", default=CONFIG_FILE, type=str, help=f"path to config file (default: {CONFIG_FILE})", metavar="") parser.add_argument("-p", "--pidfile", type=str, help="path to pid file", metavar="") parser.add_argument("-a", "--address", type=str, help="IP listening address (host or IP)", metavar="
") parser.add_argument("-t", "--tcp-port", type=int, help="TCP/IP listening port", metavar="") parser.add_argument("-I", "--link-info", action="store_true", help="/link-info debug page") parser.add_argument("-s", "--serial-port", type=str, help="Serial (printer's) port or 'auto'", metavar="") parser.add_argument("-n", "--printer-number", type=int, help="Multi-instance printer number to show in wizard") parser.add_argument("-i", "--info", action="store_true", help="more verbose logging level INFO is set") parser.add_argument("-d", "--debug", action="store_true", help="DEBUG logging level is set") parser.add_argument("-l", "--module-log-level", action="append", help="sets the log level of any submodule(s). " "use =", type=LogLevel) parser.add_argument("--profile", action="store_true", help="Use cProfile for profiling application.") parser.add_argument("--version", action="store_true", help="Print out version info and exit") argv = list(arg for arg in sys.argv[1:] if arg not in ('start', 'restart')) args = parser.parse_args() if args.version: print("PrusaLink version:", link_version) print("PrusaConnect-SDK version:", sdk_version) return 0 profile = None if args.profile: profile = Profile() profile.enable() Thread.enable_profiling() # Restart on thread exceptions threading.excepthook = lambda exc_args: excepthook(exc_args, args, argv) try: config = Config(args) set_log_levels(config) pid_file = PIDLockFile(config.daemon.pid_file) pid = pid_file.read_pid() if pid_file.is_locked() else None if args.command == "stop": if pid and check_process(pid): print("Stopping service with pid", pid) stop(pid) else: print("Service not running") return 0 if args.command == "status": if pid and check_process(pid): print("Service running with pid", pid) return 0 print("Service not running") return 1 if args.command == "restart": if pid and check_process(pid): print("Restarting service with pid", pid) stop(pid) elif args.command == "start": pass elif not args.foreground: parser.error("Unknown command %s") return 1 daemon = Daemon(config, argv) if args.foreground: log.info("Starting service on foreground.") return daemon.run(False) if pid: if not check_process(pid): pid_file.break_lock() else: print("Service is already running") return 1 files_preserve = [] for handler in logging.root.handlers: if hasattr(handler, "socket"): files_preserve.append(handler.socket.fileno()) context = DaemonContext(pidfile=pid_file, files_preserve=files_preserve, signal_map={SIGTERM: daemon.sigterm}) pid_dir = path.dirname(config.daemon.pid_file) if pid_dir == '/var/run/prusalink' and not path.exists(pid_dir): mkdir(pid_dir) chmod(pid_dir, 0o777) if geteuid() == 0: context.initgroups = True # need only for RPi, don't know why context.uid = getpwnam(config.daemon.user).pw_uid context.gid = getgrnam(config.daemon.group).gr_gid with context: log.info("Starting service with pid %d", pid_file.read_pid()) retval = daemon.run() log.info("Shutdown") return retval except Exception as exc: # pylint: disable=broad-except log.info("%s", args) log.exception("Unhandled exception reached the top level") parser.error(f"{exc}") return 1 finally: if profile: profile.disable() profile.dump_stats("prusalink-__main__.profile") if __name__ == "__main__": sys.exit(main()) ================================================ FILE: prusa/link/camera_governor.py ================================================ """Implements a simple loop for getting cameras unstuck and for auto adding them""" import logging from functools import partial from threading import Event, Thread from typing import Optional from prusa.connect.printer.camera_configurator import CameraConfigurator from prusa.connect.printer.camera_controller import CameraController from .const import CAMERA_SCAN_INTERVAL from .interesting_logger import InterestingLogRotator from .util import loop_until log = logging.getLogger("my_camera_configurator") class CameraGovernor: """A module for continually refreshing and adding cameras""" def __init__(self, camera_configurator: CameraConfigurator, camera_controller: CameraController) -> None: self.camera_configurator = camera_configurator self.camera_controller = camera_controller self._governance_quit_event = Event() self._governance_thread: Optional[Thread] = None def _govern(self) -> None: """Monitors the cameras re-starts failed ones, optionally scans for newly connected ones""" log.debug("Running the camera governance routine") if self.camera_controller.disconnect_stuck_cameras(): InterestingLogRotator.trigger("a stuck camera") self.camera_configurator.load_cameras() def start(self) -> None: """Starts the camera governing loop""" self._governance_quit_event.clear() target = partial( loop_until, loop_evt=self._governance_quit_event, run_every_sec=lambda: CAMERA_SCAN_INTERVAL, to_run=self._govern) self._governance_thread = Thread( target=target, name="camera_governance", daemon=True, ) self._governance_thread.start() def stop(self) -> None: """Stops the auto-add loop""" self._governance_quit_event.set() def wait_stopped(self) -> None: """Waits util the component's thread stops""" if self._governance_thread is None: return if self._governance_thread.is_alive(): self._governance_thread.join() ================================================ FILE: prusa/link/cameras/__init__.py ================================================ ================================================ FILE: prusa/link/cameras/encoders.py ================================================ """This file contains encoders for the camera drivers Especially the hardware conversion needs a lot of prep work""" import abc import ctypes import fcntl import functools import mmap import os import select from enum import Enum from math import sqrt from types import MappingProxyType import numpy as np from turbojpeg import TJSAMP_422, TurboJPEG # type: ignore from . import v4l2 jpeg = TurboJPEG() def fopen(path, write=False): """Opens a specified video device file""" return open(path, "rb+" if write else "rb", buffering=0, opener=opener) def opener(path, flags): """Adds flags for the open function""" return os.open(path, flags | os.O_NONBLOCK) class Quality(Enum): """A simple enum that can be easily interpreted by encoders""" VERY_LOW = "Very low" LOW = "Low" MEDIUM = "Medium" HIGH = "High" VERY_HIGH = "Very high" class BufferDetails: """A structure to encapsulate buffer info needed for encoding""" def __init__(self, file_descriptor, length, offset): self.file_descriptor = file_descriptor self.length = length self.offset = offset self.mmap = mmap.mmap(fileno=self.file_descriptor, length=self.length, offset=self.offset) def __del__(self): try: self.mmap.close() except AttributeError: pass def get_appropriate_encoder(resolution, pixel_format, use_mmap=False): """Returns the appropriate encoder based on stream parameters""" max_resolution = max(resolution.width, resolution.height) if pixel_format == v4l2.V4L2_PIX_FMT_MJPEG: return PassthroughEncoder() if not MJPEGEncoder.is_available(): return JPEGEncoder() if max_resolution > MJPEGEncoder.WIDTH_LIMIT: return JPEGEncoder() encoder = MJPEGEncoder() if use_mmap: # Switch to a type that copies data instead of trying to use # a foreign buffer encoder.ingest_buffer_memory = v4l2.V4L2_MEMORY_MMAP return encoder class Encoder: """A base class for encoders""" def __init__(self): """Set all parameters encoder needs after calling init""" self.width = 0 self.height = 0 self.stride = 0 self.fps = 30 self._quality = Quality.HIGH # Information about the buffer from which to read self.source_details = None def start(self): """Initializes the encoder""" def stop(self): """Stops the encoder""" @property def quality(self): """Gets the quality""" return self._quality @quality.setter def quality(self, quality=Quality.HIGH): """An entry point for other parameters dependant on quality""" self._quality = quality @abc.abstractmethod def encode(self, bytes_used: int) -> bytes: """Encode here, return bytes""" class MJPEGEncoder(Encoder): """Encoder using the MJPEG Encoder on the Raspberry Pi through V4L2 Glossary: SOURCE means foreign object like a buffer we copy data from INGEST means our own data structure with raw data (V4L2 name: Output) CODED means the structure with compressed data (V4L2 name: Capture) """ WIDTH_LIMIT = 1920 DEVICE_PATH = "/dev/video11" # These are suggested bitrates for 1080p30 in Mbps BITRATE_TABLE = MappingProxyType({ Quality.VERY_LOW: 6, Quality.LOW: 12, Quality.MEDIUM: 18, Quality.HIGH: 27, Quality.VERY_HIGH: 45, }) # Use only one buffer, so no indexes need to exist BUFFER_INDEX = 0 @classmethod @functools.cache def is_available(cls): """Figures whether we can do hardware decode or not""" if not os.path.exists(cls.DEVICE_PATH): return False with open(cls.DEVICE_PATH, 'rb+', buffering=0) as file_descriptor: coded_format = v4l2.v4l2_format() coded_format.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE coded_format.fmt.pix_mp.pixelformat = v4l2.V4L2_PIX_FMT_MJPEG if fcntl.ioctl(file_descriptor, v4l2.VIDIOC_S_FMT, coded_format): return False ingest_format = v4l2.v4l2_format() ingest_format.type = v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE ingest_format.fmt.pix_mp.pixelformat = v4l2.V4L2_PIX_FMT_YUYV return not fcntl.ioctl(file_descriptor, v4l2.VIDIOC_S_FMT, ingest_format) def __init__(self): """Initialise V4L2 encoder""" super().__init__() self._bitrate = None # set by setting quality self.coded_buffer = None self.coded_mmap = None self.ingest_buffer = None self.ingest_mmap = None self.controls = [] self.file_object = None # This is important, it tells us if we can use the buffer given to # encode as is, or if we are to copy the data (MMAP = copy) self.ingest_buffer_memory = v4l2.V4L2_MEMORY_DMABUF def _pre_fill_format(self, format_type, pixel_format): format_ = v4l2.v4l2_format() format_.type = format_type format_.fmt.pix_mp.width = self.width format_.fmt.pix_mp.height = self.height format_.fmt.pix_mp.pixelformat = pixel_format format_.fmt.pix_mp.plane_fmt[0].bytesperline = self.stride format_.fmt.pix_mp.field = v4l2.V4L2_FIELD_ANY format_.fmt.pix_mp.colorspace = v4l2.V4L2_COLORSPACE_JPEG format_.fmt.pix_mp.num_planes = 1 return format_ def _request_buffers(self, buffer_type, memory, count=1): buffer_request = v4l2.v4l2_requestbuffers() buffer_request.count = count buffer_request.type = buffer_type buffer_request.memory = memory fcntl.ioctl(self.file_object, v4l2.VIDIOC_REQBUFS, buffer_request) def _get_buffer(self, buffer_type, memory): # This is a definition of a ctype array plane_proto = v4l2.v4l2_plane * 1 buffer = v4l2.v4l2_buffer() ctypes.memset(ctypes.byref(buffer), 0, ctypes.sizeof(buffer)) buffer.type = buffer_type buffer.memory = memory buffer.index = 0 buffer.length = 1 buffer.m.planes = plane_proto() return buffer def _stream_on(self, buffer_type): typev = v4l2.v4l2_buf_type(buffer_type) fcntl.ioctl(self.file_object, v4l2.VIDIOC_STREAMON, typev) def _stream_off(self, buffer_type): typev = v4l2.v4l2_buf_type(buffer_type) fcntl.ioctl(self.file_object, v4l2.VIDIOC_STREAMOFF, typev) def start(self): # Removed framerate calculation, we don't do those reference_complexity = 1920 * 1080 actual_complexity = self.width * self.height reference_bitrate = self.BITRATE_TABLE[self.quality] * 1000000 self._bitrate = int(reference_bitrate * sqrt(actual_complexity / reference_complexity)) # pylint: disable=consider-using-with self.file_object = open(self.DEVICE_PATH, 'rb+', buffering=0) capability = v4l2.v4l2_capability() fcntl.ioctl(self.file_object, v4l2.VIDIOC_QUERYCAP, capability) control = v4l2.v4l2_control() control.id = v4l2.V4L2_CID_MPEG_VIDEO_BITRATE control.value = self._bitrate fcntl.ioctl(self.file_object, v4l2.VIDIOC_S_CTRL, control) ingest_format = self._pre_fill_format( format_type=v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, pixel_format=v4l2.V4L2_PIX_FMT_YUYV, ) fcntl.ioctl(self.file_object, v4l2.VIDIOC_S_FMT, ingest_format) coded_format = self._pre_fill_format( format_type=v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, pixel_format=v4l2.V4L2_PIX_FMT_MJPEG, ) coded_format.fmt.pix_mp.plane_fmt[0].bytesperline = 0 coded_format.fmt.pix_mp.plane_fmt[0].sizeimage = 512 << 10 fcntl.ioctl(self.file_object, v4l2.VIDIOC_S_FMT, coded_format) self._request_buffers( buffer_type=v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, memory=self.ingest_buffer_memory) self._request_buffers( buffer_type=v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, memory=v4l2.V4L2_MEMORY_MMAP) # Prepare the buffer for encoded data # The raw data buffer will get re-used from libcamera in this case self.coded_buffer = self._get_buffer( v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, v4l2.V4L2_MEMORY_MMAP, ) fcntl.ioctl(self.file_object, v4l2.VIDIOC_QUERYBUF, self.coded_buffer) plane = self.coded_buffer.m.planes[0] self.coded_mmap = mmap.mmap( fileno=self.file_object.fileno(), length=plane.length, offset=plane.m.mem_offset, prot=mmap.PROT_READ | mmap.PROT_WRITE, flags=mmap.MAP_SHARED, ) self.ingest_buffer = self._get_buffer( v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, self.ingest_buffer_memory, ) fcntl.ioctl(self.file_object, v4l2.VIDIOC_QUERYBUF, self.ingest_buffer) if self.ingest_buffer_memory == v4l2.V4L2_MEMORY_MMAP: plane = self.ingest_buffer.m.planes[0] self.ingest_mmap = mmap.mmap( fileno=self.file_object.fileno(), length=plane.length, offset=plane.m.mem_offset, prot=mmap.PROT_READ | mmap.PROT_WRITE, flags=mmap.MAP_SHARED, ) fcntl.ioctl(self.file_object, v4l2.VIDIOC_QBUF, self.coded_buffer) self._stream_on(v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) self._stream_on(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) def stop(self): """Prepares the encoder for encoding""" self._stream_off(v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) self._stream_off(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) self._request_buffers( buffer_type=v4l2.V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, memory=self.ingest_buffer_memory, count=0) self.coded_mmap.close() self.coded_mmap = None if self.ingest_mmap is not None: self.ingest_mmap.close() self.ingest_mmap = None self._request_buffers( buffer_type=v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, memory=v4l2.V4L2_MEMORY_MMAP, count=0) self.file_object.close() self.ingest_buffer = None self.coded_buffer = None def encode(self, bytes_used): """Encodes a frame""" if self.file_object is None or self.file_object.closed: raise RuntimeError("Cannot encode with a stopped encoder") if self.ingest_buffer_memory == v4l2.V4L2_MEMORY_DMABUF: ingest_plane = self.ingest_buffer.m.planes[0] ingest_plane.m.fd = self.source_details.file_descriptor ingest_plane.length = self.source_details.length ingest_plane.bytesused = bytes_used elif self.ingest_buffer_memory == v4l2.V4L2_MEMORY_MMAP: self.ingest_mmap.write(self.source_details.mmap.read(bytes_used)) self.ingest_mmap.seek(self.ingest_buffer.m.planes[0].m.mem_offset) self.source_details.mmap.seek(self.source_details.offset) fcntl.ioctl(self.file_object, v4l2.VIDIOC_QBUF, self.ingest_buffer) select.select((self.file_object, ), (), ()) if fcntl.ioctl(self.file_object, v4l2.VIDIOC_DQBUF, self.ingest_buffer): raise RuntimeError( "Encoding failed - dequeueing the ingest buffer") if fcntl.ioctl(self.file_object, v4l2.VIDIOC_DQBUF, self.coded_buffer): raise RuntimeError( "Encoding failed - de-queueing the coded buffer") output = self.coded_mmap.read(self.coded_buffer.m.planes[0].bytesused) self.coded_mmap.seek(0) if fcntl.ioctl(self.file_object, v4l2.VIDIOC_QBUF, self.coded_buffer): raise RuntimeError( "Encoding failed - re-queueing the coded buffer") return output class JPEGEncoder(Encoder): """Encoder using the TurboJPEG library (CPU encoding)""" QUALITY_TABLE = MappingProxyType({ Quality.VERY_LOW: 25, Quality.LOW: 50, Quality.MEDIUM: 70, Quality.HIGH: 85, Quality.VERY_HIGH: 95, }) def __init__(self): super().__init__() self.quality_percent = None def start(self): """Prepares the encoder for encoding""" self.quality_percent = self.QUALITY_TABLE[self.quality] def encode(self, bytes_used): """Extracts Y, U and V, then puts them one after another instead of interweaving""" array_data = np.array(self.source_details.mmap, dtype=np.uint8) size = bytes_used yuv_array = np.empty((size, ), dtype=np.uint8) yuv_array[:size // 2] = array_data[0::2] yuv_array[size // 2:size // 4 * 3] = array_data[1::4] yuv_array[size // 4 * 3:] = array_data[3::4] return jpeg.encode_from_yuv(yuv_array, self.height, self.width, quality=self.quality_percent, jpeg_subsample=TJSAMP_422) class PassthroughEncoder(Encoder): """An encoder, that just transforms the data from the format accepted by encode to the format returned by encoders without touching the data""" def encode(self, bytes_used: int) -> bytes: """Reads the source data and outputs as bytes""" return self.source_details.mmap[:bytes_used] ================================================ FILE: prusa/link/cameras/picamera_driver.py ================================================ """Contains implementation of a driver for Rpi Cameras""" import gc import logging import select from time import time from types import MappingProxyType from typing import Any, Callable, Dict, Optional from prusa.connect.printer.camera import Resolution from prusa.connect.printer.camera_driver import CameraDriver from prusa.connect.printer.const import ( CAMERA_WAIT_TIMEOUT, CapabilityType, NotSupported, ) from ..util import is_potato_cpu, prctl_name from . import v4l2 from .encoders import BufferDetails, MJPEGEncoder, get_appropriate_encoder log = logging.getLogger(__name__) PICAMERA_SUPPORTED = False try: from libcamera import ( # type: ignore Camera, CameraManager, ControlId, FrameBufferAllocator, PixelFormat, Rectangle, Request, Size, Stream, StreamConfiguration, StreamFormats, StreamRole, controls, ) except ImportError: CameraManager = Camera = StreamConfiguration = Stream = StreamFormats = \ StreamRole = PixelFormat = Request = Size = FrameBufferAllocator = \ controls = Rectangle = ControlId = None else: PICAMERA_SUPPORTED = True PICAMERA_MODELS = { "imx219", "imx296_mono", "imx477_v1", "ov5647_noir", "imx219_noir", "imx378", "imx519", "ov9281_mono", "imx290", "imx477", "se327m12", "imx296", "imx477_noir", "ov5647", "imx708", "imx708_noir", "imx708_wide", "imx708_wide_noir", } SUPPORTED_PIXEL_FORMAT = "YUYV" def param_change(func): """Wraps any settings change with a stop and start of the video stream, so the camera driver does not return it's busy""" def inner(self, new_param): # pylint: disable=protected-access self.camera.stop() self.encoder.stop() func(self, new_param) self._start() return inner class PiCameraDriver(CameraDriver): """A camera driver for RaspberryPi cameras""" name = "PiCamera" supported = PICAMERA_SUPPORTED REQUIRES_SETTINGS: MappingProxyType[str, str] = MappingProxyType({}) @staticmethod def _scan(): """Scan for Pi Cameras""" available = {} camera_manager = CameraManager.singleton() for camera in camera_manager.cameras: model = "unknown" for name, value in camera.properties.items(): if str(name) == "Model": model = value break log.debug("picamera found model: %s", model) if model in PICAMERA_MODELS: available[camera.id] = { "id_string": camera.id, "name": f"RaspberryPi Camera: {model}"} return available def __init__(self, camera_id: str, config: Dict[str, str], disconnected_cb: Callable[["CameraDriver"], None]) -> None: # pylint: disable=duplicate-code super().__init__(camera_id, config, disconnected_cb) self.camera_manager: CameraManager = CameraManager.singleton() self.camera: Optional[Camera] = None self.resolution: Optional[Resolution] = None self.raw_resolution = None self.stream: Optional[Stream] = None self.request: Optional[Request] = None self.allocator: Optional[FrameBufferAllocator] = None self.frame_number = 0 self.scaler_crop = Rectangle(Size(3200, 2400)) self.encoder = None self.controls_to_set: Dict[ControlId, Any] = {} @staticmethod def get_resolutions(camera: Camera, stream_role: StreamRole, wanted_pixel_format: Optional[str] = None): """Gets the formats and their resolutions for any given camera""" resolutions = set() camera_config = camera.generate_configuration( [stream_role]) stream_config = camera_config.at(0) stream_formats: StreamFormats = stream_config.formats for pixel_format in stream_formats.pixel_formats: if wanted_pixel_format is not None: if str(pixel_format) != wanted_pixel_format: continue for resolution in stream_formats.sizes(pixel_format): # Ignore resolutions that would need more post-processing # as a result of padding to 64 bytes. Docs say 32, # but that does not seem to be right. 32 here, means 64 bytes. # One for brightness and one for color, two per pixel if stream_role != StreamRole.Raw: if resolution.width % 32: continue # Cannot HW encode these, and we don't have the CPU # for it either if is_potato_cpu() and \ resolution.width > MJPEGEncoder.WIDTH_LIMIT: continue resolutions.add(Resolution( resolution.width, resolution.height)) return resolutions @staticmethod def make_camera_configuration(camera, still_resolution: Resolution, raw_resolution: Resolution, pixel_format: str): """Creates a camera configuration for our specific use case Sets the raw sensor resolution, the scaled down output resolution and the pixel format for a specified camera The buffer counts are hardcoded, getting more of them would incentivize the camera stack to pre-fill them which would mean we'd get old data from the first couple of them """ camera_configuration = camera.generate_configuration( [StreamRole.Raw, StreamRole.StillCapture]) raw_configuration: StreamConfiguration = camera_configuration.at(0) raw_configuration.size = Size(raw_resolution.width, raw_resolution.height) raw_configuration.buffer_count = 0 still_configuration: StreamConfiguration = camera_configuration.at(1) still_configuration.size = Size(still_resolution.width, still_resolution.height) still_configuration.pixel_format = PixelFormat(pixel_format) still_configuration.buffer_count = 1 return camera_configuration def _connect(self): """Connects to the picamera""" for camera in self.camera_manager.cameras: if camera.id == self.config["id_string"]: self.camera = camera break if self.camera is None: raise RuntimeError("Couldn't find a configured pi camera" f" {self.config['name']} in the connected ones") self._capabilities = ({ CapabilityType.TRIGGER_SCHEME, CapabilityType.IMAGING, CapabilityType.RESOLUTION, }) if controls.LensPosition in self.camera.controls: self._capabilities.add(CapabilityType.FOCUS) # Defaults to infinity self._config["focus"] = self._config.get("focus", str(0.0)) self.set_focus(float(self._config["focus"])) sensor_resolutions = self.get_resolutions( self.camera, StreamRole.Raw) self._available_resolutions = self.get_resolutions( self.camera, StreamRole.StillCapture, SUPPORTED_PIXEL_FORMAT) if not self.available_resolutions or not sensor_resolutions: raise NotSupported( "Sorry, PrusaLink PiCamera module supports only YUYV 4:2:2. " "This camera does not support either that, or something else " "is broken") self.raw_resolution = sorted(sensor_resolutions)[-1] self.camera.acquire() self.allocator = FrameBufferAllocator(self.camera) initial_resolution = self._get_initial_resolution( self._available_resolutions, self._config) self._set_resolution(initial_resolution) self._config["resolution"] = str(initial_resolution) self._start() def _start(self): """A method to start the camera and the encoder after connecting or parameter change""" # set controls again if controls.AfMode in self.camera.controls: self.controls_to_set[controls.AfMode] = \ controls.AfModeEnum.Manual self.controls_to_set[controls.ScalerCrop] = self.scaler_crop self.encoder.start() self.camera.start() @staticmethod def _get_scalar_crop(raw_resolution, target_resolution): """Figures out how to crop the raw sensor to get the resulting scaled image in the correct aspect ratio""" raw_aspect_ratio = (raw_resolution.width / raw_resolution.height) still_aspect_ratio = (target_resolution.width / target_resolution.height) if raw_aspect_ratio > still_aspect_ratio: width = int(raw_resolution.height * still_aspect_ratio) width_offset = int((raw_resolution.width - width) / 2) cropped_size = Size(width, raw_resolution.height) scaler_crop = Rectangle(width_offset, 0, cropped_size) elif raw_aspect_ratio < still_aspect_ratio: height = int(raw_resolution.width / still_aspect_ratio) height_offset = int((raw_resolution.height - height) / 2) cropped_size = Size(raw_resolution.width, height) scaler_crop = Rectangle(0, height_offset, cropped_size) else: cropped_size = Size(raw_resolution.width, raw_resolution.height) scaler_crop = Rectangle(0, 0, cropped_size) return scaler_crop @param_change def set_resolution(self, resolution): """Sets the camera resolution""" self._set_resolution(resolution) def _set_resolution(self, resolution): """A way to set the resolution without @param_change""" self.allocator.buffers(self.stream).clear() self.allocator = None self.request = None self.stream = None gc.collect() camera_configuration = self.make_camera_configuration( self.camera, resolution, self.raw_resolution, SUPPORTED_PIXEL_FORMAT) camera_configuration.validate() self.scaler_crop = self._get_scalar_crop( raw_resolution=self.raw_resolution, target_resolution=resolution) self.camera.configure(camera_configuration) self.stream = camera_configuration.at(1).stream # A lot of this can fail, that would hopefully result in another # attempt to connect. To see what result codes to expect and stuff, # look at picamera2 on github, they do it the more proper way gc.collect() self.allocator = FrameBufferAllocator(self.camera) self.allocator.allocate(self.stream) buffer = self.allocator.buffers(self.stream)[0] self.request = self.camera.create_request() self.request.add_buffer(self.stream, buffer) self.encoder = get_appropriate_encoder( resolution, v4l2.v4l2_fourcc(*SUPPORTED_PIXEL_FORMAT)) plane = buffer.planes[0] self.encoder.source_details = BufferDetails( file_descriptor=plane.fd, length=self.stream.configuration.frame_size, offset=plane.offset) self.encoder.width = resolution.width self.encoder.height = resolution.height self.encoder.stride = self.stream.configuration.stride def _focus_transform(self, value): """Transforms the focus value from 0 - 1 to the range supported by the camera""" min_position = self.camera.controls[controls.LensPosition].min max_position = self.camera.controls[controls.LensPosition].max position_range = max_position - min_position return value * position_range - min_position def set_focus(self, focus): """Sets the camera resolution""" self.controls_to_set[controls.LensPosition] = \ self._focus_transform(focus) def take_a_photo(self): """Asks for eight photos but is only interested in the last one""" prctl_name() log.debug("Taking a photo!") self.request.reuse() for control_id, value in self.controls_to_set.items(): self.request.set_control(control_id, value) self.controls_to_set.clear() self.camera.queue_request(self.request) started_at = time() while True: remaining = started_at + CAMERA_WAIT_TIMEOUT - time() if self.request.status == Request.Status.Complete: break if remaining <= 0: raise TimeoutError("Taking a photo timed out") # Cannot use returned events for breaking this loop because # we would need to handle a negative time remaining as well select.select((self.camera_manager.event_fd,), (), (), remaining) log.debug("Converting a photo") data = self.encoder.encode(self.stream.configuration.frame_size) log.debug("Done converting a photo") return data def _disconnect(self): """Disconnects from the camera""" if self.camera is None: return self.camera.stop() self.camera.release() ================================================ FILE: prusa/link/cameras/v4l2.py ================================================ # Python bindings for the v4l2 userspace api # Copyright (C) 1999-2009 the contributors # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # Alternatively you can redistribute this file under the terms of the # BSD license as stated below: # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # 3. The names of its contributors may not be used to endorse or promote # products derived from this software without specific prior written # permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ Python bindings for the v4l2 userspace api in Linux 2.6.34 """ # see linux/videodev2.h # flake8: noqa import ctypes _IOC_NRBITS = 8 _IOC_TYPEBITS = 8 _IOC_SIZEBITS = 14 _IOC_DIRBITS = 2 _IOC_NRSHIFT = 0 _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS _IOC_NONE = 0 _IOC_WRITE = 1 _IOC_READ = 2 def _IOC(dir_, type_, nr, size): return ( ctypes.c_int32(dir_ << _IOC_DIRSHIFT).value | ctypes.c_int32(ord(type_) << _IOC_TYPESHIFT).value | ctypes.c_int32(nr << _IOC_NRSHIFT).value | ctypes.c_int32(size << _IOC_SIZESHIFT).value) def _IOC_TYPECHECK(t): return ctypes.sizeof(t) def _IO(type_, nr): return _IOC(_IOC_NONE, type_, nr, 0) def _IOW(type_, nr, size): return _IOC(_IOC_WRITE, type_, nr, _IOC_TYPECHECK(size)) def _IOR(type_, nr, size): return _IOC(_IOC_READ, type_, nr, _IOC_TYPECHECK(size)) def _IOWR(type_, nr, size): return _IOC(_IOC_READ | _IOC_WRITE, type_, nr, _IOC_TYPECHECK(size)) # # type alias # enum = ctypes.c_uint c_int = ctypes.c_int # # time # class timeval(ctypes.Structure): _fields_ = [ ('secs', ctypes.c_long), ('usecs', ctypes.c_long), ] # # v4l2 # VIDEO_MAX_FRAME = 32 VIDEO_MAX_PLANES = 8 VID_TYPE_CAPTURE = 1 VID_TYPE_TUNER = 2 VID_TYPE_TELETEXT = 4 VID_TYPE_OVERLAY = 8 VID_TYPE_CHROMAKEY = 16 VID_TYPE_CLIPPING = 32 VID_TYPE_FRAMERAM = 64 VID_TYPE_SCALES = 128 VID_TYPE_MONOCHROME = 256 VID_TYPE_SUBCAPTURE = 512 VID_TYPE_MPEG_DECODER = 1024 VID_TYPE_MPEG_ENCODER = 2048 VID_TYPE_MJPEG_DECODER = 4096 VID_TYPE_MJPEG_ENCODER = 8192 def v4l2_fourcc(a, b, c, d): return ord(a) | (ord(b) << 8) | (ord(c) << 16) | (ord(d) << 24) def v4l2_fourcc2str(fourcc): a = chr(fourcc & 0xFF) b = chr((fourcc >> 8) & 0xFF) c = chr((fourcc >> 16) & 0xFF) d = chr((fourcc >> 24) & 0xFF) return ''.join([a, b, c, d]) v4l2_field = enum ( V4L2_FIELD_ANY, V4L2_FIELD_NONE, V4L2_FIELD_TOP, V4L2_FIELD_BOTTOM, V4L2_FIELD_INTERLACED, V4L2_FIELD_SEQ_TB, V4L2_FIELD_SEQ_BT, V4L2_FIELD_ALTERNATE, V4L2_FIELD_INTERLACED_TB, V4L2_FIELD_INTERLACED_BT, ) = range(10) def V4L2_FIELD_HAS_TOP(field): return ( field == V4L2_FIELD_TOP or field == V4L2_FIELD_INTERLACED or field == V4L2_FIELD_INTERLACED_TB or field == V4L2_FIELD_INTERLACED_BT or field == V4L2_FIELD_SEQ_TB or field == V4L2_FIELD_SEQ_BT) def V4L2_FIELD_HAS_BOTTOM(field): return ( field == V4L2_FIELD_BOTTOM or field == V4L2_FIELD_INTERLACED or field == V4L2_FIELD_INTERLACED_TB or field == V4L2_FIELD_INTERLACED_BT or field == V4L2_FIELD_SEQ_TB or field == V4L2_FIELD_SEQ_BT) def V4L2_FIELD_HAS_BOTH(field): return ( field == V4L2_FIELD_INTERLACED or field == V4L2_FIELD_INTERLACED_TB or field == V4L2_FIELD_INTERLACED_BT or field == V4L2_FIELD_SEQ_TB or field == V4L2_FIELD_SEQ_BT) v4l2_buf_type = enum ( V4L2_BUF_TYPE_VIDEO_CAPTURE, V4L2_BUF_TYPE_VIDEO_OUTPUT, V4L2_BUF_TYPE_VIDEO_OVERLAY, V4L2_BUF_TYPE_VBI_CAPTURE, V4L2_BUF_TYPE_VBI_OUTPUT, V4L2_BUF_TYPE_SLICED_VBI_CAPTURE, V4L2_BUF_TYPE_SLICED_VBI_OUTPUT, V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, V4L2_BUF_TYPE_SDR_CAPTURE, V4L2_BUF_TYPE_SDR_OUTPUT, V4L2_BUF_TYPE_META_CAPTURE, V4L2_BUF_TYPE_PRIVATE, # Deprecated, do not use. ) = list(range(1, 14)) + [0x80] v4l2_ctrl_type = enum ( V4L2_CTRL_TYPE_INTEGER, V4L2_CTRL_TYPE_BOOLEAN, V4L2_CTRL_TYPE_MENU, V4L2_CTRL_TYPE_BUTTON, V4L2_CTRL_TYPE_INTEGER64, V4L2_CTRL_TYPE_CTRL_CLASS, V4L2_CTRL_TYPE_STRING, V4L2_CTRL_TYPE_BITMASK, V4L2_CTRL_TYPE_INTEGER_MENU, ) = range(1, 10) # Compound types are >= 0x0100 V4L2_CTRL_COMPOUND_TYPES = 0x0100 V4L2_CTRL_TYPE_U8 = 0x0100 V4L2_CTRL_TYPE_U16 = 0x0101 V4L2_CTRL_TYPE_U32 = 0x0102 v4l2_tuner_type = enum ( V4L2_TUNER_RADIO, V4L2_TUNER_ANALOG_TV, V4L2_TUNER_DIGITAL_TV, ) = range(1, 4) v4l2_memory = enum ( V4L2_MEMORY_MMAP, V4L2_MEMORY_USERPTR, V4L2_MEMORY_OVERLAY, V4L2_MEMORY_DMABUF, ) = range(1, 5) v4l2_colorspace = enum ( #Default colorspace, i.e. let the driver figure it out. #Can only be used with video capture. V4L2_COLORSPACE_DEFAULT, # SMPTE 170M: used for broadcast NTSC/PAL SDTV V4L2_COLORSPACE_SMPTE170M, # Obsolete pre-1998 SMPTE 240M HDTV standard, superseded by Rec 709 V4L2_COLORSPACE_SMPTE240M, # Rec.709: used for HDTV V4L2_COLORSPACE_REC709, #Deprecated, do not use. No driver will ever return this. This was #based on a misunderstanding of the bt878 datasheet. V4L2_COLORSPACE_BT878, #NTSC 1953 colorspace. This only makes sense when dealing with #really, really old NTSC recordings. Superseded by SMPTE 170M. V4L2_COLORSPACE_470_SYSTEM_M, #EBU Tech 3213 PAL/SECAM colorspace. This only makes sense when #dealing with really old PAL/SECAM recordings. Superseded by #SMPTE 170M. V4L2_COLORSPACE_470_SYSTEM_BG, #Effectively shorthand for V4L2_COLORSPACE_SRGB, V4L2_YCBCR_ENC_601 #and V4L2_QUANTIZATION_FULL_RANGE. To be used for (Motion-)JPEG. V4L2_COLORSPACE_JPEG, # For RGB colorspaces such as produces by most webcams. V4L2_COLORSPACE_SRGB, # AdobeRGB colorspace V4L2_COLORSPACE_ADOBERGB, # BT.2020 colorspace, used for UHDTV. V4L2_COLORSPACE_BT2020, # Raw colorspace: for RAW unprocessed images V4L2_COLORSPACE_RAW, # DCI-P3 colorspace, used by cinema projectors V4L2_COLORSPACE_DCI_P3, ) = range(0, 13) v4l2_priority = enum ( V4L2_PRIORITY_UNSET, V4L2_PRIORITY_BACKGROUND, V4L2_PRIORITY_INTERACTIVE, V4L2_PRIORITY_RECORD, V4L2_PRIORITY_DEFAULT, ) = list(range(0, 4)) + [2] class v4l2_rect(ctypes.Structure): _fields_ = [ ('left', ctypes.c_int32), ('top', ctypes.c_int32), ('width', ctypes.c_int32), ('height', ctypes.c_int32), ] class v4l2_fract(ctypes.Structure): _fields_ = [ ('numerator', ctypes.c_uint32), ('denominator', ctypes.c_uint32), ] # # Driver capabilities # class v4l2_capability(ctypes.Structure): _fields_ = [ ('driver', ctypes.c_char * 16), ('card', ctypes.c_char * 32), ('bus_info', ctypes.c_char * 32), ('version', ctypes.c_uint32), ('capabilities', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] # # Values for 'capabilities' field # V4L2_CAP_VIDEO_CAPTURE = 0x00000001 # Is a video capture device V4L2_CAP_VIDEO_OUTPUT = 0x00000002 # Is a video output device V4L2_CAP_VIDEO_OVERLAY = 0x00000004 # Can do video overlay V4L2_CAP_VBI_CAPTURE = 0x00000010 # Is a raw VBI capture device V4L2_CAP_VBI_OUTPUT = 0x00000020 # Is a raw VBI output device V4L2_CAP_SLICED_VBI_CAPTURE = 0x00000040 # Is a sliced VBI capture device V4L2_CAP_SLICED_VBI_OUTPUT = 0x00000080 # Is a sliced VBI output device V4L2_CAP_RDS_CAPTURE = 0x00000100 # RDS data capture V4L2_CAP_VIDEO_OUTPUT_OVERLAY = 0x00000200 # Can do video output overlay V4L2_CAP_HW_FREQ_SEEK = 0x00000400 # Can do hardware frequency seek V4L2_CAP_RDS_OUTPUT = 0x00000800 # Is an RDS encoder V4L2_CAP_VIDEO_CAPTURE_MPLANE = 0x00001000 # Is a video capture device that supports multiplanar formats V4L2_CAP_VIDEO_OUTPUT_MPLANE = 0x00002000 # Is a video output device that supports multiplanar formats V4L2_CAP_VIDEO_M2M_MPLANE = 0x00004000 # Is a video mem-to-mem device that supports multiplanar formats V4L2_CAP_VIDEO_M2M = 0x00008000 # Is a video mem-to-mem device V4L2_CAP_TUNER = 0x00010000 # has a tuner V4L2_CAP_AUDIO = 0x00020000 # has audio support V4L2_CAP_RADIO = 0x00040000 # is a radio device V4L2_CAP_MODULATOR = 0x00080000 # has a modulator V4L2_CAP_SDR_CAPTURE = 0x00100000 # Is a SDR capture device V4L2_CAP_EXT_PIX_FORMAT = 0x00200000 # Supports the extended pixel format V4L2_CAP_SDR_OUTPUT = 0x00400000 # Is a SDR output device V4L2_CAP_META_CAPTURE = 0x00800000 # Is a metadata capture device V4L2_CAP_READWRITE = 0x01000000 # read/write systemcalls V4L2_CAP_ASYNCIO = 0x02000000 # async I/O V4L2_CAP_STREAMING = 0x04000000 # streaming I/O ioctls V4L2_CAP_TOUCH = 0x10000000 # Is a touch device V4L2_CAP_DEVICE_CAPS = 0x80000000 # sets device capabilities field # # Video image format # class v4l2_pix_format(ctypes.Structure): _fields_ = [ ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ('pixelformat', ctypes.c_uint32), ('field', v4l2_field), ('bytesperline', ctypes.c_uint32), ('sizeimage', ctypes.c_uint32), ('colorspace', v4l2_colorspace), ('priv', ctypes.c_uint32), ] # RGB formats V4L2_PIX_FMT_RGB332 = v4l2_fourcc('R', 'G', 'B', '1') V4L2_PIX_FMT_RGB444 = v4l2_fourcc('R', '4', '4', '4') V4L2_PIX_FMT_RGB555 = v4l2_fourcc('R', 'G', 'B', 'O') V4L2_PIX_FMT_RGB565 = v4l2_fourcc('R', 'G', 'B', 'P') V4L2_PIX_FMT_RGB555X = v4l2_fourcc('R', 'G', 'B', 'Q') V4L2_PIX_FMT_RGB565X = v4l2_fourcc('R', 'G', 'B', 'R') V4L2_PIX_FMT_BGR24 = v4l2_fourcc('B', 'G', 'R', '3') V4L2_PIX_FMT_RGB24 = v4l2_fourcc('R', 'G', 'B', '3') V4L2_PIX_FMT_BGR32 = v4l2_fourcc('B', 'G', 'R', '4') V4L2_PIX_FMT_RGB32 = v4l2_fourcc('R', 'G', 'B', '4') V4L2_PIX_FMT_RGBX32 = v4l2_fourcc('X', 'B', '2', '4') V4L2_PIX_FMT_XRGB32 = v4l2_fourcc('B', 'X', '2', '4') V4L2_PIX_FMT_RGBA32 = v4l2_fourcc('A', 'B', '2', '4') # Grey formats V4L2_PIX_FMT_GREY = v4l2_fourcc('G', 'R', 'E', 'Y') V4L2_PIX_FMT_Y10 = v4l2_fourcc('Y', '1', '0', ' ') V4L2_PIX_FMT_Y16 = v4l2_fourcc('Y', '1', '6', ' ') # Palette formats V4L2_PIX_FMT_PAL8 = v4l2_fourcc('P', 'A', 'L', '8') # Luminance+Chrominance formats V4L2_PIX_FMT_YVU410 = v4l2_fourcc('Y', 'V', 'U', '9') V4L2_PIX_FMT_YVU420 = v4l2_fourcc('Y', 'V', '1', '2') V4L2_PIX_FMT_YUYV = v4l2_fourcc('Y', 'U', 'Y', 'V') V4L2_PIX_FMT_YYUV = v4l2_fourcc('Y', 'Y', 'U', 'V') V4L2_PIX_FMT_YVYU = v4l2_fourcc('Y', 'V', 'Y', 'U') V4L2_PIX_FMT_UYVY = v4l2_fourcc('U', 'Y', 'V', 'Y') V4L2_PIX_FMT_VYUY = v4l2_fourcc('V', 'Y', 'U', 'Y') V4L2_PIX_FMT_YUV422P = v4l2_fourcc('4', '2', '2', 'P') V4L2_PIX_FMT_YUV411P = v4l2_fourcc('4', '1', '1', 'P') V4L2_PIX_FMT_Y41P = v4l2_fourcc('Y', '4', '1', 'P') V4L2_PIX_FMT_YUV444 = v4l2_fourcc('Y', '4', '4', '4') V4L2_PIX_FMT_YUV555 = v4l2_fourcc('Y', 'U', 'V', 'O') V4L2_PIX_FMT_YUV565 = v4l2_fourcc('Y', 'U', 'V', 'P') V4L2_PIX_FMT_YUV32 = v4l2_fourcc('Y', 'U', 'V', '4') V4L2_PIX_FMT_YUV410 = v4l2_fourcc('Y', 'U', 'V', '9') V4L2_PIX_FMT_YUV420 = v4l2_fourcc('Y', 'U', '1', '2') V4L2_PIX_FMT_HI240 = v4l2_fourcc('H', 'I', '2', '4') V4L2_PIX_FMT_HM12 = v4l2_fourcc('H', 'M', '1', '2') # two planes -- one Y, one Cr + Cb interleaved V4L2_PIX_FMT_NV12 = v4l2_fourcc('N', 'V', '1', '2') V4L2_PIX_FMT_NV21 = v4l2_fourcc('N', 'V', '2', '1') V4L2_PIX_FMT_NV16 = v4l2_fourcc('N', 'V', '1', '6') V4L2_PIX_FMT_NV61 = v4l2_fourcc('N', 'V', '6', '1') # Bayer formats - see http://www.siliconimaging.com/RGB%20Bayer.htm V4L2_PIX_FMT_SBGGR8 = v4l2_fourcc('B', 'A', '8', '1') V4L2_PIX_FMT_SGBRG8 = v4l2_fourcc('G', 'B', 'R', 'G') V4L2_PIX_FMT_SGRBG8 = v4l2_fourcc('G', 'R', 'B', 'G') V4L2_PIX_FMT_SRGGB8 = v4l2_fourcc('R', 'G', 'G', 'B') V4L2_PIX_FMT_SBGGR10 = v4l2_fourcc('B', 'G', '1', '0') V4L2_PIX_FMT_SGBRG10 = v4l2_fourcc('G', 'B', '1', '0') V4L2_PIX_FMT_SGRBG10 = v4l2_fourcc('B', 'A', '1', '0') V4L2_PIX_FMT_SRGGB10 = v4l2_fourcc('R', 'G', '1', '0') V4L2_PIX_FMT_SGRBG10DPCM8 = v4l2_fourcc('B', 'D', '1', '0') V4L2_PIX_FMT_SBGGR16 = v4l2_fourcc('B', 'Y', 'R', '2') # compressed formats V4L2_PIX_FMT_MJPEG = v4l2_fourcc('M', 'J', 'P', 'G') V4L2_PIX_FMT_JPEG = v4l2_fourcc('J', 'P', 'E', 'G') V4L2_PIX_FMT_DV = v4l2_fourcc('d', 'v', 's', 'd') V4L2_PIX_FMT_MPEG = v4l2_fourcc('M', 'P', 'E', 'G') V4L2_PIX_FMT_H264 = v4l2_fourcc('H', '2', '6', '4') # Vendor-specific formats V4L2_PIX_FMT_CPIA1 = v4l2_fourcc('C', 'P', 'I', 'A') V4L2_PIX_FMT_WNVA = v4l2_fourcc('W', 'N', 'V', 'A') V4L2_PIX_FMT_SN9C10X = v4l2_fourcc('S', '9', '1', '0') V4L2_PIX_FMT_SN9C20X_I420 = v4l2_fourcc('S', '9', '2', '0') V4L2_PIX_FMT_PWC1 = v4l2_fourcc('P', 'W', 'C', '1') V4L2_PIX_FMT_PWC2 = v4l2_fourcc('P', 'W', 'C', '2') V4L2_PIX_FMT_ET61X251 = v4l2_fourcc('E', '6', '2', '5') V4L2_PIX_FMT_SPCA501 = v4l2_fourcc('S', '5', '0', '1') V4L2_PIX_FMT_SPCA505 = v4l2_fourcc('S', '5', '0', '5') V4L2_PIX_FMT_SPCA508 = v4l2_fourcc('S', '5', '0', '8') V4L2_PIX_FMT_SPCA561 = v4l2_fourcc('S', '5', '6', '1') V4L2_PIX_FMT_PAC207 = v4l2_fourcc('P', '2', '0', '7') V4L2_PIX_FMT_MR97310A = v4l2_fourcc('M', '3', '1', '0') V4L2_PIX_FMT_SN9C2028 = v4l2_fourcc('S', 'O', 'N', 'X') V4L2_PIX_FMT_SQ905C = v4l2_fourcc('9', '0', '5', 'C') V4L2_PIX_FMT_PJPG = v4l2_fourcc('P', 'J', 'P', 'G') V4L2_PIX_FMT_OV511 = v4l2_fourcc('O', '5', '1', '1') V4L2_PIX_FMT_OV518 = v4l2_fourcc('O', '5', '1', '8') V4L2_PIX_FMT_STV0680 = v4l2_fourcc('S', '6', '8', '0') # # Format enumeration # class v4l2_fmtdesc(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('type', ctypes.c_int), ('flags', ctypes.c_uint32), ('description', ctypes.c_char * 32), ('pixelformat', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] V4L2_FMT_FLAG_COMPRESSED = 0x0001 V4L2_FMT_FLAG_EMULATED = 0x0002 # # Experimental frame size and frame rate enumeration # v4l2_frmsizetypes = enum ( V4L2_FRMSIZE_TYPE_DISCRETE, V4L2_FRMSIZE_TYPE_CONTINUOUS, V4L2_FRMSIZE_TYPE_STEPWISE, ) = range(1, 4) class v4l2_frmsize_discrete(ctypes.Structure): _fields_ = [ ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ] class v4l2_frmsize_stepwise(ctypes.Structure): _fields_ = [ ('min_width', ctypes.c_uint32), ('min_height', ctypes.c_uint32), ('step_width', ctypes.c_uint32), ('min_height', ctypes.c_uint32), ('max_height', ctypes.c_uint32), ('step_height', ctypes.c_uint32), ] class v4l2_frmsizeenum(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('discrete', v4l2_frmsize_discrete), ('stepwise', v4l2_frmsize_stepwise), ] _fields_ = [ ('index', ctypes.c_uint32), ('pixel_format', ctypes.c_uint32), ('type', ctypes.c_uint32), ('_u', _u), ('reserved', ctypes.c_uint32 * 2) ] _anonymous_ = ('_u',) # # Frame rate enumeration # v4l2_frmivaltypes = enum ( V4L2_FRMIVAL_TYPE_DISCRETE, V4L2_FRMIVAL_TYPE_CONTINUOUS, V4L2_FRMIVAL_TYPE_STEPWISE, ) = range(1, 4) class v4l2_frmival_stepwise(ctypes.Structure): _fields_ = [ ('min', v4l2_fract), ('max', v4l2_fract), ('step', v4l2_fract), ] class v4l2_frmivalenum(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('discrete', v4l2_fract), ('stepwise', v4l2_frmival_stepwise), ] _fields_ = [ ('index', ctypes.c_uint32), ('pixel_format', ctypes.c_uint32), ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ('type', ctypes.c_uint32), ('_u', _u), ('reserved', ctypes.c_uint32 * 2), ] _anonymous_ = ('_u',) # # Timecode # class v4l2_timecode(ctypes.Structure): _fields_ = [ ('type', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('frames', ctypes.c_uint8), ('seconds', ctypes.c_uint8), ('minutes', ctypes.c_uint8), ('hours', ctypes.c_uint8), ('userbits', ctypes.c_uint8 * 4), ] V4L2_TC_TYPE_24FPS = 1 V4L2_TC_TYPE_25FPS = 2 V4L2_TC_TYPE_30FPS = 3 V4L2_TC_TYPE_50FPS = 4 V4L2_TC_TYPE_60FPS = 5 V4L2_TC_FLAG_DROPFRAME = 0x0001 V4L2_TC_FLAG_COLORFRAME = 0x0002 V4L2_TC_USERBITS_field = 0x000C V4L2_TC_USERBITS_USERDEFINED = 0x0000 V4L2_TC_USERBITS_8BITCHARS = 0x0008 class v4l2_jpegcompression(ctypes.Structure): _fields_ = [ ('quality', ctypes.c_int), ('APPn', ctypes.c_int), ('APP_len', ctypes.c_int), ('APP_data', ctypes.c_char * 60), ('COM_len', ctypes.c_int), ('COM_data', ctypes.c_char * 60), ('jpeg_markers', ctypes.c_uint32), ] V4L2_JPEG_MARKER_DHT = 1 << 3 V4L2_JPEG_MARKER_DQT = 1 << 4 V4L2_JPEG_MARKER_DRI = 1 << 5 V4L2_JPEG_MARKER_COM = 1 << 6 V4L2_JPEG_MARKER_APP = 1 << 7 # # Memory-mapping buffers # # https://www.kernel.org/doc/html/v5.10/userspace-api/media/v4l/buffer.html#struct-v4l2-plane class v4l2_plane(ctypes.Structure): class _u(ctypes.Union): _fields_ = [("mem_offset", ctypes.c_uint32), ("userptr", ctypes.c_ulong), ("fd", ctypes.c_int32)] _fields_ = [ ('bytesused', ctypes.c_uint32), ('length', ctypes.c_uint32), ('m', _u), ('data_offset', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 11) ] class v4l2_requestbuffers(ctypes.Structure): _fields_ = [ ('count', ctypes.c_uint32), ('type', v4l2_buf_type), ('memory', v4l2_memory), ('reserved', ctypes.c_uint32 * 2), ] class v4l2_buffer(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('offset', ctypes.c_uint32), ('userptr', ctypes.c_ulong), ('planes', ctypes.POINTER(v4l2_plane)), ('fd', ctypes.c_int32) ] _fields_ = [ ('index', ctypes.c_uint32), ('type', v4l2_buf_type), ('bytesused', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('field', v4l2_field), ('timestamp', timeval), ('timecode', v4l2_timecode), ('sequence', ctypes.c_uint32), ('memory', v4l2_memory), ('m', _u), ('length', ctypes.c_uint32), ('input', ctypes.c_uint32), ('reserved', ctypes.c_uint32), ] V4L2_BUF_FLAG_MAPPED = 0x0001 V4L2_BUF_FLAG_QUEUED = 0x0002 V4L2_BUF_FLAG_DONE = 0x0004 V4L2_BUF_FLAG_KEYFRAME = 0x0008 V4L2_BUF_FLAG_PFRAME = 0x0010 V4L2_BUF_FLAG_BFRAME = 0x0020 V4L2_BUF_FLAG_TIMECODE = 0x0100 V4L2_BUF_FLAG_INPUT = 0x0200 # # Overlay preview # class v4l2_framebuffer(ctypes.Structure): _fields_ = [ ('capability', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('base', ctypes.c_void_p), ('fmt', v4l2_pix_format), ] V4L2_FBUF_CAP_EXTERNOVERLAY = 0x0001 V4L2_FBUF_CAP_CHROMAKEY = 0x0002 V4L2_FBUF_CAP_LIST_CLIPPING = 0x0004 V4L2_FBUF_CAP_BITMAP_CLIPPING = 0x0008 V4L2_FBUF_CAP_LOCAL_ALPHA = 0x0010 V4L2_FBUF_CAP_GLOBAL_ALPHA = 0x0020 V4L2_FBUF_CAP_LOCAL_INV_ALPHA = 0x0040 V4L2_FBUF_CAP_SRC_CHROMAKEY = 0x0080 V4L2_FBUF_FLAG_PRIMARY = 0x0001 V4L2_FBUF_FLAG_OVERLAY = 0x0002 V4L2_FBUF_FLAG_CHROMAKEY = 0x0004 V4L2_FBUF_FLAG_LOCAL_ALPHA = 0x0008 V4L2_FBUF_FLAG_GLOBAL_ALPHA = 0x0010 V4L2_FBUF_FLAG_LOCAL_INV_ALPHA = 0x0020 V4L2_FBUF_FLAG_SRC_CHROMAKEY = 0x0040 class v4l2_clip(ctypes.Structure): pass v4l2_clip._fields_ = [ ('c', v4l2_rect), ('next', ctypes.POINTER(v4l2_clip)), ] class v4l2_window(ctypes.Structure): _fields_ = [ ('w', v4l2_rect), ('field', v4l2_field), ('chromakey', ctypes.c_uint32), ('clips', ctypes.POINTER(v4l2_clip)), ('clipcount', ctypes.c_uint32), ('bitmap', ctypes.c_void_p), ('global_alpha', ctypes.c_uint8), ] # # Capture parameters # class v4l2_captureparm(ctypes.Structure): _fields_ = [ ('capability', ctypes.c_uint32), ('capturemode', ctypes.c_uint32), ('timeperframe', v4l2_fract), ('extendedmode', ctypes.c_uint32), ('readbuffers', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] V4L2_MODE_HIGHQUALITY = 0x0001 V4L2_CAP_TIMEPERFRAME = 0x1000 class v4l2_outputparm(ctypes.Structure): _fields_ = [ ('capability', ctypes.c_uint32), ('outputmode', ctypes.c_uint32), ('timeperframe', v4l2_fract), ('extendedmode', ctypes.c_uint32), ('writebuffers', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] # # Input image cropping # class v4l2_cropcap(ctypes.Structure): _fields_ = [ ('type', v4l2_buf_type), ('bounds', v4l2_rect), ('defrect', v4l2_rect), ('pixelaspect', v4l2_fract), ] class v4l2_crop(ctypes.Structure): _fields_ = [ ('type', ctypes.c_int), ('c', v4l2_rect), ] # # Analog video standard # v4l2_std_id = ctypes.c_uint64 V4L2_STD_PAL_B = 0x00000001 V4L2_STD_PAL_B1 = 0x00000002 V4L2_STD_PAL_G = 0x00000004 V4L2_STD_PAL_H = 0x00000008 V4L2_STD_PAL_I = 0x00000010 V4L2_STD_PAL_D = 0x00000020 V4L2_STD_PAL_D1 = 0x00000040 V4L2_STD_PAL_K = 0x00000080 V4L2_STD_PAL_M = 0x00000100 V4L2_STD_PAL_N = 0x00000200 V4L2_STD_PAL_Nc = 0x00000400 V4L2_STD_PAL_60 = 0x00000800 V4L2_STD_NTSC_M = 0x00001000 V4L2_STD_NTSC_M_JP = 0x00002000 V4L2_STD_NTSC_443 = 0x00004000 V4L2_STD_NTSC_M_KR = 0x00008000 V4L2_STD_SECAM_B = 0x00010000 V4L2_STD_SECAM_D = 0x00020000 V4L2_STD_SECAM_G = 0x00040000 V4L2_STD_SECAM_H = 0x00080000 V4L2_STD_SECAM_K = 0x00100000 V4L2_STD_SECAM_K1 = 0x00200000 V4L2_STD_SECAM_L = 0x00400000 V4L2_STD_SECAM_LC = 0x00800000 V4L2_STD_ATSC_8_VSB = 0x01000000 V4L2_STD_ATSC_16_VSB = 0x02000000 # some common needed stuff V4L2_STD_PAL_BG = (V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_PAL_G) V4L2_STD_PAL_DK = (V4L2_STD_PAL_D | V4L2_STD_PAL_D1 | V4L2_STD_PAL_K) V4L2_STD_PAL = (V4L2_STD_PAL_BG | V4L2_STD_PAL_DK | V4L2_STD_PAL_H | V4L2_STD_PAL_I) V4L2_STD_NTSC = (V4L2_STD_NTSC_M | V4L2_STD_NTSC_M_JP | V4L2_STD_NTSC_M_KR) V4L2_STD_SECAM_DK = (V4L2_STD_SECAM_D | V4L2_STD_SECAM_K | V4L2_STD_SECAM_K1) V4L2_STD_SECAM = (V4L2_STD_SECAM_B | V4L2_STD_SECAM_G | V4L2_STD_SECAM_H | V4L2_STD_SECAM_DK | V4L2_STD_SECAM_L | V4L2_STD_SECAM_LC) V4L2_STD_525_60 = (V4L2_STD_PAL_M | V4L2_STD_PAL_60 | V4L2_STD_NTSC | V4L2_STD_NTSC_443) V4L2_STD_625_50 = (V4L2_STD_PAL | V4L2_STD_PAL_N | V4L2_STD_PAL_Nc | V4L2_STD_SECAM) V4L2_STD_ATSC = (V4L2_STD_ATSC_8_VSB | V4L2_STD_ATSC_16_VSB) V4L2_STD_UNKNOWN = 0 V4L2_STD_ALL = (V4L2_STD_525_60 | V4L2_STD_625_50) # some merged standards V4L2_STD_MN = (V4L2_STD_PAL_M | V4L2_STD_PAL_N | V4L2_STD_PAL_Nc | V4L2_STD_NTSC) V4L2_STD_B = (V4L2_STD_PAL_B | V4L2_STD_PAL_B1 | V4L2_STD_SECAM_B) V4L2_STD_GH = (V4L2_STD_PAL_G | V4L2_STD_PAL_H|V4L2_STD_SECAM_G | V4L2_STD_SECAM_H) V4L2_STD_DK = (V4L2_STD_PAL_DK | V4L2_STD_SECAM_DK) class v4l2_standard(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('id', v4l2_std_id), ('name', ctypes.c_char * 24), ('frameperiod', v4l2_fract), ('framelines', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] # # Video timings dv preset # class v4l2_dv_preset(ctypes.Structure): _fields_ = [ ('preset', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4) ] # # DV preset enumeration # class v4l2_dv_enum_preset(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('preset', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] # # DV preset values # V4L2_DV_INVALID = 0 V4L2_DV_480P59_94 = 1 V4L2_DV_576P50 = 2 V4L2_DV_720P24 = 3 V4L2_DV_720P25 = 4 V4L2_DV_720P30 = 5 V4L2_DV_720P50 = 6 V4L2_DV_720P59_94 = 7 V4L2_DV_720P60 = 8 V4L2_DV_1080I29_97 = 9 V4L2_DV_1080I30 = 10 V4L2_DV_1080I25 = 11 V4L2_DV_1080I50 = 12 V4L2_DV_1080I60 = 13 V4L2_DV_1080P24 = 14 V4L2_DV_1080P25 = 15 V4L2_DV_1080P30 = 16 V4L2_DV_1080P50 = 17 V4L2_DV_1080P60 = 18 # # DV BT timings # class v4l2_bt_timings(ctypes.Structure): _fields_ = [ ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ('interlaced', ctypes.c_uint32), ('polarities', ctypes.c_uint32), ('pixelclock', ctypes.c_uint64), ('hfrontporch', ctypes.c_uint32), ('hsync', ctypes.c_uint32), ('hbackporch', ctypes.c_uint32), ('vfrontporch', ctypes.c_uint32), ('vsync', ctypes.c_uint32), ('vbackporch', ctypes.c_uint32), ('il_vfrontporch', ctypes.c_uint32), ('il_vsync', ctypes.c_uint32), ('il_vbackporch', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 16), ] _pack_ = True # Interlaced or progressive format V4L2_DV_PROGRESSIVE = 0 V4L2_DV_INTERLACED = 1 # Polarities. If bit is not set, it is assumed to be negative polarity V4L2_DV_VSYNC_POS_POL = 0x00000001 V4L2_DV_HSYNC_POS_POL = 0x00000002 class v4l2_dv_timings(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('bt', v4l2_bt_timings), ('reserved', ctypes.c_uint32 * 32), ] _fields_ = [ ('type', ctypes.c_uint32), ('_u', _u), ] _anonymous_ = ('_u',) _pack_ = True # Values for the type field V4L2_DV_BT_656_1120 = 0 # # Video inputs # class v4l2_input(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('type', ctypes.c_uint32), ('audioset', ctypes.c_uint32), ('tuner', ctypes.c_uint32), ('std', v4l2_std_id), ('status', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] V4L2_INPUT_TYPE_TUNER = 1 V4L2_INPUT_TYPE_CAMERA = 2 V4L2_IN_ST_NO_POWER = 0x00000001 V4L2_IN_ST_NO_SIGNAL = 0x00000002 V4L2_IN_ST_NO_COLOR = 0x00000004 V4L2_IN_ST_HFLIP = 0x00000010 V4L2_IN_ST_VFLIP = 0x00000020 V4L2_IN_ST_NO_H_LOCK = 0x00000100 V4L2_IN_ST_COLOR_KILL = 0x00000200 V4L2_IN_ST_NO_SYNC = 0x00010000 V4L2_IN_ST_NO_EQU = 0x00020000 V4L2_IN_ST_NO_CARRIER = 0x00040000 V4L2_IN_ST_MACROVISION = 0x01000000 V4L2_IN_ST_NO_ACCESS = 0x02000000 V4L2_IN_ST_VTR = 0x04000000 V4L2_IN_CAP_PRESETS = 0x00000001 V4L2_IN_CAP_CUSTOM_TIMINGS = 0x00000002 V4L2_IN_CAP_STD = 0x00000004 # # Video outputs # class v4l2_output(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('type', ctypes.c_uint32), ('audioset', ctypes.c_uint32), ('modulator', ctypes.c_uint32), ('std', v4l2_std_id), ('reserved', ctypes.c_uint32 * 4), ] V4L2_OUTPUT_TYPE_MODULATOR = 1 V4L2_OUTPUT_TYPE_ANALOG = 2 V4L2_OUTPUT_TYPE_ANALOGVGAOVERLAY = 3 V4L2_OUT_CAP_PRESETS = 0x00000001 V4L2_OUT_CAP_CUSTOM_TIMINGS = 0x00000002 V4L2_OUT_CAP_STD = 0x00000004 # # Controls # class v4l2_control(ctypes.Structure): _fields_ = [ ('id', ctypes.c_uint32), ('value', ctypes.c_int32), ] class v4l2_ext_control(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('value', ctypes.c_int32), ('value64', ctypes.c_int64), ('reserved', ctypes.c_void_p), ] _fields_ = [ ('id', ctypes.c_uint32), ('reserved2', ctypes.c_uint32 * 2), ('_u', _u) ] _anonymous_ = ('_u',) _pack_ = True class v4l2_ext_controls(ctypes.Structure): _fields_ = [ ('ctrl_class', ctypes.c_uint32), ('count', ctypes.c_uint32), ('error_idx', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ('controls', ctypes.POINTER(v4l2_ext_control)), ] V4L2_CTRL_CLASS_USER = 0x00980000 V4L2_CTRL_CLASS_MPEG = 0x00990000 V4L2_CTRL_CLASS_CAMERA = 0x009a0000 V4L2_CTRL_CLASS_FM_TX = 0x009b0000 def V4L2_CTRL_ID_MASK(): return 0x0fffffff def V4L2_CTRL_ID2CLASS(id_): return id_ & 0x0fff0000 # unsigned long def V4L2_CTRL_DRIVER_PRIV(id_): return (id_ & 0xffff) >= 0x1000 class v4l2_queryctrl(ctypes.Structure): _fields_ = [ ('id', ctypes.c_uint32), ('type', v4l2_ctrl_type), ('name', ctypes.c_char * 32), ('minimum', ctypes.c_int32), ('maximum', ctypes.c_int32), ('step', ctypes.c_int32), ('default_value', ctypes.c_int32), ('flags', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] class v4l2_querymenu(ctypes.Structure): _fields_ = [ ('id', ctypes.c_uint32), ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('reserved', ctypes.c_uint32), ] NONE = 0x0000 V4L2_CTRL_FLAG_DISABLED = 0x0001 V4L2_CTRL_FLAG_GRABBED = 0x0002 V4L2_CTRL_FLAG_READ_ONLY = 0x0004 V4L2_CTRL_FLAG_UPDATE = 0x0008 V4L2_CTRL_FLAG_INACTIVE = 0x0010 V4L2_CTRL_FLAG_SLIDER = 0x0020 V4L2_CTRL_FLAG_WRITE_ONLY = 0x0040 V4L2_CTRL_FLAG_VOLATILE = 0x0080 V4L2_CTRL_FLAG_HAS_PAYLOAD = 0x0100 V4L2_CTRL_FLAG_EXECUTE_ON_WRITE = 0x0200 V4L2_CTRL_FLAG_MODIFY_LAYOUT = 0x0400 V4L2_CTRL_FLAG_NEXT_CTRL = 0x80000000 V4L2_CTRL_FLAG_NEXT_COMPOUND = 0x40000000 V4L2_CID_BASE = V4L2_CTRL_CLASS_USER | 0x900 V4L2_CID_USER_BASE = V4L2_CID_BASE V4L2_CID_PRIVATE_BASE = 0x08000000 V4L2_CID_USER_CLASS = V4L2_CTRL_CLASS_USER | 1 V4L2_CID_BRIGHTNESS = V4L2_CID_BASE + 0 V4L2_CID_CONTRAST = V4L2_CID_BASE + 1 V4L2_CID_SATURATION = V4L2_CID_BASE + 2 V4L2_CID_HUE = V4L2_CID_BASE + 3 V4L2_CID_AUDIO_VOLUME = V4L2_CID_BASE + 5 V4L2_CID_AUDIO_BALANCE = V4L2_CID_BASE + 6 V4L2_CID_AUDIO_BASS = V4L2_CID_BASE + 7 V4L2_CID_AUDIO_TREBLE = V4L2_CID_BASE + 8 V4L2_CID_AUDIO_MUTE = V4L2_CID_BASE + 9 V4L2_CID_AUDIO_LOUDNESS = V4L2_CID_BASE + 10 V4L2_CID_BLACK_LEVEL = V4L2_CID_BASE + 11 # Deprecated V4L2_CID_AUTO_WHITE_BALANCE = V4L2_CID_BASE + 12 V4L2_CID_DO_WHITE_BALANCE = V4L2_CID_BASE + 13 V4L2_CID_RED_BALANCE = V4L2_CID_BASE + 14 V4L2_CID_BLUE_BALANCE = V4L2_CID_BASE + 15 V4L2_CID_GAMMA = V4L2_CID_BASE + 16 V4L2_CID_WHITENESS = V4L2_CID_GAMMA # Deprecated V4L2_CID_EXPOSURE = V4L2_CID_BASE + 17 V4L2_CID_AUTOGAIN = V4L2_CID_BASE + 18 V4L2_CID_GAIN = V4L2_CID_BASE + 19 V4L2_CID_HFLIP = V4L2_CID_BASE + 20 V4L2_CID_VFLIP = V4L2_CID_BASE + 21 # Deprecated; use V4L2_CID_PAN_RESET and V4L2_CID_TILT_RESET V4L2_CID_HCENTER = V4L2_CID_BASE + 22 V4L2_CID_VCENTER = V4L2_CID_BASE + 23 V4L2_CID_POWER_LINE_FREQUENCY = V4L2_CID_BASE + 24 v4l2_power_line_frequency = enum ( V4L2_CID_POWER_LINE_FREQUENCY_DISABLED, V4L2_CID_POWER_LINE_FREQUENCY_50HZ, V4L2_CID_POWER_LINE_FREQUENCY_60HZ, ) = range(3) V4L2_CID_HUE_AUTO = V4L2_CID_BASE + 25 V4L2_CID_WHITE_BALANCE_TEMPERATURE = V4L2_CID_BASE + 26 V4L2_CID_SHARPNESS = V4L2_CID_BASE + 27 V4L2_CID_BACKLIGHT_COMPENSATION = V4L2_CID_BASE + 28 V4L2_CID_CHROMA_AGC = V4L2_CID_BASE + 29 V4L2_CID_COLOR_KILLER = V4L2_CID_BASE + 30 V4L2_CID_COLORFX = V4L2_CID_BASE + 31 v4l2_colorfx = enum ( V4L2_COLORFX_NONE, V4L2_COLORFX_BW, V4L2_COLORFX_SEPIA, ) = range(3) V4L2_CID_AUTOBRIGHTNESS = V4L2_CID_BASE + 32 V4L2_CID_BAND_STOP_FILTER = V4L2_CID_BASE + 33 V4L2_CID_ROTATE = V4L2_CID_BASE + 34 V4L2_CID_BG_COLOR = V4L2_CID_BASE + 35 V4L2_CID_LASTP1 = V4L2_CID_BASE + 36 V4L2_CID_MPEG_BASE = V4L2_CTRL_CLASS_MPEG | 0x900 V4L2_CID_MPEG_CLASS = V4L2_CTRL_CLASS_MPEG | 1 # MPEG streams V4L2_CID_MPEG_STREAM_TYPE = V4L2_CID_MPEG_BASE + 0 v4l2_mpeg_stream_type = enum ( V4L2_MPEG_STREAM_TYPE_MPEG2_PS, V4L2_MPEG_STREAM_TYPE_MPEG2_TS, V4L2_MPEG_STREAM_TYPE_MPEG1_SS, V4L2_MPEG_STREAM_TYPE_MPEG2_DVD, V4L2_MPEG_STREAM_TYPE_MPEG1_VCD, V4L2_MPEG_STREAM_TYPE_MPEG2_SVCD, ) = range(6) V4L2_CID_MPEG_STREAM_PID_PMT = V4L2_CID_MPEG_BASE + 1 V4L2_CID_MPEG_STREAM_PID_AUDIO = V4L2_CID_MPEG_BASE + 2 V4L2_CID_MPEG_STREAM_PID_VIDEO = V4L2_CID_MPEG_BASE + 3 V4L2_CID_MPEG_STREAM_PID_PCR = V4L2_CID_MPEG_BASE + 4 V4L2_CID_MPEG_STREAM_PES_ID_AUDIO = V4L2_CID_MPEG_BASE + 5 V4L2_CID_MPEG_STREAM_PES_ID_VIDEO = V4L2_CID_MPEG_BASE + 6 V4L2_CID_MPEG_STREAM_VBI_FMT = V4L2_CID_MPEG_BASE + 7 v4l2_mpeg_stream_vbi_fmt = enum ( V4L2_MPEG_STREAM_VBI_FMT_NONE, V4L2_MPEG_STREAM_VBI_FMT_IVTV, ) = range(2) V4L2_CID_MPEG_AUDIO_SAMPLING_FREQ = V4L2_CID_MPEG_BASE + 100 v4l2_mpeg_audio_sampling_freq = enum ( V4L2_MPEG_AUDIO_SAMPLING_FREQ_44100, V4L2_MPEG_AUDIO_SAMPLING_FREQ_48000, V4L2_MPEG_AUDIO_SAMPLING_FREQ_32000, ) = range(3) V4L2_CID_MPEG_AUDIO_ENCODING = V4L2_CID_MPEG_BASE + 101 v4l2_mpeg_audio_encoding = enum ( V4L2_MPEG_AUDIO_ENCODING_LAYER_1, V4L2_MPEG_AUDIO_ENCODING_LAYER_2, V4L2_MPEG_AUDIO_ENCODING_LAYER_3, V4L2_MPEG_AUDIO_ENCODING_AAC, V4L2_MPEG_AUDIO_ENCODING_AC3, ) = range(5) V4L2_CID_MPEG_AUDIO_L1_BITRATE = V4L2_CID_MPEG_BASE + 102 v4l2_mpeg_audio_l1_bitrate = enum ( V4L2_MPEG_AUDIO_L1_BITRATE_32K, V4L2_MPEG_AUDIO_L1_BITRATE_64K, V4L2_MPEG_AUDIO_L1_BITRATE_96K, V4L2_MPEG_AUDIO_L1_BITRATE_128K, V4L2_MPEG_AUDIO_L1_BITRATE_160K, V4L2_MPEG_AUDIO_L1_BITRATE_192K, V4L2_MPEG_AUDIO_L1_BITRATE_224K, V4L2_MPEG_AUDIO_L1_BITRATE_256K, V4L2_MPEG_AUDIO_L1_BITRATE_288K, V4L2_MPEG_AUDIO_L1_BITRATE_320K, V4L2_MPEG_AUDIO_L1_BITRATE_352K, V4L2_MPEG_AUDIO_L1_BITRATE_384K, V4L2_MPEG_AUDIO_L1_BITRATE_416K, V4L2_MPEG_AUDIO_L1_BITRATE_448K, ) = range(14) V4L2_CID_MPEG_AUDIO_L2_BITRATE = V4L2_CID_MPEG_BASE + 103 v4l2_mpeg_audio_l2_bitrate = enum ( V4L2_MPEG_AUDIO_L2_BITRATE_32K, V4L2_MPEG_AUDIO_L2_BITRATE_48K, V4L2_MPEG_AUDIO_L2_BITRATE_56K, V4L2_MPEG_AUDIO_L2_BITRATE_64K, V4L2_MPEG_AUDIO_L2_BITRATE_80K, V4L2_MPEG_AUDIO_L2_BITRATE_96K, V4L2_MPEG_AUDIO_L2_BITRATE_112K, V4L2_MPEG_AUDIO_L2_BITRATE_128K, V4L2_MPEG_AUDIO_L2_BITRATE_160K, V4L2_MPEG_AUDIO_L2_BITRATE_192K, V4L2_MPEG_AUDIO_L2_BITRATE_224K, V4L2_MPEG_AUDIO_L2_BITRATE_256K, V4L2_MPEG_AUDIO_L2_BITRATE_320K, V4L2_MPEG_AUDIO_L2_BITRATE_384K, ) = range(14) V4L2_CID_MPEG_AUDIO_L3_BITRATE = V4L2_CID_MPEG_BASE + 104 v4l2_mpeg_audio_l3_bitrate = enum ( V4L2_MPEG_AUDIO_L3_BITRATE_32K, V4L2_MPEG_AUDIO_L3_BITRATE_40K, V4L2_MPEG_AUDIO_L3_BITRATE_48K, V4L2_MPEG_AUDIO_L3_BITRATE_56K, V4L2_MPEG_AUDIO_L3_BITRATE_64K, V4L2_MPEG_AUDIO_L3_BITRATE_80K, V4L2_MPEG_AUDIO_L3_BITRATE_96K, V4L2_MPEG_AUDIO_L3_BITRATE_112K, V4L2_MPEG_AUDIO_L3_BITRATE_128K, V4L2_MPEG_AUDIO_L3_BITRATE_160K, V4L2_MPEG_AUDIO_L3_BITRATE_192K, V4L2_MPEG_AUDIO_L3_BITRATE_224K, V4L2_MPEG_AUDIO_L3_BITRATE_256K, V4L2_MPEG_AUDIO_L3_BITRATE_320K, ) = range(14) V4L2_CID_MPEG_AUDIO_MODE = V4L2_CID_MPEG_BASE + 105 v4l2_mpeg_audio_mode = enum ( V4L2_MPEG_AUDIO_MODE_STEREO, V4L2_MPEG_AUDIO_MODE_JOINT_STEREO, V4L2_MPEG_AUDIO_MODE_DUAL, V4L2_MPEG_AUDIO_MODE_MONO, ) = range(4) V4L2_CID_MPEG_AUDIO_MODE_EXTENSION = V4L2_CID_MPEG_BASE + 106 v4l2_mpeg_audio_mode_extension = enum ( V4L2_MPEG_AUDIO_MODE_EXTENSION_BOUND_4, V4L2_MPEG_AUDIO_MODE_EXTENSION_BOUND_8, V4L2_MPEG_AUDIO_MODE_EXTENSION_BOUND_12, V4L2_MPEG_AUDIO_MODE_EXTENSION_BOUND_16, ) = range(4) V4L2_CID_MPEG_AUDIO_EMPHASIS = V4L2_CID_MPEG_BASE + 107 v4l2_mpeg_audio_emphasis = enum ( V4L2_MPEG_AUDIO_EMPHASIS_NONE, V4L2_MPEG_AUDIO_EMPHASIS_50_DIV_15_uS, V4L2_MPEG_AUDIO_EMPHASIS_CCITT_J17, ) = range(3) V4L2_CID_MPEG_AUDIO_CRC = V4L2_CID_MPEG_BASE + 108 v4l2_mpeg_audio_crc = enum ( V4L2_MPEG_AUDIO_CRC_NONE, V4L2_MPEG_AUDIO_CRC_CRC16, ) = range(2) V4L2_CID_MPEG_AUDIO_MUTE = V4L2_CID_MPEG_BASE + 109 V4L2_CID_MPEG_AUDIO_AAC_BITRATE = V4L2_CID_MPEG_BASE + 110 V4L2_CID_MPEG_AUDIO_AC3_BITRATE = V4L2_CID_MPEG_BASE + 111 v4l2_mpeg_audio_ac3_bitrate = enum ( V4L2_MPEG_AUDIO_AC3_BITRATE_32K, V4L2_MPEG_AUDIO_AC3_BITRATE_40K, V4L2_MPEG_AUDIO_AC3_BITRATE_48K, V4L2_MPEG_AUDIO_AC3_BITRATE_56K, V4L2_MPEG_AUDIO_AC3_BITRATE_64K, V4L2_MPEG_AUDIO_AC3_BITRATE_80K, V4L2_MPEG_AUDIO_AC3_BITRATE_96K, V4L2_MPEG_AUDIO_AC3_BITRATE_112K, V4L2_MPEG_AUDIO_AC3_BITRATE_128K, V4L2_MPEG_AUDIO_AC3_BITRATE_160K, V4L2_MPEG_AUDIO_AC3_BITRATE_192K, V4L2_MPEG_AUDIO_AC3_BITRATE_224K, V4L2_MPEG_AUDIO_AC3_BITRATE_256K, V4L2_MPEG_AUDIO_AC3_BITRATE_320K, V4L2_MPEG_AUDIO_AC3_BITRATE_384K, V4L2_MPEG_AUDIO_AC3_BITRATE_448K, V4L2_MPEG_AUDIO_AC3_BITRATE_512K, V4L2_MPEG_AUDIO_AC3_BITRATE_576K, V4L2_MPEG_AUDIO_AC3_BITRATE_640K, ) = range(19) V4L2_CID_MPEG_VIDEO_ENCODING = V4L2_CID_MPEG_BASE + 200 v4l2_mpeg_video_encoding = enum ( V4L2_MPEG_VIDEO_ENCODING_MPEG_1, V4L2_MPEG_VIDEO_ENCODING_MPEG_2, V4L2_MPEG_VIDEO_ENCODING_MPEG_4_AVC, ) = range(3) V4L2_CID_MPEG_VIDEO_ASPECT = V4L2_CID_MPEG_BASE + 201 v4l2_mpeg_video_aspect = enum ( V4L2_MPEG_VIDEO_ASPECT_1x1, V4L2_MPEG_VIDEO_ASPECT_4x3, V4L2_MPEG_VIDEO_ASPECT_16x9, V4L2_MPEG_VIDEO_ASPECT_221x100, ) = range(4) V4L2_CID_MPEG_VIDEO_B_FRAMES = V4L2_CID_MPEG_BASE + 202 V4L2_CID_MPEG_VIDEO_GOP_SIZE = V4L2_CID_MPEG_BASE + 203 V4L2_CID_MPEG_VIDEO_GOP_CLOSURE = V4L2_CID_MPEG_BASE + 204 V4L2_CID_MPEG_VIDEO_PULLDOWN = V4L2_CID_MPEG_BASE + 205 V4L2_CID_MPEG_VIDEO_BITRATE_MODE = V4L2_CID_MPEG_BASE + 206 v4l2_mpeg_video_bitrate_mode = enum ( V4L2_MPEG_VIDEO_BITRATE_MODE_VBR, V4L2_MPEG_VIDEO_BITRATE_MODE_CBR, ) = range(2) V4L2_CID_MPEG_VIDEO_BITRATE = V4L2_CID_MPEG_BASE + 207 V4L2_CID_MPEG_VIDEO_BITRATE_PEAK = V4L2_CID_MPEG_BASE + 208 V4L2_CID_MPEG_VIDEO_TEMPORAL_DECIMATION = V4L2_CID_MPEG_BASE + 209 V4L2_CID_MPEG_VIDEO_MUTE = V4L2_CID_MPEG_BASE + 210 V4L2_CID_MPEG_VIDEO_MUTE_YUV = V4L2_CID_MPEG_BASE + 211 V4L2_CID_MPEG_VIDEO_VBV_SIZE = V4L2_CID_MPEG_BASE + 222 V4L2_CID_MPEG_VIDEO_DEC_PTS = V4L2_CID_MPEG_BASE + 223 V4L2_CID_MPEG_VIDEO_DEC_FRAME = V4L2_CID_MPEG_BASE + 224 V4L2_CID_MPEG_VIDEO_VBV_DELAY = V4L2_CID_MPEG_BASE + 225 V4L2_CID_MPEG_VIDEO_REPEAT_SEQ_HEADER = V4L2_CID_MPEG_BASE + 226 V4L2_CID_MPEG_VIDEO_MV_H_SEARCH_RANGE = V4L2_CID_MPEG_BASE + 227 V4L2_CID_MPEG_VIDEO_MV_V_SEARCH_RANGE = V4L2_CID_MPEG_BASE + 228 V4L2_CID_MPEG_VIDEO_FORCE_KEY_FRAME = V4L2_CID_MPEG_BASE + 229 V4L2_CID_MPEG_VIDEO_H264_I_PERIOD = V4L2_CID_MPEG_BASE + 358 V4L2_CID_MPEG_VIDEO_H264_LEVEL = V4L2_CID_MPEG_BASE + 359 V4L2_CID_MPEG_CX2341X_BASE = V4L2_CTRL_CLASS_MPEG | 0x1000 V4L2_CID_MPEG_CX2341X_VIDEO_SPATIAL_FILTER_MODE = V4L2_CID_MPEG_CX2341X_BASE + 0 v4l2_mpeg_cx2341x_video_spatial_filter_mode = enum ( V4L2_MPEG_CX2341X_VIDEO_SPATIAL_FILTER_MODE_MANUAL, V4L2_MPEG_CX2341X_VIDEO_SPATIAL_FILTER_MODE_AUTO, ) = range(2) V4L2_CID_MPEG_CX2341X_VIDEO_SPATIAL_FILTER = V4L2_CID_MPEG_CX2341X_BASE + 1 V4L2_CID_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE = V4L2_CID_MPEG_CX2341X_BASE + 2 v4l2_mpeg_cx2341x_video_luma_spatial_filter_type = enum ( V4L2_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE_OFF, V4L2_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE_1D_HOR, V4L2_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE_1D_VERT, V4L2_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE_2D_HV_SEPARABLE, V4L2_MPEG_CX2341X_VIDEO_LUMA_SPATIAL_FILTER_TYPE_2D_SYM_NON_SEPARABLE, ) = range(5) V4L2_CID_MPEG_CX2341X_VIDEO_CHROMA_SPATIAL_FILTER_TYPE = V4L2_CID_MPEG_CX2341X_BASE + 3 v4l2_mpeg_cx2341x_video_chroma_spatial_filter_type = enum ( V4L2_MPEG_CX2341X_VIDEO_CHROMA_SPATIAL_FILTER_TYPE_OFF, V4L2_MPEG_CX2341X_VIDEO_CHROMA_SPATIAL_FILTER_TYPE_1D_HOR, ) = range(2) V4L2_CID_MPEG_CX2341X_VIDEO_TEMPORAL_FILTER_MODE = V4L2_CID_MPEG_CX2341X_BASE + 4 v4l2_mpeg_cx2341x_video_temporal_filter_mode = enum ( V4L2_MPEG_CX2341X_VIDEO_TEMPORAL_FILTER_MODE_MANUAL, V4L2_MPEG_CX2341X_VIDEO_TEMPORAL_FILTER_MODE_AUTO, ) = range(2) V4L2_CID_MPEG_CX2341X_VIDEO_TEMPORAL_FILTER = V4L2_CID_MPEG_CX2341X_BASE + 5 V4L2_CID_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE = V4L2_CID_MPEG_CX2341X_BASE + 6 v4l2_mpeg_cx2341x_video_median_filter_type = enum ( V4L2_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE_OFF, V4L2_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE_HOR, V4L2_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE_VERT, V4L2_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE_HOR_VERT, V4L2_MPEG_CX2341X_VIDEO_MEDIAN_FILTER_TYPE_DIAG, ) = range(5) V4L2_CID_MPEG_CX2341X_VIDEO_LUMA_MEDIAN_FILTER_BOTTOM = V4L2_CID_MPEG_CX2341X_BASE + 7 V4L2_CID_MPEG_CX2341X_VIDEO_LUMA_MEDIAN_FILTER_TOP = V4L2_CID_MPEG_CX2341X_BASE + 8 V4L2_CID_MPEG_CX2341X_VIDEO_CHROMA_MEDIAN_FILTER_BOTTOM = V4L2_CID_MPEG_CX2341X_BASE + 9 V4L2_CID_MPEG_CX2341X_VIDEO_CHROMA_MEDIAN_FILTER_TOP = V4L2_CID_MPEG_CX2341X_BASE + 10 V4L2_CID_MPEG_CX2341X_STREAM_INSERT_NAV_PACKETS = V4L2_CID_MPEG_CX2341X_BASE + 11 V4L2_CID_CAMERA_CLASS_BASE = V4L2_CTRL_CLASS_CAMERA | 0x900 V4L2_CID_CAMERA_CLASS = V4L2_CTRL_CLASS_CAMERA | 1 V4L2_CID_EXPOSURE_AUTO = V4L2_CID_CAMERA_CLASS_BASE + 1 v4l2_exposure_auto_type = enum ( V4L2_EXPOSURE_AUTO, V4L2_EXPOSURE_MANUAL, V4L2_EXPOSURE_SHUTTER_PRIORITY, V4L2_EXPOSURE_APERTURE_PRIORITY, ) = range(4) V4L2_CID_EXPOSURE_ABSOLUTE = V4L2_CID_CAMERA_CLASS_BASE + 2 V4L2_CID_EXPOSURE_AUTO_PRIORITY = V4L2_CID_CAMERA_CLASS_BASE + 3 V4L2_CID_PAN_RELATIVE = V4L2_CID_CAMERA_CLASS_BASE + 4 V4L2_CID_TILT_RELATIVE = V4L2_CID_CAMERA_CLASS_BASE + 5 V4L2_CID_PAN_RESET = V4L2_CID_CAMERA_CLASS_BASE + 6 V4L2_CID_TILT_RESET = V4L2_CID_CAMERA_CLASS_BASE + 7 V4L2_CID_PAN_ABSOLUTE = V4L2_CID_CAMERA_CLASS_BASE + 8 V4L2_CID_TILT_ABSOLUTE = V4L2_CID_CAMERA_CLASS_BASE + 9 V4L2_CID_FOCUS_ABSOLUTE = V4L2_CID_CAMERA_CLASS_BASE + 10 V4L2_CID_FOCUS_RELATIVE = V4L2_CID_CAMERA_CLASS_BASE + 11 V4L2_CID_FOCUS_AUTO = V4L2_CID_CAMERA_CLASS_BASE + 12 V4L2_CID_ZOOM_ABSOLUTE = V4L2_CID_CAMERA_CLASS_BASE + 13 V4L2_CID_ZOOM_RELATIVE = V4L2_CID_CAMERA_CLASS_BASE + 14 V4L2_CID_ZOOM_CONTINUOUS = V4L2_CID_CAMERA_CLASS_BASE + 15 V4L2_CID_PRIVACY = V4L2_CID_CAMERA_CLASS_BASE + 16 V4L2_CID_FM_TX_CLASS_BASE = V4L2_CTRL_CLASS_FM_TX | 0x900 V4L2_CID_FM_TX_CLASS = V4L2_CTRL_CLASS_FM_TX | 1 V4L2_CID_RDS_TX_DEVIATION = V4L2_CID_FM_TX_CLASS_BASE + 1 V4L2_CID_RDS_TX_PI = V4L2_CID_FM_TX_CLASS_BASE + 2 V4L2_CID_RDS_TX_PTY = V4L2_CID_FM_TX_CLASS_BASE + 3 V4L2_CID_RDS_TX_PS_NAME = V4L2_CID_FM_TX_CLASS_BASE + 5 V4L2_CID_RDS_TX_RADIO_TEXT = V4L2_CID_FM_TX_CLASS_BASE + 6 V4L2_CID_AUDIO_LIMITER_ENABLED = V4L2_CID_FM_TX_CLASS_BASE + 64 V4L2_CID_AUDIO_LIMITER_RELEASE_TIME = V4L2_CID_FM_TX_CLASS_BASE + 65 V4L2_CID_AUDIO_LIMITER_DEVIATION = V4L2_CID_FM_TX_CLASS_BASE + 66 V4L2_CID_AUDIO_COMPRESSION_ENABLED = V4L2_CID_FM_TX_CLASS_BASE + 80 V4L2_CID_AUDIO_COMPRESSION_GAIN = V4L2_CID_FM_TX_CLASS_BASE + 81 V4L2_CID_AUDIO_COMPRESSION_THRESHOLD = V4L2_CID_FM_TX_CLASS_BASE + 82 V4L2_CID_AUDIO_COMPRESSION_ATTACK_TIME = V4L2_CID_FM_TX_CLASS_BASE + 83 V4L2_CID_AUDIO_COMPRESSION_RELEASE_TIME = V4L2_CID_FM_TX_CLASS_BASE + 84 V4L2_CID_PILOT_TONE_ENABLED = V4L2_CID_FM_TX_CLASS_BASE + 96 V4L2_CID_PILOT_TONE_DEVIATION = V4L2_CID_FM_TX_CLASS_BASE + 97 V4L2_CID_PILOT_TONE_FREQUENCY = V4L2_CID_FM_TX_CLASS_BASE + 98 V4L2_CID_TUNE_PREEMPHASIS = V4L2_CID_FM_TX_CLASS_BASE + 112 v4l2_preemphasis = enum ( V4L2_PREEMPHASIS_DISABLED, V4L2_PREEMPHASIS_50_uS, V4L2_PREEMPHASIS_75_uS, ) = range(3) V4L2_CID_TUNE_POWER_LEVEL = V4L2_CID_FM_TX_CLASS_BASE + 113 V4L2_CID_TUNE_ANTENNA_CAPACITOR = V4L2_CID_FM_TX_CLASS_BASE + 114 # # Tuning # class v4l2_tuner(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('type', v4l2_tuner_type), ('capability', ctypes.c_uint32), ('rangelow', ctypes.c_uint32), ('rangehigh', ctypes.c_uint32), ('rxsubchans', ctypes.c_uint32), ('audmode', ctypes.c_uint32), ('signal', ctypes.c_int32), ('afc', ctypes.c_int32), ('reserved', ctypes.c_uint32 * 4), ] class v4l2_modulator(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('capability', ctypes.c_uint32), ('rangelow', ctypes.c_uint32), ('rangehigh', ctypes.c_uint32), ('txsubchans', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ] V4L2_TUNER_CAP_LOW = 0x0001 V4L2_TUNER_CAP_NORM = 0x0002 V4L2_TUNER_CAP_STEREO = 0x0010 V4L2_TUNER_CAP_LANG2 = 0x0020 V4L2_TUNER_CAP_SAP = 0x0020 V4L2_TUNER_CAP_LANG1 = 0x0040 V4L2_TUNER_CAP_RDS = 0x0080 V4L2_TUNER_SUB_MONO = 0x0001 V4L2_TUNER_SUB_STEREO = 0x0002 V4L2_TUNER_SUB_LANG2 = 0x0004 V4L2_TUNER_SUB_SAP = 0x0004 V4L2_TUNER_SUB_LANG1 = 0x0008 V4L2_TUNER_SUB_RDS = 0x0010 V4L2_TUNER_MODE_MONO = 0x0000 V4L2_TUNER_MODE_STEREO = 0x0001 V4L2_TUNER_MODE_LANG2 = 0x0002 V4L2_TUNER_MODE_SAP = 0x0002 V4L2_TUNER_MODE_LANG1 = 0x0003 V4L2_TUNER_MODE_LANG1_LANG2 = 0x0004 class v4l2_frequency(ctypes.Structure): _fields_ = [ ('tuner', ctypes.c_uint32), ('type', v4l2_tuner_type), ('frequency', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 8), ] class v4l2_hw_freq_seek(ctypes.Structure): _fields_ = [ ('tuner', ctypes.c_uint32), ('type', v4l2_tuner_type), ('seek_upward', ctypes.c_uint32), ('wrap_around', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 8), ] # # RDS # class v4l2_rds_data(ctypes.Structure): _fields_ = [ ('lsb', ctypes.c_char), ('msb', ctypes.c_char), ('block', ctypes.c_char), ] _pack_ = True V4L2_RDS_BLOCK_MSK = 0x7 V4L2_RDS_BLOCK_A = 0 V4L2_RDS_BLOCK_B = 1 V4L2_RDS_BLOCK_C = 2 V4L2_RDS_BLOCK_D = 3 V4L2_RDS_BLOCK_C_ALT = 4 V4L2_RDS_BLOCK_INVALID = 7 V4L2_RDS_BLOCK_CORRECTED = 0x40 V4L2_RDS_BLOCK_ERROR = 0x80 # # Audio # class v4l2_audio(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('capability', ctypes.c_uint32), ('mode', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] V4L2_AUDCAP_STEREO = 0x00001 V4L2_AUDCAP_AVL = 0x00002 V4L2_AUDMODE_AVL = 0x00001 class v4l2_audioout(ctypes.Structure): _fields_ = [ ('index', ctypes.c_uint32), ('name', ctypes.c_char * 32), ('capability', ctypes.c_uint32), ('mode', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] # # Mpeg services (experimental) # V4L2_ENC_IDX_FRAME_I = 0 V4L2_ENC_IDX_FRAME_P = 1 V4L2_ENC_IDX_FRAME_B = 2 V4L2_ENC_IDX_FRAME_MASK = 0xf class v4l2_enc_idx_entry(ctypes.Structure): _fields_ = [ ('offset', ctypes.c_uint64), ('pts', ctypes.c_uint64), ('length', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] V4L2_ENC_IDX_ENTRIES = 64 class v4l2_enc_idx(ctypes.Structure): _fields_ = [ ('entries', ctypes.c_uint32), ('entries_cap', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 4), ('entry', v4l2_enc_idx_entry * V4L2_ENC_IDX_ENTRIES), ] V4L2_ENC_CMD_START = 0 V4L2_ENC_CMD_STOP = 1 V4L2_ENC_CMD_PAUSE = 2 V4L2_ENC_CMD_RESUME = 3 V4L2_ENC_CMD_STOP_AT_GOP_END = 1 << 0 class v4l2_encoder_cmd(ctypes.Structure): class _u(ctypes.Union): class _s(ctypes.Structure): _fields_ = [ ('data', ctypes.c_uint32 * 8), ] _fields_ = [ ('raw', _s), ] _fields_ = [ ('cmd', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('_u', _u), ] _anonymous_ = ('_u',) # # Data services (VBI) # class v4l2_vbi_format(ctypes.Structure): _fields_ = [ ('sampling_rate', ctypes.c_uint32), ('offset', ctypes.c_uint32), ('samples_per_line', ctypes.c_uint32), ('sample_format', ctypes.c_uint32), ('start', ctypes.c_int32 * 2), ('count', ctypes.c_uint32 * 2), ('flags', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] V4L2_VBI_UNSYNC = 1 << 0 V4L2_VBI_INTERLACED = 1 << 1 class v4l2_sliced_vbi_format(ctypes.Structure): _fields_ = [ ('service_set', ctypes.c_uint16), ('service_lines', ctypes.c_uint16 * 2 * 24), ('io_size', ctypes.c_uint32), ('reserved', ctypes.c_uint32 * 2), ] V4L2_SLICED_TELETEXT_B = 0x0001 V4L2_SLICED_VPS = 0x0400 V4L2_SLICED_CAPTION_525 = 0x1000 V4L2_SLICED_WSS_625 = 0x4000 V4L2_SLICED_VBI_525 = V4L2_SLICED_CAPTION_525 V4L2_SLICED_VBI_625 = ( V4L2_SLICED_TELETEXT_B | V4L2_SLICED_VPS | V4L2_SLICED_WSS_625) class v4l2_sliced_vbi_cap(ctypes.Structure): _fields_ = [ ('service_set', ctypes.c_uint16), ('service_lines', ctypes.c_uint16 * 2 * 24), ('type', v4l2_buf_type), ('reserved', ctypes.c_uint32 * 3), ] class v4l2_sliced_vbi_data(ctypes.Structure): _fields_ = [ ('id', ctypes.c_uint32), ('field', ctypes.c_uint32), ('line', ctypes.c_uint32), ('reserved', ctypes.c_uint32), ('data', ctypes.c_char * 48), ] # # Sliced VBI data inserted into MPEG Streams # V4L2_MPEG_VBI_IVTV_TELETEXT_B = 1 V4L2_MPEG_VBI_IVTV_CAPTION_525 = 4 V4L2_MPEG_VBI_IVTV_WSS_625 = 5 V4L2_MPEG_VBI_IVTV_VPS = 7 class v4l2_mpeg_vbi_itv0_line(ctypes.Structure): _fields_ = [ ('id', ctypes.c_char), ('data', ctypes.c_char * 42), ] _pack_ = True class v4l2_mpeg_vbi_itv0(ctypes.Structure): _fields_ = [ ('linemask', ctypes.c_uint32 * 2), # how to define __le32 in ctypes? ('line', v4l2_mpeg_vbi_itv0_line * 35), ] _pack_ = True class v4l2_mpeg_vbi_ITV0(ctypes.Structure): _fields_ = [ ('line', v4l2_mpeg_vbi_itv0_line * 36), ] _pack_ = True V4L2_MPEG_VBI_IVTV_MAGIC0 = "itv0" V4L2_MPEG_VBI_IVTV_MAGIC1 = "ITV0" class v4l2_mpeg_vbi_fmt_ivtv(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('itv0', v4l2_mpeg_vbi_itv0), ('ITV0', v4l2_mpeg_vbi_ITV0), ] _fields_ = [ ('magic', ctypes.c_char * 4), ('_u', _u) ] _anonymous_ = ('_u',) _pack_ = True # # Aggregate structures # class v4l2_plane_pix_format(ctypes.Structure): _fields_ = [ ('sizeimage', ctypes.c_uint32), ('bytesperline', ctypes.c_uint32), ('reserved', ctypes.c_uint16 * 6) ] class v4l2_sdr_format(ctypes.Structure): _fields_ = [ ('pixelformat', ctypes.c_uint32), ('buffersize', ctypes.c_uint32), ('reserved', ctypes.c_uint8 * 24) ] class v4l2_meta_format(ctypes.Structure): _fields_ = [ ('dataformat', ctypes.c_uint32), ('buffersize', ctypes.c_uint32) ] class v4l2_pix_format_mplane(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('ycbcr_enc', ctypes.c_uint8), ('hsv_enc', ctypes.c_uint8) ] _fields_ = [ ('width', ctypes.c_uint32), ('height', ctypes.c_uint32), ('pixelformat', ctypes.c_uint32), ('field', ctypes.c_uint32), ('colorspace', ctypes.c_uint32), ('plane_fmt', v4l2_plane_pix_format * VIDEO_MAX_PLANES), ('num_planes', ctypes.c_uint8), ('flags', ctypes.c_uint8), ('_u', _u), ('quantization', ctypes.c_uint8), ('xfer_func', ctypes.c_uint8), ('reserved', ctypes.c_uint8 * 7) ] class v4l2_format(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('pix', v4l2_pix_format), ('pix_mp', v4l2_pix_format_mplane), ('win', v4l2_window), ('vbi', v4l2_vbi_format), ('sliced', v4l2_sliced_vbi_format), ('sdr', v4l2_sdr_format), ('meta', v4l2_meta_format), ('raw_data', ctypes.c_char * 200) ] _fields_ = [ ('type', v4l2_buf_type), ('fmt', _u) ] class v4l2_streamparm(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('capture', v4l2_captureparm), ('output', v4l2_outputparm), ('raw_data', ctypes.c_char * 200), ] _fields_ = [ ('type', v4l2_buf_type), ('parm', _u) ] # # Advanced debugging # V4L2_CHIP_MATCH_HOST = 0 V4L2_CHIP_MATCH_I2C_DRIVER = 1 V4L2_CHIP_MATCH_I2C_ADDR = 2 V4L2_CHIP_MATCH_AC97 = 3 class v4l2_dbg_match(ctypes.Structure): class _u(ctypes.Union): _fields_ = [ ('addr', ctypes.c_uint32), ('name', ctypes.c_char * 32), ] _fields_ = [ ('type', ctypes.c_uint32), ('_u', _u), ] _anonymous_ = ('_u',) _pack_ = True class v4l2_dbg_register(ctypes.Structure): _fields_ = [ ('match', v4l2_dbg_match), ('size', ctypes.c_uint32), ('reg', ctypes.c_uint64), ('val', ctypes.c_uint64), ] _pack_ = True class v4l2_dbg_chip_ident(ctypes.Structure): _fields_ = [ ('match', v4l2_dbg_match), ('ident', ctypes.c_uint32), ('revision', ctypes.c_uint32), ] _pack_ = True # # ioctl codes for video devices # VIDIOC_QUERYCAP = _IOR('V', 0, v4l2_capability) VIDIOC_RESERVED = _IO('V', 1) VIDIOC_ENUM_FMT = _IOWR('V', 2, v4l2_fmtdesc) VIDIOC_G_FMT = _IOWR('V', 4, v4l2_format) VIDIOC_S_FMT = _IOWR('V', 5, v4l2_format) VIDIOC_REQBUFS = _IOWR('V', 8, v4l2_requestbuffers) VIDIOC_QUERYBUF = _IOWR('V', 9, v4l2_buffer) VIDIOC_G_FBUF = _IOR('V', 10, v4l2_framebuffer) VIDIOC_S_FBUF = _IOW('V', 11, v4l2_framebuffer) VIDIOC_OVERLAY = _IOW('V', 14, ctypes.c_int) VIDIOC_QBUF = _IOWR('V', 15, v4l2_buffer) VIDIOC_DQBUF = _IOWR('V', 17, v4l2_buffer) VIDIOC_STREAMON = _IOW('V', 18, ctypes.c_int) VIDIOC_STREAMOFF = _IOW('V', 19, ctypes.c_int) VIDIOC_G_PARM = _IOWR('V', 21, v4l2_streamparm) VIDIOC_S_PARM = _IOWR('V', 22, v4l2_streamparm) VIDIOC_G_STD = _IOR('V', 23, v4l2_std_id) VIDIOC_S_STD = _IOW('V', 24, v4l2_std_id) VIDIOC_ENUMSTD = _IOWR('V', 25, v4l2_standard) VIDIOC_ENUMINPUT = _IOWR('V', 26, v4l2_input) VIDIOC_G_CTRL = _IOWR('V', 27, v4l2_control) VIDIOC_S_CTRL = _IOWR('V', 28, v4l2_control) VIDIOC_G_TUNER = _IOWR('V', 29, v4l2_tuner) VIDIOC_S_TUNER = _IOW('V', 30, v4l2_tuner) VIDIOC_G_AUDIO = _IOR('V', 33, v4l2_audio) VIDIOC_S_AUDIO = _IOW('V', 34, v4l2_audio) VIDIOC_QUERYCTRL = _IOWR('V', 36, v4l2_queryctrl) VIDIOC_QUERYMENU = _IOWR('V', 37, v4l2_querymenu) VIDIOC_G_INPUT = _IOR('V', 38, ctypes.c_int) VIDIOC_S_INPUT = _IOWR('V', 39, ctypes.c_int) VIDIOC_G_OUTPUT = _IOR('V', 46, ctypes.c_int) VIDIOC_S_OUTPUT = _IOWR('V', 47, ctypes.c_int) VIDIOC_ENUMOUTPUT = _IOWR('V', 48, v4l2_output) VIDIOC_G_AUDOUT = _IOR('V', 49, v4l2_audioout) VIDIOC_S_AUDOUT = _IOW('V', 50, v4l2_audioout) VIDIOC_G_MODULATOR = _IOWR('V', 54, v4l2_modulator) VIDIOC_S_MODULATOR = _IOW('V', 55, v4l2_modulator) VIDIOC_G_FREQUENCY = _IOWR('V', 56, v4l2_frequency) VIDIOC_S_FREQUENCY = _IOW('V', 57, v4l2_frequency) VIDIOC_CROPCAP = _IOWR('V', 58, v4l2_cropcap) VIDIOC_G_CROP = _IOWR('V', 59, v4l2_crop) VIDIOC_S_CROP = _IOW('V', 60, v4l2_crop) VIDIOC_G_JPEGCOMP = _IOR('V', 61, v4l2_jpegcompression) VIDIOC_S_JPEGCOMP = _IOW('V', 62, v4l2_jpegcompression) VIDIOC_QUERYSTD = _IOR('V', 63, v4l2_std_id) VIDIOC_TRY_FMT = _IOWR('V', 64, v4l2_format) VIDIOC_ENUMAUDIO = _IOWR('V', 65, v4l2_audio) VIDIOC_ENUMAUDOUT = _IOWR('V', 66, v4l2_audioout) VIDIOC_G_PRIORITY = _IOR('V', 67, v4l2_priority) VIDIOC_S_PRIORITY = _IOW('V', 68, v4l2_priority) VIDIOC_G_SLICED_VBI_CAP = _IOWR('V', 69, v4l2_sliced_vbi_cap) VIDIOC_LOG_STATUS = _IO('V', 70) VIDIOC_G_EXT_CTRLS = _IOWR('V', 71, v4l2_ext_controls) VIDIOC_S_EXT_CTRLS = _IOWR('V', 72, v4l2_ext_controls) VIDIOC_TRY_EXT_CTRLS = _IOWR('V', 73, v4l2_ext_controls) VIDIOC_ENUM_FRAMESIZES = _IOWR('V', 74, v4l2_frmsizeenum) VIDIOC_ENUM_FRAMEINTERVALS = _IOWR('V', 75, v4l2_frmivalenum) VIDIOC_G_ENC_INDEX = _IOR('V', 76, v4l2_enc_idx) VIDIOC_ENCODER_CMD = _IOWR('V', 77, v4l2_encoder_cmd) VIDIOC_TRY_ENCODER_CMD = _IOWR('V', 78, v4l2_encoder_cmd) VIDIOC_DBG_S_REGISTER = _IOW('V', 79, v4l2_dbg_register) VIDIOC_DBG_G_REGISTER = _IOWR('V', 80, v4l2_dbg_register) VIDIOC_DBG_G_CHIP_IDENT = _IOWR('V', 81, v4l2_dbg_chip_ident) VIDIOC_S_HW_FREQ_SEEK = _IOW('V', 82, v4l2_hw_freq_seek) VIDIOC_ENUM_DV_PRESETS = _IOWR('V', 83, v4l2_dv_enum_preset) VIDIOC_S_DV_PRESET = _IOWR('V', 84, v4l2_dv_preset) VIDIOC_G_DV_PRESET = _IOWR('V', 85, v4l2_dv_preset) VIDIOC_QUERY_DV_PRESET = _IOR('V', 86, v4l2_dv_preset) VIDIOC_S_DV_TIMINGS = _IOWR('V', 87, v4l2_dv_timings) VIDIOC_G_DV_TIMINGS = _IOWR('V', 88, v4l2_dv_timings) VIDIOC_OVERLAY_OLD = _IOWR('V', 14, ctypes.c_int) VIDIOC_S_PARM_OLD = _IOW('V', 22, v4l2_streamparm) VIDIOC_S_CTRL_OLD = _IOW('V', 28, v4l2_control) VIDIOC_G_AUDIO_OLD = _IOWR('V', 33, v4l2_audio) VIDIOC_G_AUDOUT_OLD = _IOWR('V', 49, v4l2_audioout) VIDIOC_CROPCAP_OLD = _IOR('V', 58, v4l2_cropcap) BASE_VIDIOC_PRIVATE = 192 v4l2_colorspace_dict = {0:'DEFAULT', 1:'SMPTE170M', 2:'SMPTE240M', 3:'REC709', 4:'BT878', 5:'470_SYSTEM_M', 6:'470_SYSTEM_BG', 7:'JPEG', 8:'SRGB', 9:'ADOBERGB', 10:'BT2020', 11:'RAW', 12:'DCI_P3'} v4l2_field_dict = {0:'ANY', 1:'NONE', 2:'TOP', 3:'BOTTOM', 4:'INTERLACED', 5:'SEQ_TB', 6:'SEQ_BT', 7:'ALTERNATE', 8:'INTERLACED_TB', 9:'INTERLACED_BT'} v4l2_CID_dict = {V4L2_CID_BRIGHTNESS:'V4L2_CID_BRIGHTNESS', V4L2_CID_CONTRAST:'V4L2_CID_CONTRAST', V4L2_CID_SATURATION:'V4L2_CID_SATURATION', V4L2_CID_HUE:'V4L2_CID_HUE', V4L2_CID_AUTO_WHITE_BALANCE:'V4L2_CID_AUTO_WHITE_BALANCE', V4L2_CID_GAMMA:'V4L2_CID_GAMMA', V4L2_CID_GAIN:'V4L2_CID_GAIN', V4L2_CID_POWER_LINE_FREQUENCY:'V4L2_CID_POWER_LINE_FREQUENCY', V4L2_CID_WHITE_BALANCE_TEMPERATURE:'V4L2_CID_WHITE_BALANCE_TEMPERATURE', V4L2_CID_SHARPNESS:'V4L2_CID_SHARPNESS', V4L2_CID_BACKLIGHT_COMPENSATION:'V4L2_CID_BACKLIGHT_COMPENSATION', V4L2_CID_EXPOSURE_AUTO:'V4L2_CID_EXPOSURE_AUTO', V4L2_CID_EXPOSURE_ABSOLUTE:'V4L2_CID_EXPOSURE_ABSOLUTE', V4L2_CID_EXPOSURE_AUTO_PRIORITY:'V4L2_CID_EXPOSURE_AUTO_PRIORITY'} v4l2_CTRL_FLAG_dict = {NONE:'NONE', V4L2_CTRL_FLAG_DISABLED:'V4L2_CTRL_FLAG_DISABLED', V4L2_CTRL_FLAG_GRABBED:'V4L2_CTRL_FLAG_GRABBED', V4L2_CTRL_FLAG_READ_ONLY:'V4L2_CTRL_FLAG_READ_ONLY', V4L2_CTRL_FLAG_UPDATE:'V4L2_CTRL_FLAG_UPDATE', V4L2_CTRL_FLAG_INACTIVE:'V4L2_CTRL_FLAG_INACTIVE', V4L2_CTRL_FLAG_SLIDER:'V4L2_CTRL_FLAG_SLIDER', V4L2_CTRL_FLAG_WRITE_ONLY:'V4L2_CTRL_FLAG_WRITE_ONLY', V4L2_CTRL_FLAG_VOLATILE:'V4L2_CTRL_FLAG_VOLATILE', V4L2_CTRL_FLAG_HAS_PAYLOAD:'V4L2_CTRL_FLAG_HAS_PAYLOAD', V4L2_CTRL_FLAG_EXECUTE_ON_WRITE:'V4L2_CTRL_FLAG_EXECUTE_ON_WRITE', V4L2_CTRL_FLAG_MODIFY_LAYOUT:'V4L2_CTRL_FLAG_MODIFY_LAYOUT', V4L2_CTRL_FLAG_NEXT_CTRL:'V4L2_CTRL_FLAG_NEXT_CTRL', V4L2_CTRL_FLAG_NEXT_COMPOUND:'V4L2_CTRL_FLAG_NEXT_COMPOUND'} v4l2_ctrl_type_dict = {V4L2_CTRL_TYPE_INTEGER:'V4L2_CTRL_TYPE_INTEGER', V4L2_CTRL_TYPE_BOOLEAN:'V4L2_CTRL_TYPE_BOOLEAN', V4L2_CTRL_TYPE_MENU:'V4L2_CTRL_TYPE_MENU', V4L2_CTRL_TYPE_BUTTON:'V4L2_CTRL_TYPE_BUTTON', V4L2_CTRL_TYPE_INTEGER64:'V4L2_CTRL_TYPE_INTEGER64', V4L2_CTRL_TYPE_CTRL_CLASS:'V4L2_CTRL_TYPE_CTRL_CLASS', V4L2_CTRL_TYPE_STRING:'V4L2_CTRL_TYPE_STRING', V4L2_CTRL_TYPE_BITMASK:'V4L2_CTRL_TYPE_BITMASK', V4L2_CTRL_TYPE_INTEGER_MENU:'V4L2_CTRL_TYPE_INTEGER_MENU', V4L2_CTRL_COMPOUND_TYPES:'V4L2_CTRL_COMPOUND_TYPES', V4L2_CTRL_TYPE_U8:'V4L2_CTRL_TYPE_U8', V4L2_CTRL_TYPE_U16:'V4L2_CTRL_TYPE_U16', V4L2_CTRL_TYPE_U32:'V4L2_CTRL_TYPE_U32'} v4l2_capabilities_dict = {V4L2_CAP_VIDEO_CAPTURE:'V4L2_CAP_VIDEO_CAPTURE', V4L2_CAP_VIDEO_OUTPUT:'V4L2_CAP_VIDEO_OUTPUT', V4L2_CAP_VIDEO_OVERLAY:'V4L2_CAP_VIDEO_OVERLAY', V4L2_CAP_VBI_CAPTURE:'V4L2_CAP_VBI_CAPTURE', V4L2_CAP_VBI_OUTPUT:'V4L2_CAP_VBI_OUTPUT', V4L2_CAP_SLICED_VBI_CAPTURE:'V4L2_CAP_SLICED_VBI_CAPTURE', V4L2_CAP_SLICED_VBI_OUTPUT:'V4L2_CAP_SLICED_VBI_OUTPUT', V4L2_CAP_RDS_CAPTURE:'V4L2_CAP_RDS_CAPTURE', V4L2_CAP_VIDEO_OUTPUT_OVERLAY:'V4L2_CAP_VIDEO_OUTPUT_OVERLAY', V4L2_CAP_HW_FREQ_SEEK:'V4L2_CAP_HW_FREQ_SEEK', V4L2_CAP_RDS_OUTPUT:'V4L2_CAP_RDS_OUTPUT', V4L2_CAP_VIDEO_CAPTURE_MPLANE:'V4L2_CAP_VIDEO_CAPTURE_MPLANE', V4L2_CAP_VIDEO_OUTPUT_MPLANE:'V4L2_CAP_VIDEO_OUTPUT_MPLANE', V4L2_CAP_VIDEO_M2M_MPLANE:'V4L2_CAP_VIDEO_M2M_MPLANE', V4L2_CAP_VIDEO_M2M:'V4L2_CAP_VIDEO_M2M', V4L2_CAP_TUNER:'V4L2_CAP_TUNER', V4L2_CAP_AUDIO:'V4L2_CAP_AUDIO', V4L2_CAP_RADIO:'V4L2_CAP_RADIO', V4L2_CAP_MODULATOR:'V4L2_CAP_MODULATOR', V4L2_CAP_SDR_CAPTURE:'V4L2_CAP_SDR_CAPTURE', V4L2_CAP_EXT_PIX_FORMAT:'V4L2_CAP_EXT_PIX_FORMAT', V4L2_CAP_SDR_OUTPUT:'V4L2_CAP_SDR_OUTPUT', V4L2_CAP_META_CAPTURE:'V4L2_CAP_META_CAPTURE', V4L2_CAP_READWRITE:'V4L2_CAP_READWRITE', V4L2_CAP_ASYNCIO:'V4L2_CAP_ASYNCIO', V4L2_CAP_STREAMING:'V4L2_CAP_STREAMING', V4L2_CAP_TOUCH:'V4L2_CAP_TOUCH', V4L2_CAP_DEVICE_CAPS:'V4L2_CAP_DEVICE_CAPS'} v4l2_BUF_TYPE_dict = {V4L2_BUF_TYPE_VIDEO_CAPTURE:'V4L2_BUF_TYPE_VIDEO_CAPTURE', V4L2_BUF_TYPE_VIDEO_OUTPUT:'V4L2_BUF_TYPE_VIDEO_OUTPUT', V4L2_BUF_TYPE_VIDEO_OVERLAY:'V4L2_BUF_TYPE_VIDEO_OVERLAY', V4L2_BUF_TYPE_VBI_CAPTURE:'V4L2_BUF_TYPE_VBI_CAPTURE', V4L2_BUF_TYPE_VBI_OUTPUT:'V4L2_BUF_TYPE_VBI_OUTPUT', V4L2_BUF_TYPE_SLICED_VBI_CAPTURE:'V4L2_BUF_TYPE_SLICED_VBI_CAPTURE', V4L2_BUF_TYPE_SLICED_VBI_OUTPUT:'V4L2_BUF_TYPE_SLICED_VBI_OUTPUT', V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY:'V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY', V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE:'V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE', V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE:'V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE', V4L2_BUF_TYPE_SDR_CAPTURE:'V4L2_BUF_TYPE_SDR_CAPTURE', V4L2_BUF_TYPE_SDR_OUTPUT:'V4L2_BUF_TYPE_SDR_OUTPUT', V4L2_BUF_TYPE_META_CAPTURE:'V4L2_BUF_TYPE_META_CAPTURE', V4L2_BUF_TYPE_PRIVATE:'V4L2_BUF_TYPE_PRIVATE'} v4l2_MEMORY_dict = {V4L2_MEMORY_MMAP:'V4L2_MEMORY_MMAP', V4L2_MEMORY_USERPTR:'V4L2_MEMORY_USERPTR', V4L2_MEMORY_OVERLAY:'V4L2_MEMORY_OVERLAY', V4L2_MEMORY_DMABUF:'V4L2_MEMORY_DMABUF'} ================================================ FILE: prusa/link/cameras/v4l2_driver.py ================================================ """Contains implementation of a camera driver utilizing the V4L2 API""" import ctypes import errno import fcntl import fractions import logging import os import pathlib import re import select from glob import glob from types import MappingProxyType from typing import Any, NamedTuple from prusa.connect.printer.camera import Resolution from prusa.connect.printer.camera_driver import CameraDriver from prusa.connect.printer.const import ( CAMERA_WAIT_TIMEOUT, CapabilityType, NotSupported, ) from ..util import is_potato_cpu, prctl_name from . import v4l2 from .encoders import BufferDetails, MJPEGEncoder, get_appropriate_encoder from .v4l2 import ( V4L2_CID_FOCUS_ABSOLUTE, V4L2_CID_FOCUS_AUTO, VIDIOC_QUERYCTRL, VIDIOC_S_CTRL, v4l2_control, v4l2_queryctrl, ) log = logging.getLogger(__name__) # --- code taken from v4l2py, unused features cut class Info(NamedTuple): """Contains information about the device""" driver: Any card: Any bus_info: Any version: Any physical_capabilities: Any capabilities: Any formats: Any frame_sizes: Any focus_info: Any class ImageFormat(NamedTuple): """Contains information about a specific image format""" type: Any description: Any flags: Any pixel_format: Any class FrameType(NamedTuple): """Contains information about a specific frame type""" pixel_format: Any width: Any height: Any class FocusInfo(NamedTuple): """Contains information about the focus capabilities of the device""" available: Any min: Any max: Any step: Any STREAM_TYPE = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE IGNORED_BUS_INFO_REGEX = re.compile( r"(platform:[0-9a-fA-F]+\.csi)|(platform:bcm2835-isp)") def frame_sizes(file_descriptor, pixel_formats): """Gets a list of frame sizes for a specified pixel format""" size = v4l2.v4l2_frmsizeenum() sizes = [] for pixel_format in pixel_formats: size.pixel_format = pixel_format size.index = 0 while True: try: fcntl.ioctl( file_descriptor, v4l2.VIDIOC_ENUM_FRAMESIZES, size) except OSError: break if size.type == v4l2.V4L2_FRMSIZE_TYPE_DISCRETE: sizes.append(FrameType( pixel_format=pixel_format, width=size.discrete.width, height=size.discrete.height, )) size.index += 1 return sizes def read_capabilities(file_descriptor): """Reads device capabilities in the raw flag format""" caps = v4l2.v4l2_capability() fcntl.ioctl(file_descriptor, v4l2.VIDIOC_QUERYCAP, caps) return caps def read_info(filename): """Reads device specific info needed for device initialization""" with fopen(filename) as file_descriptor: caps = read_capabilities(file_descriptor) version_tuple = ( (caps.version & 0xFF0000) >> 16, (caps.version & 0x00FF00) >> 8, (caps.version & 0x0000FF), ) version_str = ".".join(map(str, version_tuple)) device_capabilities = caps.capabilities formats = [] pixel_formats = set() fmt = v4l2.v4l2_fmtdesc() fmt.type = STREAM_TYPE for index in range(128): fmt.index = index try: fcntl.ioctl(file_descriptor, v4l2.VIDIOC_ENUM_FMT, fmt) except OSError as error: if error.errno == errno.EINVAL: break raise try: pixel_format = fmt.pixelformat except ValueError: continue formats.append( ImageFormat( type=STREAM_TYPE, flags=fmt.flags, description=fmt.description.decode(), pixel_format=pixel_format, ), ) pixel_formats.add(pixel_format) focus_info = None focus_auto = v4l2_queryctrl() focus_auto.id = V4L2_CID_FOCUS_AUTO focus_absolute = v4l2_queryctrl() focus_absolute.id = V4L2_CID_FOCUS_ABSOLUTE try: if fcntl.ioctl(file_descriptor, VIDIOC_QUERYCTRL, focus_auto) != 0: raise RuntimeError("Unable to get focus auto") if fcntl.ioctl( file_descriptor, VIDIOC_QUERYCTRL, focus_absolute) != 0: raise RuntimeError("Unable to get focus absolute") except (OSError, RuntimeError): focus_info = FocusInfo( available=False, min=None, max=None, step=None, ) else: focus_info = FocusInfo( available=True, min=focus_absolute.minimum, max=focus_absolute.maximum, step=focus_absolute.step, ) return Info( driver=caps.driver.decode(), card=caps.card.decode(), bus_info=caps.bus_info.decode(), version=version_str, physical_capabilities=caps.capabilities, capabilities=device_capabilities, formats=formats, frame_sizes=frame_sizes(file_descriptor, pixel_formats), focus_info=focus_info, ) def fopen(path, write=False): """Opens a specified video device file""" return open(path, "rb+" if write else "rb", buffering=0, opener=opener) def opener(path, flags): """Adds flags for the open function""" return os.open(path, flags | os.O_NONBLOCK) def iter_video_files(path="/dev"): """Iterates over the linux detected video files under /dev""" path = pathlib.Path(path) return path.glob("video*") def iter_devices(path="/dev"): """Returns a tuple of all detected video devices as an objects""" return (V4L2Camera(name) for name in iter_video_files(path=path)) def iter_video_capture_devices(path="/dev"): """Returns all video devices that report the ability to capture video""" def filt(filename): with fopen(filename) as fobj: caps = read_capabilities(fobj.fileno()) return v4l2.V4L2_CAP_VIDEO_CAPTURE & caps.capabilities return (V4L2Camera(name) for name in filter(filt, iter_video_files(path))) # --- Video device class MediaDeviceInfo(ctypes.Structure): """A data structure for getting media device info""" _fields_ = ( ("driver", ctypes.c_char * 16), ("model", ctypes.c_char * 32), ("serial", ctypes.c_char * 40), ("bus_info", ctypes.c_char * 32), ("media_version", ctypes.c_uint32), ("hw_revision", ctypes.c_uint32), ("driver_version", ctypes.c_uint32), ("reserved", ctypes.c_uint32 * 31), ) SUPPORTED_PIXEL_FORMATS = {v4l2.V4L2_PIX_FMT_MJPEG, v4l2.V4L2_PIX_FMT_YUYV} BYTES_PER_PIXEL = {v4l2.V4L2_PIX_FMT_YUYV: 2} # pylint: disable=protected-access MEDIA_IOC_DEVICE_INFO = v4l2._IOWR('|', 0x00, MediaDeviceInfo) def read_media_device_info(path): """Given a media device path, reads its associated info :raises PermissionError""" info = MediaDeviceInfo() # pylint: disable=unspecified-encoding with open(path, "r") as file: file_descriptor = file.fileno() if fcntl.ioctl(file_descriptor, MEDIA_IOC_DEVICE_INFO, info): raise RuntimeError("Failed getting media device info " f"for device {path}") return info class V4L2Camera: """An object allowing us to easily control a camera""" buffer_type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE # To support more, more coding is needed buffer_size = 1 def __init__(self, path): self.path = pathlib.Path(path) self.width = None self.height = None self.pixel_format = v4l2.V4L2_PIX_FMT_MJPEG self.fps = None self.info = read_info(self.path) self.buffer_details = None self._file_object = None if not v4l2.V4L2_CAP_VIDEO_CAPTURE & self.info.capabilities: raise RuntimeError("This device cannot capture video") def _ioctl(self, request, arg: Any = 0): """A helper method to call a linux kernel function""" return fcntl.ioctl(self._file_object, request, arg) @property def is_stopped(self): """Is the driver currently operating or not""" return self._file_object is None def _set_format(self): """Uses the V4L2 api to set the stream format""" f = v4l2.v4l2_format() f.type = self.buffer_type if self.width is None or self.height is None: self._ioctl(v4l2.VIDIOC_G_FMT, f) self.width = f.fmt.pix.width self.height = f.fmt.pix.height self.pixel_format = f.fmt.pix.pixelformat else: f.fmt.pix.pixelformat = self.pixel_format f.fmt.pix.field = v4l2.V4L2_FIELD_ANY f.fmt.pix.width = self.width f.fmt.pix.height = self.height f.fmt.pix.bytesperline = 0 return self._ioctl(v4l2.VIDIOC_S_FMT, f) def _set_fps(self): """Uses the V4L2 API to set the fps, leaves the default if None is provided""" stream_params = v4l2.v4l2_streamparm() stream_params.type = self.buffer_type if self.fps is None: self._ioctl(v4l2.VIDIOC_G_PARM, stream_params) self.fps = (stream_params.parm.capture.timeperframe.numerator / stream_params.parm.capture.timeperframe.denominator) else: fps = fractions.Fraction(self.fps) stream_params.parm.capture.timeperframe.numerator = fps.denominator stream_params.parm.capture.timeperframe.denominator = fps.numerator return self._ioctl(v4l2.VIDIOC_S_PARM, stream_params) def _buffer_request(self, count=1): """Requests either zero or one buffer to be prepared the zero is to de-allocate existing ones""" if count > 1: raise RuntimeError("We don't support more buffers") buffer_request = v4l2.v4l2_requestbuffers() buffer_request.count = self.buffer_size # only one is supported buffer_request.type = self.buffer_type buffer_request.memory = v4l2.V4L2_MEMORY_MMAP self._ioctl(v4l2.VIDIOC_REQBUFS, buffer_request) if not buffer_request.count: raise IOError("Not enough buffer memory") def _v4l2_buffer(self): """Pre-fills a new buffer structure with the correct buffer type""" buff = v4l2.v4l2_buffer() buff.index = 0 buff.type = self.buffer_type buff.memory = v4l2.V4L2_MEMORY_MMAP return buff def start(self): """Sets up and starts the V4L2 capture, so we can request frames""" if not self.is_stopped: raise RuntimeError("Already running") self._file_object = fopen(self.path, write=True) # Set up the device parameters self._set_format() self._set_fps() # Ask for one buffer from the device (can't do more) self._buffer_request(count=1) # Query what the buffer looks like and map the memory, so we can look # at its data buffer = self._v4l2_buffer() self._ioctl(v4l2.VIDIOC_QUERYBUF, buffer) self.buffer_details = BufferDetails(self._file_object.fileno(), length=buffer.length, offset=buffer.m.offset) # Turn on the stream btype = v4l2.v4l2_buf_type(self.buffer_type) try: self._ioctl(v4l2.VIDIOC_STREAMON, btype) except OSError as exception: if exception.args[0] == 28: log.error( "You have probably plugged too many cameras into a " "Single-TT USB3 (or higher) or a USB2 (or lower) USB hub. " "This guy explains it quite well https://www.amazon.com/" "review/R12F7RYUKPCQX7/?ie=UTF8 ") raise if self.info.focus_info.available: # Set the focus to absolute self._ioctl(v4l2.VIDIOC_S_CTRL, v4l2.v4l2_control(id=V4L2_CID_FOCUS_AUTO, value=0), ) def stop(self): """Stops all V4L2 capturing activity and frees everything""" if self.is_stopped: raise RuntimeError("Already stopped") btype = v4l2.v4l2_buf_type(self.buffer_type) self._ioctl(v4l2.VIDIOC_STREAMOFF, btype) # Request there be 0 buffers ready - deallocate them self._buffer_request(count=0) if self.buffer_details is not None: self.buffer_details.mmap.close() if self._file_object is not None: self._file_object.close() self._file_object = None def next_frame(self): """Asks for the next frame, leaves the buffer memory accessible from the outside, returns the buffer details""" buffer = self._v4l2_buffer() self._ioctl(v4l2.VIDIOC_QBUF, buffer) # The same piece of code in picamera driver broke, # this one seems to work fine events, *_ = select.select((self._file_object,), (), (), CAMERA_WAIT_TIMEOUT) if not events: raise TimeoutError("Getting the next frame timed out") self._ioctl(v4l2.VIDIOC_DQBUF, buffer) return buffer def set_focus(self, value): """Sets absolute focus - source value from 0 to 1""" value_range = self.info.focus_info.max - self.info.focus_info.min scaled_value = value * value_range value_in_step = (scaled_value - (scaled_value % self.info.focus_info.step)) final_value = int(value_in_step + self.info.focus_info.min) # Create a v4l2_control structure with the control ID and value control = v4l2_control() control.id = V4L2_CID_FOCUS_ABSOLUTE control.value = final_value # Use the ioctl call to set the control value if self._ioctl(VIDIOC_S_CTRL, control) != 0: raise RuntimeError("Unable to set control value") def get_media_device_path(device: V4L2Camera): """Gets the media device path for a video device Pairs /dev/video* to /dev/media*""" bus_info = device.info.bus_info paths = glob("/dev/media*") for path in paths: try: info = read_media_device_info(path) except PermissionError: log.exception("Failed getting a media device for %s. " "This is commonly caused by the linux user " "not being a member of the 'video' group", device.path) else: if bus_info == info.bus_info.decode("UTF-8"): return path return None def param_change(func): """Wraps any settings change with a stop and start of the video stream, so the camera driver does not return it's busy""" def inner(self, new_param): # pylint: disable=protected-access self.device.stop() self.encoder.stop() func(self, new_param) self.device.start() self.encoder.source_details = self.device.buffer_details self.encoder.start() return inner class V4L2Driver(CameraDriver): """Linux V4L2 USB webcam driver""" name = "V4L2" REQUIRES_SETTINGS = MappingProxyType({ "path": "Path to the V4L2 device like '/dev/video1'", }) @staticmethod def _scan(): """Implements the mandated scan method, returns available USB cameras""" available = {} devices = iter_video_capture_devices() for device in devices: # Ignore picameras as they are handled by their own driver if IGNORED_BUS_INFO_REGEX.match(device.info.bus_info) is not None: continue if not device.info.formats: continue media_device_path = get_media_device_path(device) if media_device_path is None: continue path = str(device.path) name = device.info.card try: info = read_media_device_info(media_device_path) serial = info.serial.decode("ascii") except (OSError, PermissionError): log.exception("Getting camera sn failed for camera %s at %s", name, path) continue else: camera_id = " ".join((name, serial)) log.debug("Camera id is %s", camera_id) available[camera_id] = { "path": path, "name": name, } return available def __init__(self, camera_id, config, unavailable_cb): # pylint: disable=duplicate-code super().__init__(camera_id, config, unavailable_cb) self._resolution_to_format = {} self.device = None self.stream = None self.encoder = None def _connect(self): """Connects to the V4L2 camera""" path = self.config["path"] self._capabilities = ({ CapabilityType.TRIGGER_SCHEME, CapabilityType.IMAGING, CapabilityType.RESOLUTION, }) extra_unsupported_formats = set() self.device = V4L2Camera(path) if self.device.info.focus_info.available: self._capabilities.add(CapabilityType.FOCUS) self._config["focus"] = self._config.get("focus", str(0.0)) self._available_resolutions = set() for frame_type in self.device.info.frame_sizes: resolution = Resolution(width=frame_type.width, height=frame_type.height) # Prefer MJPEG to others if resolution in self._resolution_to_format: pixel_format = self._resolution_to_format[resolution] if pixel_format == v4l2.V4L2_PIX_FMT_MJPEG: continue pixel_format = frame_type.pixel_format if pixel_format not in SUPPORTED_PIXEL_FORMATS: if pixel_format not in extra_unsupported_formats: log.debug("Pixel format %s not supported", pixel_format) extra_unsupported_formats.add(pixel_format) continue max_resolution = max(resolution.width, resolution.height) if (pixel_format != v4l2.V4L2_PIX_FMT_MJPEG and is_potato_cpu() and max_resolution > MJPEGEncoder.WIDTH_LIMIT): # The format needs to be encoded, but we cannot encode this # using the HW encoder, and our CPU is not good either continue self._available_resolutions.add(resolution) self._resolution_to_format[resolution] = pixel_format if not self.available_resolutions: raise NotSupported( "Sorry, PrusaLink supports only YUYV 4:2:2 and MJPEG. " f"Camera {self.camera_id} supports only these formats: " f"{extra_unsupported_formats}") initial_resolution = self._get_initial_resolution( self._available_resolutions, self._config) self._set_resolution(initial_resolution) self._config["resolution"] = str(initial_resolution) self.device.start() self.encoder.start() if CapabilityType.FOCUS in self.capabilities: self.set_focus(float(self._config["focus"])) @param_change def set_resolution(self, resolution): """Sets the camera resolution""" self._set_resolution(resolution) def _set_resolution(self, resolution): """Sets the camera resolution""" pixel_format = self._resolution_to_format[resolution] self.device.width = resolution.width self.device.height = resolution.height self.device.pixel_format = pixel_format self.encoder = get_appropriate_encoder( resolution, pixel_format, use_mmap=True) self.encoder.width = resolution.width self.encoder.height = resolution.height self.encoder.stride = (resolution.width * BYTES_PER_PIXEL.get(pixel_format, 0)) def set_focus(self, focus): """Sets the camera focus""" self.device.set_focus(focus) def take_a_photo(self): """Takes a photo, blocking while doing it""" prctl_name() v4l2_source_buffer = self.device.next_frame() return self.encoder.encode(v4l2_source_buffer.bytesused) def _disconnect(self): """Disconnects from the camera""" if self.device is None: return try: self.device.stop() except OSError: log.exception("Camera %s could not be closed", self.camera_id) except Exception: # pylint: disable=broad-except log.exception("Camera %s could not be closed - unknown error", self.camera_id) try: self.encoder.stop() except OSError: log.exception("Encoder for %s could not be closed", self.camera_id) except Exception: # pylint: disable=broad-except log.exception("Encoder for %s could not be closed - unknown error", self.camera_id) ================================================ FILE: prusa/link/conditions.py ================================================ """PrusaLink error states.html For more information see prusalink_states.txt. """ from typing import Optional from poorwsgi import state from poorwsgi.response import JSONResponse, TextResponse from prusa.connect.printer.conditions import ( COND_TRACKER, HTTP, INTERNET, TOKEN, Condition, ConditionTracker, ) from .config import Settings assert HTTP is not None assert TOKEN is not None OK_MSG = {"ok": True, "message": "OK"} ROOT_COND = Condition("Root", "The root of everything, it's almost always OK") DEVICE = Condition("Device", "Eth|WLAN device does not exist", short_msg="No WLAN device", parent=ROOT_COND, priority=1020) PHY = Condition("Phy", "Eth|WLAN device is not connected", parent=DEVICE, short_msg="No WLAN conn", priority=1010) LAN = Condition("Lan", "Eth|WLAN has no IP address", parent=PHY, short_msg="No WLAN IP addr", priority=1000) INTERNET.set_parent(LAN) SERIAL = Condition("Port", "Serial device does not exist", parent=ROOT_COND, priority=570) RPI_ENABLED = Condition("RPIenabled", "RPi port is not enabled", parent=SERIAL, priority=560) ID = Condition("ID", "Device is not supported", parent=RPI_ENABLED, priority=550) UPGRADED = Condition("Upgraded", "Printer upgraded, re-register it", parent=ID, priority=500) FW = Condition("Firmware", "Firmware is not up-to-date", parent=RPI_ENABLED, priority=540) SN = Condition("SN", "Serial number cannot be obtained", parent=RPI_ENABLED, priority=530) JOB_ID = Condition("JobID", "Job ID cannot be obtained", parent=RPI_ENABLED, priority=520) HW = Condition("HW", "Firmware detected a hardware issue", parent=RPI_ENABLED, priority=510) COND_TRACKER.add_tracked_condition_tree(ROOT_COND) NET_TRACKER = ConditionTracker() NET_TRACKER.add_tracked_condition_tree(DEVICE) PRINTER_TRACKER = ConditionTracker() PRINTER_TRACKER.add_tracked_condition_tree(SERIAL) def use_connect_errors(use_connect): """Set whether to use Connect related errors or not""" if use_connect: COND_TRACKER.add_tracked_condition_tree(INTERNET) NET_TRACKER.add_tracked_condition_tree(INTERNET) else: COND_TRACKER.remove_tracked_condition_tree(INTERNET) NET_TRACKER.remove_tracked_condition_tree(INTERNET) def status(): """Return a dict with representation of all current conditions""" result = {} for condition in reversed(list(ROOT_COND)): result[condition.name] = (condition.state.name, condition.long_msg) return result def printer_status(): """Returns a representation of the currently broken printer condition""" worst = PRINTER_TRACKER.get_worst() if worst is None: return OK_MSG return {"ok": False, "message": worst.long_msg} def connect_status(): """Returns a representation of the currently broken Connect condition""" worst = NET_TRACKER.get_worst() if worst is None: if not Settings.instance.use_connect(): return {"ok": True, "message": "Connect isn't configured"} return OK_MSG return {"ok": False, "message": worst.long_msg} class LinkError(RuntimeError): """Link error structure.""" title: str text: str id: Optional[str] = None status_code: int path: Optional[str] = None details: Optional[str] = None url: str = '' use_basic_template = True def __init__(self, details: str = ""): if details: self.details = details if self.id: self.path = '/error/' + self.id # pylint: disable=consider-using-f-string if self.use_basic_template: self.template = "error.html" else: self.template = 'error-%s.html' % self.id super().__init__(self.text) def set_url(self, req): """Set url from request and self.path.""" self.url = req.construct_url(self.path) if self.path else '' def gen_headers(self): """Return headers with Content-Location if id was set.""" return {'Content-Location': self.url} if self.url else {} def json_response(self): """Return JSONResponse for error.""" kwargs = { "title": self.title, "message": self.text, } if self.url: kwargs['url'] = self.url return JSONResponse(status_code=self.status_code, headers=self.gen_headers(), **kwargs) def text_response(self): """Return TextResponse for error.""" url = "\n\nSee: " + self.url if self.url else '' # pylint: disable=consider-using-f-string text_response = "%s\n%s\n%s%s" % \ (self.title, self.text, self.details if self.details else "", url) return TextResponse(text_response, status_code=self.status_code, headers=self.gen_headers()) class BadRequestError(LinkError): """400 Bad Request error""" status_code = state.HTTP_BAD_REQUEST class TemperatureTooLow(BadRequestError): """400 Temperature is too low""" title = "Temperature too low" text = "Desired temperature is too low" id = "temperature-too-low" class TemperatureTooHigh(BadRequestError): """400 Temperature is too high""" title = "Temperature too high" text = "Desired temperature is too high" id = "temperature-too-high" class ValueTooLow(BadRequestError): """400 Generic value is too low""" title = "Value too low" text = "Desired value is too low" id = "value-too-low" class ValueTooHigh(BadRequestError): """400 Generic value is too high""" title = "Value too high" text = "Desired value is too high" id = "value-too-high" class CantMoveAxis(BadRequestError): """400 Can't Move Axis""" title = "Can't move axis" text = "Can't move axis in current state" id = "cant-move-axis" class CantMoveAxisZ(BadRequestError): """400 Can't move axis in current state""" title = "Can't Move Axis Z in current state" text = "Axis Z can't be moved in current state" id = "cant-move-axis-z" class DestinationSameAsSource(BadRequestError): """400 Destination is same as source""" title = "Destination same as source" text = "Destination to move file is same as the source of the file" id = "destination-same-as-source" class NoFileInRequest(BadRequestError): """400 File not found in request payload.""" title = "Missing file in payload." text = "File is not send in request payload or it hasn't right name." id = "no-file-in-request" class FileSizeMismatch(BadRequestError): """400 File size mismatch.""" title = "File Size Mismatch" text = "You sent more or less data than is in Content-Length header." id = "file-size-mismatch" class InvalidIniFileFormat(BadRequestError): """400 Invalid ini file format.""" title = "Invalid ini File Format" text = "Format or the structure of your ini file is invalid." id = "invalid-ini-file-format" class InvalidBooleanHeader(BadRequestError): """400 Invalid Boolean Header""" title = "Invalid Boolean Header" text = "Invalid Boolean Header according to RFC8941 / 3.3.6" id = "invalid-boolean-header" class ForbiddenCharacters(BadRequestError): """400 Forbidden Characters.""" title = "Forbidden Characters" text = "Forbidden characters in file or folder name." id = "forbidden-characters" class FilenameTooLong(BadRequestError): """400 Filename Too Long""" title = "Filename Too Long" text = "File name length is too long" id = "filename-too-long" class FoldernameTooLong(BadRequestError): """400 Foldername Too Long""" title = "Foldername Too Long" text = "Folder name length is too long" id = "foldername-too-long" class FileUploadFailed(BadRequestError): """400 File Upload Failed""" title = "File Upload Failed" text = "File upload has failed" id = "file-upload-failed" class CantConnect(BadRequestError): """400 Can't connect to PrusaConnect""" title = "Can't Connect" text = "Can't connect to PrusaConnect" id = "cant-connect" class CantResolveHostname(BadRequestError): """400 Can't resolve PrusaConnect hostname""" title = "Can't resolve hostname" text = "Can't resolve PrusaConnect hostname" id = "cant-resolve-hostname" class NotSupportedFileType(LinkError): """415 Not supported file""" title = "Not Supported File Type" text = "Uploaded file type is not supported." id = "not-supported-file-type" status_code = state.HTTP_UNSUPPORTED_MEDIA_TYPE class ForbiddenError(LinkError): """403 Forbidden""" title = "Forbidden" text = "You don not have permission to access this." status_code = state.HTTP_FORBIDDEN id = "forbidden" class NotFoundError(LinkError): """404 Not Found error""" title = "Not Found" text = "Resource you want not found." status_code = state.HTTP_NOT_FOUND id = "not-found" class NotCurrentJob(NotFoundError): """404 Not current job""" title = "Not Current Job" text = "Given job id does not belong to current job" class GoneError(LinkError): """410 Gone""" title = "Target Resource Unavailable" text = "Target resource is unavailable." status_code = state.HTTP_GONE id = "file-gone" class ThumbnailUnavailable(GoneError): """410 Thumbnail Unavailable""" title = "Thumbnail Unavailable" text = "Thumbnail is unavailable." class FileNotFound(NotFoundError): """404 File Not Found""" title = "File Not Found" text = "File you want was not found." class FolderNotFound(NotFoundError): """404 Folder Not Found""" title = "Folder Not Found" text = "Folder you want was not found." class LocationNotFound(NotFoundError): """404 Location from url not found.""" title = "Location Not Found" text = "Location not found, use local." id = "location-not-found" status_code = state.HTTP_NOT_FOUND class ConflictError(LinkError): """409 Conflict error.""" status_code = state.HTTP_CONFLICT class DirectoryNotEmpty(ConflictError): """409 Directory is not empty""" title = "Directory is not empty" text = "Directory can't be deleted, because it's not empty." id = "directory-not-empty" class CurrentlyPrinting(ConflictError): """409 Printer is currently printing""" title = "Printer is currently printing" text = "Printer is currently printing." class NotStateToPrint(ConflictError): """409 Printer is not in state to print""" title = "Not in state to print" text = "Printer is not in state to print." id = "not-state-to-print" class NotPrinting(ConflictError): """409 Printer is not printing""" title = "Printer Is Not Printing" text = "Operation you want can only be done when printer is printing." class NotPaused(ConflictError): """409 Printer is not paused""" title = "Printer Is Not Paused" text = "Operation you want can only be done when printer is paused." class FileCurrentlyPrinted(ConflictError): """409 File is currently printed""" title = "File is currently printed" text = \ "You try to do an operation with the file, which is currently printed." id = "file-currently-printed" class TransferConflict(ConflictError): """409 Already in transfer process.""" title = "Already in transfer process" text = "Only one file at time can be transferred." id = "transfer-conflict" # TODO: html variant class TransferStopped(ConflictError): """409 Transfer process was stopped by user.""" title = "Transfer stopped" text = "Transfer process was stopped by user." id = "transfer-stopped" class UnavailableUpdate(ConflictError): """409 Update is unavailable to install""" title = "Unavailable update" text = "Update is unavailable to install" id = "unavailable-update" status_code = state.HTTP_CONFLICT class UnableToUpdate(ConflictError): """409 Unable to install update""" title = "Unable to update" text = "Unable to install update" id = "unable-to-update" status_code = state.HTTP_CONFLICT class LengthRequired(LinkError): """411 Length Required.""" title = "Length Required" text = "Missing Content-Length header or no content in request." id = "length-required" status_code = state.HTTP_LENGTH_REQUIRED class EntityTooLarge(LinkError): """413 Payload Too Large""" title = "Request Entity Too Large" text = "Not enough space in storage." id = "entity-too-large" status_code = state.HTTP_REQUEST_ENTITY_TOO_LARGE class FileAlreadyExists(LinkError): """409 File Already Exists""" title = "File Already Exists" text = "File already exists." id = "file-already-exists" status_code = state.HTTP_CONFLICT class FolderAlreadyExists(LinkError): """409 Folder Already Exists""" title = "Folder Already Exists" text = "Folder already exists." id = "folder-already-exists" status_code = state.HTTP_CONFLICT class StorageNotExist(LinkError): """409 Storage Does Not Exist""" title = "Storage Does Not Exist" text = "Storage doest not exist." id = "storage-not-exist" status_code = state.HTTP_CONFLICT class SDCardReadOnly(LinkError): """409 SD Card Read Only""" title = "SD Card Read Only" text = "SD Card storage is read only." id = "entity-too-large" status_code = state.HTTP_CONFLICT class SDCardNotSupported(LinkError): """409 Some operations are not possible on SDCard.""" title = "SDCard is not Suppported" text = "Location `sdcard` is not supported, use local." id = "sdcard-not-supported" status_code = state.HTTP_CONFLICT class UnsupportedMediaError(LinkError): """415 Unsupported Media Type""" title = "Unsupported Media Type" text = "Only G-Code for FDM printer can be uploaded." id = "unsupported-media-type" status_code = state.HTTP_UNSUPPORTED_MEDIA_TYPE class InternalServerError(LinkError): """500 Internal Server Error.""" title = "Internal Server Error" text = ("We're sorry, but there is a error in service. " "Please try again later.") id = "internal-server-error" status_code = state.HTTP_INTERNAL_SERVER_ERROR class ResponseTimeout(InternalServerError): """500 Response Timeout""" title = "Response Timeout" text = "There is some problem on PrusaLink." id = "response-timeout" class PrinterUnavailable(LinkError): """503 Printer Unavailable.""" title = "Printer Unavailable." text = "PrusaLink not finished initializing or Printer not connected." id = "printer-unavailable" status_code = state.HTTP_SERVICE_UNAVAILABLE class RequestTimeout(LinkError): """408 Request timeout.""" title = "Request timeout." text = "PrusaLink got tired of waiting for your request. " \ "cancelled upload?" id = "request-timeout" status_code = state.HTTP_REQUEST_TIME_OUT ================================================ FILE: prusa/link/config.py ================================================ """Config class definition.""" import logging from logging import Formatter, StreamHandler from logging.handlers import SysLogHandler from os import getuid from os.path import abspath, join from pathlib import Path from pwd import getpwnam, getpwuid from typing import Iterable from extendparser.get import Get from .const import PRINTER_CONF_TYPES CONNECT = 'connect.prusa3d.com' LOG_FORMAT_FOREGROUND = \ "%(asctime)s %(levelname)s {%(module)s.%(funcName)s():%(lineno)d} "\ "[%(threadName)s]: %(message)s " LOG_FORMAT_SYSLOG = \ "%(name)s[%(process)d]: "\ "%(levelname)s: %(message)s {%(funcName)s():%(lineno)d}" # pylint: disable=too-many-ancestors def get_log_level_dict(log_levels: Iterable[str]): """Parse log level from command line arguments.""" log_level_dict = {} for log_config in log_levels: parts = log_config.split("=") if len(parts) != 2: raise ValueError("Log level settings needs to contain exactly one " "\"=\"") name, loglevel = parts log_level_dict[name] = loglevel return log_level_dict def check_log_level(value): """Check valid log level.""" if value not in ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"): raise ValueError(f"Invalid value {value}") def check_server_type(value): """Check valid server class""" if value not in ("single", "threading", "forking"): raise ValueError(f"Invalid value {value}") class Model(dict): """Config model based on dictionary. It simply implements set and get attr methods. """ def __getattr__(self, key): try: return self[key] except KeyError as err: raise AttributeError(err) from err def __setattr__(self, key, val): self[key] = val @staticmethod def get(cfg, name, options): return Model(cfg.get_section(name, options)) class FakeArgs: """Fake arguments for the config.py component""" def __init__(self, path): self.config = path self.debug = False self.foreground = True self.pidfile = None self.module_log_level = None self.address = None self.tcp_port = None self.link_info = None self.serial_port = None self.debug = False self.info = False self.printer_number = None class Config(Get): """This class handles prusalink.ini configuration file.""" # pylint: disable=too-many-branches def __init__(self, args): super().__init__() self.read(args.config) self.debug = args.debug # [daemon] self.daemon = Model( self.get_section( "daemon", ( ("data_dir", str, ''), # user home by default ("pid_file", str, "./prusalink.pid"), ("power_panic_file", str, "./power_panic"), ("threshold_file", str, "./threshold.data"), ("user", str, "pi"), ("group", str, "pi"), ("printer_number", int, None), ))) if args.foreground or getuid() != 0: pwd = getpwuid(getuid()) self.daemon.user = pwd.pw_name self.daemon.home = pwd.pw_dir else: self.daemon.home = getpwnam(self.daemon.user).pw_dir if not self.daemon.data_dir: self.daemon.data_dir = self.daemon.home if args.pidfile: self.daemon.pid_file = abspath(args.pidfile) if args.printer_number is not None: self.daemon.printer_number = args.printer_number for file_ in ('pid_file', 'power_panic_file', 'threshold_file'): setattr( self.daemon, file_, abspath(join(self.daemon.data_dir, getattr(self.daemon, file_)))) # [logging] self.set_global_log_level(args) # Let's combine the config log setting and cmd args # with cmd args overriding config values self.log_settings = {} if "log" in self: for module_name, log_level in self["log"].items(): check_log_level(log_level) self.log_settings[module_name] = log_level if args.module_log_level is not None: override_log_settings = get_log_level_dict(args.module_log_level) self.log_settings.update(override_log_settings) # Let's save the handler we've configured for later use self.configured_handler = self.get_log_handler(args) # [http] self.http = Model( self.get_section("http", ( ("address", str, "0.0.0.0"), ("port", int, 8080), ("link_info", bool, False), ))) if args.address: self.http.address = args.address if args.tcp_port: self.http.port = args.tcp_port if args.link_info: self.http.link_info = args.link_info # [printer] self.printer = Model( self.get_section( "printer", ( ("port", str, "auto"), ("baudrate", int, 115200), # Dangerous, it writes to the EEPROM on the little 32u2/8u2 # This wears it out. Enabling this, you get PowerPanic # for the SD prints with RPi over USB, but you get # Around 40000 guaranteed working SD prints. After that # Your 32u2 EEPROM might wear out and the enable/disable # would get stuck in one or the other state ("reset_disabling", bool, False), ("settings", str, "./prusa_printer_settings.ini"), # Support for monitoring mountpoints temporarily off # ("storage", tuple, [], ':'), # relative to HOME ("directory", str, "./PrusaLink gcodes"), ))) if args.serial_port: self.printer.port = args.serial_port self.printer.settings = abspath( join(self.daemon.data_dir, self.printer.settings)) self.printer.directory = abspath(join(self.daemon.data_dir, self.printer.directory)) self.printer.directory_name = Path(self.printer.directory).name # [cameras] self.cameras = Model( self.get_section( "cameras", ( ("auto_detect", bool, True), ))) def set_section(self, name, model): """Set section from model""" if name not in self: self.add_section(name) for key, val in model.items(): # FIXME: HACKS! We are at the limits of extendparser if name == "printer" and key == "storage": value = ":".join(val) self.set(name, key, value) elif name == "daemon" and key == "home": continue elif name == "printer" and key == "directory_name": continue else: self.set(name, key, str(val)) def update_sections(self): """Update config from attributes.""" self.set_section('daemon', self.daemon) self.set_section('log', self.log_settings) self.set_section('http', self.http) self.set_section('printer', self.printer) self.set_section('cameras', self.cameras) def set_global_log_level(self, args): """Set default global log level from command line.""" if args.debug: log_level = "DEBUG" elif args.info: log_level = "INFO" else: log_level = logging.root.level logging.root.setLevel(log_level) logging.getLogger("urllib3").setLevel(log_level) logging.getLogger("connect-printer").setLevel(log_level) # FIXME def get_log_handler(self, args): """Logger setting are more complex.""" if args.foreground: log_format = LOG_FORMAT_FOREGROUND configured_handler = StreamHandler() else: log_format = LOG_FORMAT_SYSLOG log_syslog = self.get("logging", "syslog", fallback="/dev/log") configured_handler = SysLogHandler(log_syslog, SysLogHandler.LOG_DAEMON) log_format = self.get("logging", "format", fallback=log_format) for handler in logging.root.handlers: # reset root logger handlers logging.root.removeHandler(handler) logging.root.addHandler(configured_handler) formatter = Formatter(log_format) configured_handler.setFormatter(formatter) return configured_handler class Settings(Get): """This class handles prusa_printer_settings.ini configuration file. File prusa_printer_settings.ini is official Prusa settings file, which has shared format between all printers, and PrusaConnect can generate it. """ instance = None def __init__(self, settings_file): if Settings.instance is not None: raise RuntimeError('Config is singleton') super().__init__(interpolation=None) self.read(settings_file) # [printer] self.printer = Model( self.get_section('printer', (('type', str, ''), ('name', str, ''), ('location', str, ''), ('farm_mode', bool, False), ("network_error_chime", bool, False), ))) self.printer['name'] = self.printer['name'].strip() self.printer['location'] = self.printer['location'].strip() if self.printer.type and self.printer.type not in PRINTER_CONF_TYPES: raise ValueError("Settings file for an unsupported printer") # [network] self.network = Model( self.get_section('network', (('hostname', str, ''), ))) # [service::connect] self.service_connect = Model( self.get_section( 'service::connect', ( ('hostname', str, CONNECT), ('tls', bool, True), ('port', int, 0), # 0 means 443 with tls, or 80 without tls ('token', str, '')))) # [service::local] self.service_local = Model( self.get_section('service::local', (('enable', int, 1), ('username', str, ''), ('digest', str, ''), ('api_key', str, '')))) Settings.instance = self # Reflect possible changes back to prusa_printer_settings.ini file self.update_sections() with open(settings_file, 'w', encoding='utf-8') as ini: Settings.instance.write(ini) def set_section(self, name, model): """Set section from model""" if name not in self: self.add_section(name) for key, val in model.items(): self.set(name, key, str(val)) def update_sections(self, connect_skip=False): """Update config from attributes.""" self.set_section('printer', self.printer) self.set_section('network', self.network) if not connect_skip: self.set_section('service::connect', self.service_connect) self.set_section('service::local', self.service_local) def is_wizard_needed(self): """ Is there a reason for the wizard to be shown? """ interested_in = [ self.printer["type"], self.service_local["username"], self.service_local["digest"], ] return not all(interested_in) def use_connect(self): """ Gets the user's wish to use or not tu use connect Needs its own value, now substituted by token """ return bool(self.service_connect["token"]) ================================================ FILE: prusa/link/const.py ================================================ """ Contains almost every constant for the printer communication part of PrusaLink """ import uuid from importlib.resources import files # type: ignore from os import path from typing import List from bidict import bidict from packaging.version import Version from prusa.connect.printer.const import PrinterType, State from .printer_adapter.structures.model_classes import PrintMode, PrintState instance_id = uuid.uuid4() # e.g. Mon, 07 Nov 2022 13:52:49 GMT HEADER_DATETIME_FORMAT = "%a, %d %b %Y %X GMT" PRINTER_TYPES = { 250: PrinterType.I3MK25, 252: PrinterType.I3MK25S, 20250: PrinterType.I3MK25, 20252: PrinterType.I3MK25S, 300: PrinterType.I3MK3, 20300: PrinterType.I3MK3, 302: PrinterType.I3MK3S, 20302: PrinterType.I3MK3S, 30302: PrinterType.I3MK3S, } MMU3_TYPE_CODE = 30302 PRINTER_CONF_TYPES = bidict({ "MK2.5": PrinterType.I3MK25, "MK2.5S": PrinterType.I3MK25S, "MK3": PrinterType.I3MK3, "MK3S": PrinterType.I3MK3S, }) DATA_PATH = path.abspath(path.join(str(files('prusa.link')), 'data')) BASE_STATES = {State.IDLE, State.BUSY, State.READY} PRINTING_STATES = {State.PRINTING, State.PAUSED, State.FINISHED, State.STOPPED} MK25_PRINTERS = {PrinterType.I3MK25.value, PrinterType.I3MK25S.value} JOB_STARTING_STATES = {State.PRINTING, State.PAUSED} JOB_ENDING_STATES = { State.FINISHED, State.STOPPED, } JOB_DESTROYING_STATES = { State.ERROR, State.IDLE, # These are needed for the job to end through ATTENTION State.BUSY, } JITTER_THRESHOLD = 0.5 PRUSA_VENDOR_ID = "2c99" # --- Intervals --- # Values are in seconds TELEMETRY_IDLE_INTERVAL = 0.25 TELEMETRY_PRINTING_INTERVAL = 1 TELEMETRY_SLEEPING_INTERVAL = 4 # can be sleeping in any state TELEMETRY_SLEEP_AFTER = 3 * 60 TELEMETRY_REFRESH_INTERVAL = 5 * 60 # full telemetry re-send FAST_POLL_INTERVAL = 1 SLOW_POLL_INTERVAL = 10 # for values, that aren't that important VERY_SLOW_POLL_INTERVAL = 30 IP_UPDATE_INTERVAL = 5 QUIT_INTERVAL = 0.2 SD_INTERVAL = 0.2 SD_FILESCAN_INTERVAL = 60 DIR_RESCAN_INTERVAL = 1 PRINTER_BOOT_WAIT = 8 SEND_INFO_RETRY = 5 SERIAL_REOPEN_TIMEOUT = 2 REPORTING_TIMEOUT = 60 FW_MESSAGE_TIMEOUT = 10 STATE_CHANGE_TIMEOUT = 15 IP_WRITE_TIMEOUT = 5 SN_OBTAIN_INTERVAL = 5 EXIT_TIMEOUT = 15 ERROR_REASON_TIMEOUT = 2 PATH_WAIT_TIMEOUT = 10 SLEEP_SCREEN_TIMEOUT = 20 SELF_PING_TIMEOUT = 5 SELF_PING_RETRY_INTERVAL = 10 ATTENTION_CLEAR_INTERVAL = 5 CAMERA_INIT_DELAY = 2 CAMERA_SCAN_INTERVAL = 30 CAMERA_REGISTER_TIMEOUT = 5 TIME_FOR_SNAPSHOT = 1 PRINT_END_TIMEOUT = 11 KEEPALIVE_INTERVAL = 12 PP_MOVES_DELAY = 20 # --- Lcd queue --- LCD_QUEUE_SIZE = 30 # --- Serial queue --- RX_SIZE = 128 # Not used much, limits the max serial message size SERIAL_QUEUE_TIMEOUT = 25 SERIAL_QUEUE_MONITOR_INTERVAL = 1 HISTORY_LENGTH = 100 # How many messages to remember for Resends # --- Is planner fed --- QUEUE_SIZE = 10000 # From how many messages to compute the percentile HEAP_RATIO = 0.95 # What percentile to compute IGNORE_ABOVE = 1.0 # Ignore instructions, that take longer than x sec DEFAULT_THRESHOLD = 0.13 # Percentile for uninitialised component USE_DYNAMIC_THRESHOLD = True # Compute the percentile or use a fixed value? # --- File printer --- STATS_EVERY = 100 TAIL_COMMANDS = 10 # how many commands after the last progress report PRINT_QUEUE_SIZE = 4 # --- Storage --- MAX_FILENAME_LENGTH = 52 SD_STORAGE_NAME = "SD Card" BLACKLISTED_TYPES: List[str] = [] BLACKLISTED_PATHS = [ "/dev", "/sys", "/proc", "/tmp", ] BLACKLISTED_NAMES = [SD_STORAGE_NAME] SFN_TO_LFN_EXTENSIONS = {"GCO": "gcode", "G": "g", "GC": "gc"} RESET_PIN = 22 # RPi gpio pin for resetting printer SUPPORTED_FIRMWARE = "3.14.0" MINIMAL_FIRMWARE = Version(SUPPORTED_FIRMWARE) MAX_INT = (2**31) - 1 STATE_HISTORY_SIZE = 10 # --- Interesting_Logger --- LOG_BUFFER_SIZE = 200 AFTERMATH_LOG_SIZE = 100 # --- Selected log files--- GZ_SUFFIX = ".gz" LOGS_PATH = "/var/log" LOGS_FILES = ("auth.log", "daemon.log", "kern.log", "messages", "syslog", "user.log") # --- Hardware limits for commands --- class LimitsFDM: """Generic FDM Limits object""" # --- Printer Object info --- id: str name: str type: int version: int subversion: int # --- Hardware limits --- extrusion_min = -10 extrusion_max = 100 feedrate_e_min = 0 feedrate_e_max = 100 feedrate_x_min = 0 feedrate_x_max = 2700 feedrate_y_min = 0 feedrate_y_max = 2700 feedrate_z_min = 0 feedrate_z_max = 1000 min_temp_nozzle_e = 170 position_x_min = 0 position_x_max = 255 position_y_min = -4 position_y_max = 212.5 position_z_min = 0.15 position_z_max = 210 print_flow_min = 10 print_flow_max = 999 print_speed_min = 10 print_speed_max = 999 temp_bed_min = 0 temp_bed_max = 125 temp_nozzle_min = 0 temp_nozzle_max = 305 class LimitsMK25(LimitsFDM): """Printer MK2.5 Limits object""" id = '1.2.5' name = 'Original Prusa i3 MK2.5' type = 1 version = 2 subversion = 5 class LimitsMK25S(LimitsFDM): """Printer MK2.5S Limits object""" id = '1.2.6' name = 'Original Prusa i3 MK2.5S' type = 1 version = 2 subversion = 6 class LimitsMK3(LimitsFDM): """Printer MK3 Limits object""" id = '1.3.0' name = 'Original Prusa i3 MK3' type = 1 version = 3 subversion = 0 class LimitsMK3S(LimitsFDM): """Printer MK3S Limits object""" id = '1.3.1' name = 'Original Prusa i3 MK3S' type = 1 version = 3 subversion = 1 PRINT_STATE_PAIRING = { "sdn_lfn": PrintState.SD_PRINTING, "sd_paused": PrintState.SD_PAUSED, "serial_paused": PrintState.SERIAL_PAUSED, "no_print": PrintState.NOT_SD_PRINTING, } PRINT_MODE_PAIRING = {"SILENT": PrintMode.SILENT, "NORMAL": PrintMode.NORMAL} PRINT_MODE_ID_PAIRING = { 0: PrintMode.NORMAL, 1: PrintMode.SILENT, 2: PrintMode.AUTO, } # keys are the manufacturer ids, values are supported models SUPPORTED_PRINTERS = { "2c99": {"0001", "0002"}, } MMU_SLOTS = 5 MMU_PROGRESS_MAP = { "OK": 0, "Engaging idler": 1, "Disengaging idler": 2, "Unloading to FINDA": 3, "Unloading to pulley": 4, "Feeding to FINDA": 5, "Feeding to extruder": 6, "Feeding to nozzle": 7, "Avoiding grind": 8, "ERR Disengaging idler": 10, "ERR Engaging idler": 11, "ERR Wait for User": 12, "ERR Internal": 13, "ERR Help filament": 14, "ERR TMC failed": 15, "Selecting fil. slot": 18, "Preparing blade": 19, "Pushing filament": 20, "Performing cut": 21, "Returning selector": 22, "Ejecting filament": 24, "Parking selector": 23, "Retract from FINDA": 25, "Homing": 26, "Moving selector": 27, "Feeding to FSensor": 28, } MMU_ERROR_MAP = { 0x8001: 101, # FINDA didn't switch on -> FINDA didn't trigger 0x8002: 102, # FINDA didn't switch off -> FINDA filament stuck 0x8003: 103, # Filament sensor didn't switch on -> FSENSOR didn't trigger 0x8004: 104, # Filament sensor didn't switch off -> FSENSOR filament stuck 0x800b: 105, # MOVE_PULLEY_FAILED -> MECHANICAL pulley cannot move 0x8009: 106, # FSensor triggered too early -> MECHANICAL FSENSOR too early 0x800a: 107, # FINDA flickers -> MECHANICAL inspect FINDA 0x802a: 108, # LOAD_TO_EXTRUDER_FAILED -> Loading to extruder failed. # Inspect the filament tip shape. Refine the sensor # calibration, if needed 0x8007 | 0x0080: 115, # Selector homing failed 0x800b | 0x0080: 116, # Selector move failed 0x8007 | 0x0100: 125, # Idler homing failed 0x800b | 0x0100: 126, # Idler move failed 0xA000: 201, # TMC_OVER_TEMPERATURE_WARN -> Temperature warning TMC pulley # too hot 0xC000: 202, # TMC_OVER_TEMPERATURE_ERROR -> Temperature TMC pulley # overheat error # Temperature errors for the selector driver 0xA000 | 0x0080: 211, # Temperature warning TMC selector too hot 0xC000 | 0x0080: 212, # Temperature TMC selector overheat error # Temperature errors for the idler driver 0xA000 | 0x0100: 221, # Temperature warning TMC idler too hot 0xC000 | 0x0100: 222, # Temperature TMC idler overheat error # Electrical errors 0x8200: 301, # TMC_IOIN_MISMATCH -> Electrical TMC pulley driver error 0x8400: 302, # TMC_RESET -> Electrical TMC pulley driver reset 0x8800: 303, # TMC_UNDERVOLTAGE_ON_CHARGE_PUMP -> Electrical TMC # pulley undervoltage error 0x9000: 304, # TMC_SHORT_TO_GROUND -> Electrical TMC pulley driver shorted 0xC200: 305, # ERR_ELECTRICAL_MMU_PULLEY_SELFTEST_FAILED -> Electrical # TMC pulley selftest failed # Electrical errors for the selector driver 0x8200 | 0x0080: 311, # Electrical TMC selector driver error 0x8400 | 0x0080: 312, # Electrical TMC selector driver reset 0x8800 | 0x0080: 313, # Electrical TMC selector undervoltage error 0x9000 | 0x0080: 314, # Electrical TMC selector driver shorted 0xC200 | 0x0080: 315, # Electrical TMC selector selftest failed # Electrical errors for the idler driver 0x8200 | 0x0100: 321, # Electrical TMC idler driver error 0x8400 | 0x0100: 322, # Electrical TMC idler driver reset 0x8800 | 0x0100: 323, # Electrical TMC idler undervoltage error 0x9000 | 0x0100: 324, # Electrical TMC idler driver shorted 0xC200 | 0x0100: 325, # Electrical TMC idler selftest failed 0x0800d: 306, # MMU MCU detected a 5V undervoltage. There might be an # issue with the electronics. Check the wiring and # connectors # Connectivity errors 0x802e: 401, # MMU not responding -> CONNECT MMU not responding 0x802d: 402, # MMU not responding correctly. Check the wiring and # connectors # System errors 0x8005: 501, # Filament already loaded -> SYSTEM filament already loaded 0x8006: 502, # Invalid tool -> SYSTEM invalid tool 0x802b: 503, # QUEUE_FULL -> MMU Firmware internal error, please reset # the MMU 0x802c: 504, # VERSION_MISMATCH -> The MMU firmware version is # incompatible with the printer's FW. Update to compatible # version 0x802f: 505, # PROTOCOL_ERROR -> Internal runtime error. Try resetting # the MMU or updating the firmware 0x8008: 506, # FINDA_VS_EEPROM_DISCREPANCY -> Unload manually 0x800c: 507, # Filament was ejected -> SYSTEM filament ejected 0x8029: 508, # FILAMENT_CHANGE -> SYSTEM filament change } ================================================ FILE: prusa/link/daemon.py ================================================ """Daemon class implementation.""" import logging import sys from subprocess import Popen from typing import List import prctl # type: ignore from .config import Settings from .printer_adapter import prusa_link from .printer_adapter.prusa_link import PrusaLink from .web import WebServer, init_web_app from .web.lib.core import app log = logging.getLogger(__name__) class Daemon: """HTTP Daemon based on wsgiref.""" instance = None # pylint: disable=too-few-public-methods def __init__(self, config, argv: List): if Daemon.instance: raise RuntimeError("Daemon can be only one.") self.cfg = config self.argv = argv self.settings = None self.http = None self.prusa_link = None Daemon.instance = self def run(self, daemon=True): """Run daemon.""" prctl.set_name("pl#main") self.settings = Settings(self.cfg.printer.settings) init_web_app(self) self.http = WebServer(app, self.cfg.http.address, self.cfg.http.port, exit_on_error=not daemon) if self.settings.service_local.enable: self.http.start() # Log daemon stuff as printer_adapter adapter_logger = logging.getLogger(prusa_link.__name__) try: self.prusa_link = PrusaLink(self.cfg, self.settings) except Exception: # pylint: disable=broad-except adapter_logger.exception("Adapter was not start") self.http.stop() return 1 try: self.prusa_link.stopped_event.wait() return 0 except KeyboardInterrupt: adapter_logger.info('Keyboard interrupt') adapter_logger.info("Shutdown adapter") self.prusa_link.stop() self.http.stop() return 0 except Exception: # pylint: disable=broad-except adapter_logger.exception("Unknown Exception") self.http.stop() return 1 @staticmethod def restart(argv: List): """Restart prusa link by command line tool.""" # pylint: disable=consider-using-with Popen([sys.executable, '-m', 'prusa.link', 'restart'] + argv, start_new_session=True, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, close_fds=True) def sigterm(self, *_): """Raise KeyboardInterrupt exceptions in threads.""" log.info("SIGTERM received, shutting down PrusaLink") self.http.stop() if self.prusa_link: self.prusa_link.stop() log.warning("Shutdown complete") ================================================ FILE: prusa/link/data/image_builder/boot-message.service ================================================ [Unit] Description=Boot message [Service] Type=simple ExecStart=/bin/sh -c 'stty -F /dev/ttyAMA0 115200; printf \'M117 RPi booting...\n\' > /dev/ttyAMA0' [Install] WantedBy=basic.target ================================================ FILE: prusa/link/data/image_builder/first-boot.sh ================================================ set_up_port () { # Sets the baudrate and cancels the hangup at the end of a connection stty -F "$1" 115200 -hupcl || true } message() { printf "M117 $2\n" > "$1" || true } set_up_port "/dev/ttyAMA0" message "/dev/ttyAMA0" "Please wait < 10min"; for i in {0..5}; do set_up_port "/dev/ttyACM$i" done sleep 8 for i in {0..5}; do message "/dev/ttyACM$i" "Please wait < 10min" done # This generates the host keys for the ssh server to work ssh-keygen -A ================================================ FILE: prusa/link/data/image_builder/manager-start-script.sh ================================================ # Forward the port 80 to 8080 even on the loopback, so we can ping ourselves iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 8080 iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8080 iptables -t nat -I OUTPUT -p tcp -o lo -d localhost --dport 80 -j REDIRECT --to-ports 8080 set_up_port () { # Sets the baudrate and cancels the hangup at the end of a connection stty -F "$1" 115200 -hupcl || true } message() { printf "M117 $2\n" > "$1" || true } wifi_nic_name=$(find /sys/class/net -follow -maxdepth 2 -name wireless 2> /dev/null | cut -d / -f 5) if [ $? -eq 0 ] && [ -n "$wifi_nic_name" ]; then /sbin/iwconfig "$wifi_nic_name" power off if [ $? -eq 0 ]; then printf "Turned off power management for $wifi_nic_name\n" > "$1" fi fi username=$(id -nu 1000) user_site=$(su $username -c "python -m site --user-site") set_up_port "/dev/ttyAMA0" message "/dev/ttyAMA0" "Starting PrusaLink"; /home/$username/.local/bin/prusalink-boot rm -f /run/prusalink/manager.pid export PYTHONOPTIMIZE=2 PYTHONPATH=$user_site /home/$username/.local/bin/prusalink-manager -p "PYTHONPATH=$user_site /home/$username/.local/bin/" start ================================================ FILE: prusa/link/data/image_builder/prusalink-start-script.sh ================================================ # Forward the port 80 to 8080 even on the loopback, so we can ping ourselves iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 8080 iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8080 iptables -t nat -I OUTPUT -p tcp -o lo -d localhost --dport 80 -j REDIRECT --to-ports 8080 set_up_port () { # Sets the baudrate and cancels the hangup at the end of a connection stty -F "$1" 115200 -hupcl || true } message() { printf "M117 $2\n" > "$1" || true } wifi_nic_name=$(find /sys/class/net -follow -maxdepth 2 -name wireless 2> /dev/null | cut -d / -f 5) if [ $? -eq 0 ] && [ -n "$wifi_nic_name" ]; then /sbin/iwconfig "$wifi_nic_name" power off if [ $? -eq 0 ]; then printf "Turned off power management for $wifi_nic_name\n" fi fi username=$(id -nu 1000) set_up_port "/dev/ttyAMA0" message "/dev/ttyAMA0" "Starting PrusaLink"; /home/$username/.local/bin/prusalink-boot rm -f /home/$username/prusalink.pid export PYTHONOPTIMIZE=2 su $username -c "/home/$username/.local/bin/prusalink -i start" ================================================ FILE: prusa/link/data/prusalink.ini ================================================ [daemon] ; data_dir is used as default directory for other files, like ; prusa_printer_settings.ini or threshold_file ; default is user home ; data_dir = ; pid_file = ./prusalink.pid ; power_panic backup file - not supported yet ; power_panic_file = ./power_panic_file ; threshold_file = ./threshold.data ; user and group, when PrusaLink was start by root account ; user = pi ; group = pi [http] ; address = 0.0.0.0 ; port = 8080 ; ; Special /link-info debug page. ; link_info = False [printer] ; port = /dev/ttyAMA0 ; baudrate = 115200 ; settings = ./prusa_printer_settings.ini ; directory = ./PrusaLink gcodes ; Dangerous, it writes to the EEPROM on the little 32u2/8u2 each time an ; SD print starts or ends ; This wears it out. Enabling this, you get PowerPanic ; for the SD prints with RPi over USB, but you get ; around 50 000 guaranteed working SD prints. After that ; Your 32u2 EEPROM might wear out and the enable/disable ; would get stuck in one or the other state ; reset_disabling = False [cameras] ; auto_detect = True ================================================ FILE: prusa/link/interesting_logger.py ================================================ """Implements the InterestingLogRotator and InterestingLogger classes""" import logging import sys import threading import traceback from collections import deque from copy import copy from logging import CRITICAL, DEBUG, ERROR, INFO, NOTSET, WARNING, Logger from multiprocessing import RLock from .const import AFTERMATH_LOG_SIZE, LOG_BUFFER_SIZE from .printer_adapter.structures.mc_singleton import MCSingleton log = logging.getLogger("interesting_logger") class DecoySrcfile: """ Hi, you have found a hack, please make yourself a coffee ;) If we want to make our own Logger which will function as a normal vanilla Logger, we need it to skip more stack frames. From Python 3.8 this is possible as you can give the _log() method a number of frames to skip, originally, this has been done differently Each stack frame knows from which file it originated, so they compare those against their own filename and skip those, that match. As this is a different file, we need to skip it too, otherwise the log messages would just list the function name and line number from here. So let's trick the logging component by sneaking in a decoy "_srcfile" that will equal the original, plus our own file path. That way both frames from logging and here will get skipped and the real function name and line number will be shown. """ def __init__(self): self.original_logging_srcfile = copy(logging._srcfile) def __eq__(self, other): return other in {__file__, self.original_logging_srcfile} def __hash__(self): return hash(self.original_logging_srcfile) # pylint: disable=protected-access logging._srcfile = DecoySrcfile() # type: ignore class InterestingLogRotator(metaclass=MCSingleton): """ Stores all logs in a rotating queue, on trigger logs the current queue plus AFTERMATH_LOG_SIZE messages forward """ def __init__(self): self.log_buffer = deque(maxlen=LOG_BUFFER_SIZE) self.additional_messages_to_print = 0 self.log_lock = RLock() self.skipped_loggers = set() def skip_logger(self, logger_to_skip): """ Add a skipped logger to the set of skipped ones Reset cached skip values """ with self.log_lock: name = logger_to_skip.name self.skipped_loggers.add(name) # Reset the skip caches of all the loggers for logger in logging.getLogger().manager.loggerDict.values(): if isinstance(logger, InterestingLogger): logger._skipped = None def is_skipped(self, logger_name): """Is the logger name in the skipped set?""" return logger_name in self.skipped_loggers def process_log_entry(self, got_printed, level, msg, *args, **kwargs): """ If the log entry should be written out and was not, lets do it if there is nothing interesting going on, adds the log entry ino the rotating queue """ with self.log_lock: if self.additional_messages_to_print > 0: self.additional_messages_to_print -= 1 if not got_printed: self._log(level, msg, *args, **kwargs) else: self.log_buffer.appendleft((level, msg, args, kwargs)) @staticmethod def _log(level, msg, *args, **kwargs): """ Writes the message to the log, bumps its priority to warning and reports the original one in the text """ msg = f"Was[{logging.getLevelName(level)}]: " + str(msg) log.warning(msg, *args, **kwargs) @staticmethod def trigger(by_what: str): """ Static proxy for the instance_trigger method :param by_what: Interesting log triggered by ______ """ InterestingLogRotator.get_instance().instance_trigger(by_what) def instance_trigger(self, by_what: str): """ Triggers the mechanism to start dumping log messages :param by_what: Interesting log triggered by ______ """ with self.log_lock: self.additional_messages_to_print = AFTERMATH_LOG_SIZE log.warning("Interesting log triggered by %s", by_what) while self.log_buffer: level, msg, args, kwargs = self.log_buffer.pop() self._log(level, msg, *args, **kwargs) log.warning("Repeat - triggered by %s", by_what) log.warning("Listing all threads with stack traces for debugging") frames = sys._current_frames() # Print where all the threads are for thread in threading.enumerate(): if thread.ident is None: continue try: current_frame = frames[thread.ident] stack = traceback.extract_stack(current_frame) stacktrace_strings = stack.format() log.warning("Thread %s stack trace:", thread.name) for stack_trace_frame in stacktrace_strings: for line in stack_trace_frame.split("\n"): if line: log.warning(line) except KeyError: log.warning("Couldn't get a stacktrace for thread %s", thread.name) log.warning("") # An empty line for better orientation class InterestingLogger(Logger): """The logger that will mirror log entries to the log rotator""" def __init__(self, name, level=NOTSET): super().__init__(name, level) self.log_rotator = InterestingLogRotator.get_instance() self._skipped = None def is_skipped(self): """ Recursively figure out if we are supposed to skip appending to log_rotator. Cache the result """ if self._skipped is not None: return self._skipped # Lock our log modification lock - a bit hacky with self.log_rotator.log_lock: if self.log_rotator.is_skipped(self.name): self._skipped = True elif isinstance(self.parent, logging.RootLogger): self._skipped = False else: if isinstance(self.parent, InterestingLogger): self._skipped = self.parent.is_skipped() else: # Should not get triggered ever log.warning("Unsupported logger found: %s", self.parent.name) return False return self._skipped def debug(self, msg, *args, **kwargs): """ As a normal debug, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(DEBUG), DEBUG, msg, *args, **kwargs) super().debug(msg, *args, **kwargs) def info(self, msg, *args, **kwargs): """ As a normal info, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(INFO), INFO, msg, *args, **kwargs) super().info(msg, *args, **kwargs) def warning(self, msg, *args, **kwargs): """ As a normal warning, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(WARNING), WARNING, msg, *args, **kwargs) super().warning(msg, *args, **kwargs) def error(self, msg, *args, **kwargs): """ As a normal error, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(ERROR), ERROR, msg, *args, **kwargs) super().error(msg, *args, **kwargs) def critical(self, msg, *args, **kwargs): """ As a normal critical, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(CRITICAL), CRITICAL, msg, *args, **kwargs) super().critical(msg, *args, **kwargs) def log(self, level, msg, *args, **kwargs): """ As a normal log, with the added functionality of this class documented in the Class docstring """ if not self.is_skipped(): self.log_rotator.process_log_entry(self.isEnabledFor(level), level, msg, *args, **kwargs) super().log(level, msg, *args, **kwargs) ================================================ FILE: prusa/link/multi_instance/__init__.py ================================================ ================================================ FILE: prusa/link/multi_instance/__main__.py ================================================ """The module for starting PrusaLink Instance Manager components""" import argparse import logging import os import pwd import signal import sys import threading from concurrent.futures import ThreadPoolExecutor from logging.handlers import SysLogHandler from pathlib import Path from daemon import DaemonContext # type: ignore from lockfile.pidlockfile import PIDLockFile # type: ignore from ..__main__ import check_process from ..__main__ import stop as stop_process from ..config import LOG_FORMAT_SYSLOG, Config, FakeArgs from ..util import ensure_directory from .config_component import MultiInstanceConfig from .const import ( DEFAULT_UID, MANAGER_PID_PATH, MULTI_INSTANCE_CONFIG_PATH, RUN_DIRECTORY, SERVER_PID_PATH, UDEV_REFRESH_QUEUE_NAME, ) from .controller import Controller from .ipc_queue_adapter import IPCSender from .web import get_web_server log = logging.getLogger(__name__) def main_thread_exception(exc_type, exc_value, exc_traceback): """Log unhandled exceptions""" if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_traceback) return log.exception("Unhandled exception reached top level", exc_info=(exc_type, exc_value, exc_traceback)) def thread_exception(_): """Re-raise unhandled exceptions in threads to call sys.excepthook""" # ruff: noqa: PLE0704 raise # pylint: disable=misplaced-bare-raise threading.excepthook = thread_exception sys.excepthook = main_thread_exception def get_logger_file_descriptors(): """Get the file descriptors for all loggers""" file_descriptors = [] for handler in logging.root.handlers: if hasattr(handler, "socket"): file_descriptors.append(handler.socket.fileno()) if hasattr(handler, "stream"): file_descriptors.append(handler.stream.fileno()) return file_descriptors class Manager: """This class represents the process that runs the controller""" pid_file = PIDLockFile(MANAGER_PID_PATH) def __init__(self, user_info, prepend_executables_with): self.user_info = user_info if self.pid_file.is_locked(): if check_process(self.pid_file.read_pid()): print("Manager already running") log.error("Manager already running") sys.exit(1) self.pid_file.break_lock() context = DaemonContext( pidfile=self.pid_file, files_preserve=get_logger_file_descriptors(), signal_map={signal.SIGTERM: self._sigterm_handler}, detach_process=True, ) with context: self.controller = Controller( user_info=self.user_info, prepend_executables_with=prepend_executables_with) self.controller.run() def _sigterm_handler(self, *_): """Stops the controller. Has to return as fast as possible""" log.info("Received SIGTERM. Stopping Multi Instance Manager") self.controller.stop() class Server: """This class represents the process that runs the web server""" pid_file = PIDLockFile(SERVER_PID_PATH) def __init__(self, user_info): self.user_info = user_info self.web_server = None if self.pid_file.is_locked(): if check_process(self.pid_file.read_pid()): stop_process(self.pid_file.read_pid()) self.pid_file.break_lock() context = DaemonContext( uid=self.user_info.pw_uid, gid=self.user_info.pw_gid, files_preserve=get_logger_file_descriptors(), pidfile=self.pid_file, signal_map={signal.SIGTERM: self._sigterm_handler}, detach_process=True, ) with context: config = MultiInstanceConfig() self.web_server = get_web_server(config.web.port_range_start) self.web_server.start() self.web_server.thread.join() def _sigterm_handler(self, *_): """Stop the web server. Has to return as fast as possible""" log.info("Received SIGTERM. Stopping Multi Instance Web Server") self.web_server.stop() def get_username(username=None): """Return a valid username, if possible""" if username is not None: try: return pwd.getpwnam(username).pw_name except KeyError: log.error("Could not find configured user %s. Exiting..", username) raise else: try: return pwd.getpwuid(DEFAULT_UID).pw_name except KeyError: log.error("Could not get user for uid %s. Exiting...", DEFAULT_UID) raise def start(user_info, prepend_executables_with): """Starts the instance manager processes""" if os.fork() == 0: Manager(user_info, prepend_executables_with) sys.exit(0) if os.fork() == 0: Server(user_info) sys.exit(0) def handle_process_stop(pid_file, name="Process", quiet=False): """Stops a process handling pid file edge cases""" pid = pid_file.read_pid() if pid is not None: name = f"{name} PID {pid}" if pid_file.is_locked() and check_process(pid): stop_process(pid) else: if not quiet: print(f"{name} not running") log.warning("%s not running", name) def stop(quiet=False): """Stops the instance manager and all PrusaLink instances""" multi_instance_config = MultiInstanceConfig() stop_thread_count = len(multi_instance_config.printers) + 2 with ThreadPoolExecutor(max_workers=stop_thread_count) as executor: executor.submit(handle_process_stop, Manager.pid_file, "Instance Manager", quiet) executor.submit(handle_process_stop, Server.pid_file, "Multi Instance Server", quiet) for printer in multi_instance_config.printers: config = Config(FakeArgs(path=printer.config_path)) pid_file = PIDLockFile(Path(config.daemon.data_dir, config.daemon.pid_file)) executor.submit(handle_process_stop, pid_file, "PrusaLink instance", quiet) def clean(user_info, prepend_executables_with): """Stops the MultiInstance Manager and removes all printers""" stop(quiet=True) controller = Controller(user_info, prepend_executables_with) controller.remove_all_printers() def rescan(): """Notify the manager that a connection has been established by writing "connected" to the communication pipe.""" try: IPCSender.send_and_close(UDEV_REFRESH_QUEUE_NAME, "rescan") except FileNotFoundError: log.error("Cannot communicate to manager. Missing queue") def main(): """The main function for the PrusaLink instance manager. Parses command-line arguments and runs the instance controller""" parser = argparse.ArgumentParser( description="Multi-instance suite for PrusaLink") parser.add_argument("-i", "--info", action="store_true", help="include log messages up to the INFO level") parser.add_argument("-d", "--debug", action="store_true", help="include log messages up to the INFO level") parser.add_argument( "-u", "--username", required=False, help="Which users to use for running and storing everything") parser.add_argument( "-p", "--prepend-executables-with", required=False, help="Environment variables and path to the executables directory") subparsers = parser.add_subparsers(dest="command", help="Available commands") # Create a subparser for the start_daemon command subparsers.add_parser( "start", help="Start the instance managing daemon (needs root privileges)") subparsers.add_parser( "stop", help="Stop any manager daemon running (needs root privileges)") subparsers.add_parser( "clean", help="Danger! cleans all PrusaLink multi-instance configuration") # Create a subparser for the printer_connected command subparsers.add_parser( "rescan", help="Notify the daemon a printer has been connected") args = parser.parse_args() log_level = logging.WARNING if args.info: log_level = logging.INFO if args.debug: log_level = logging.DEBUG logging.basicConfig( level=log_level, format=LOG_FORMAT_SYSLOG, handlers=[SysLogHandler(address='/dev/log')], ) safe_username = get_username(args.username) user_info = pwd.getpwnam(safe_username) prepend_executables_with = args.prepend_executables_with or "" ensure_directory(RUN_DIRECTORY, chown_username=safe_username) ensure_directory(Path(MULTI_INSTANCE_CONFIG_PATH).parent) if args.command == "start": start(user_info, prepend_executables_with) elif args.command == "stop": stop() elif args.command == "clean": clean(user_info, prepend_executables_with) elif args.command == "rescan": rescan() else: parser.print_help() ================================================ FILE: prusa/link/multi_instance/config_component.py ================================================ """A module for managing the configuration files of multiple PrusaLink instances""" import grp import logging import os import shutil import stat import subprocess from pathlib import Path from time import monotonic, sleep from typing import List from blinker import Signal from extendparser import Get from ..config import Config, FakeArgs, Model from ..const import SUPPORTED_PRINTERS from ..util import PrinterDevice, ensure_directory, get_usb_printers from .const import ( CONFIG_PATH_PATTERN, CONNECTED_RULE_PATH, CONNECTED_RULE_PATTERN, DEV_PATH, MULTI_INSTANCE_CONFIG_PATH, PORT_RANGE_START, PRINTER_FOLDER_NAME_PATTERN, PRINTER_NAME_PATTERN, PRINTER_SYMLINK_PATTERN, RULE_PATH_PATTERN, RULE_PATTERN, UDEV_SYMLINK_TIMEOUT, ) log = logging.getLogger(__name__) class MultiInstanceConfig(Get): """This class handles the multi instance config file""" def __init__(self): super().__init__() self.read(MULTI_INSTANCE_CONFIG_PATH) self.printers = [] self.web = None self.web = Model( self.get_section( "web", ( ("port_range_start", int, PORT_RANGE_START), ), ), ) for section in self.sections(): if section == "web": continue try: self.add_from_section(section) except (FileNotFoundError, AttributeError): continue def add(self, printer_number, serial_number, config_path): """Adds a new printer config using specified parameters""" printer_name = PRINTER_NAME_PATTERN.format( printer_number=printer_number) printer = Model( self.get_section( printer_name, ( ("number", int, printer_number), ("serial_number", str, serial_number), ("config_path", str, config_path), ), ), ) printer.name = printer_name self.printers.append(printer) def add_from_section(self, section_name: str): """Adds a new printer config using a section read from config""" printer = Model( self.get_section( section_name, ( ("number", int, None), ("serial_number", str, None), ("config_path", str, None), ), ), ) printer.name = section_name for value in printer.values(): if value is None: raise ValueError(f"Invalid config for printer {section_name}") if not os.path.isfile(printer.config_path): raise FileNotFoundError("The configured printer config " "file is missing") self.printers.append(printer) def save(self): """Writes everything from RAM to the config file""" known_printers = set() for printer in self.printers: known_printers.add(printer.name) if printer.name not in self: self.add_section(printer.name) for key, val in printer.items(): if key == "name": continue self.set(printer.name, key, str(val)) if "web" not in self: self.add_section("web") for key, val in self.web.items(): self.set("web", key, str(val)) for section in self.sections(): if section in known_printers: continue if section == "web": continue self.remove_section(section) with open(MULTI_INSTANCE_CONFIG_PATH, "w", encoding="UTF-8") as file: self.write(file) class ConfigComponent: """Manages the configuration files and directories""" def __init__(self, multi_instance_config, user_info, prepend_executables_with): # -- create multi instance config -- self.multi_instance_config = multi_instance_config self.user_info = user_info self.prepend_executables_with = prepend_executables_with self.highest_printer_number = self._get_highest_printer_number() self.config_changed_signal = Signal() def configure_instance(self, printer: PrinterDevice, printer_number): """Oversees the creation of an instance configuration for a detected prnter device""" try: symlink_path = self._create_udev_rule(printer, printer_number) config_path = CONFIG_PATH_PATTERN.format(number=printer_number) # save multi_instance_config first # we rely on it for deleting the config stuff if anything fails self.multi_instance_config.add( printer_number=printer_number, serial_number=printer.serial_number, config_path=config_path) self.multi_instance_config.save() # Create data folder data_folder_name = PRINTER_FOLDER_NAME_PATTERN.format( number=printer_number) data_folder = os.path.join( self.user_info.pw_dir, data_folder_name) ensure_directory(data_folder, self.user_info.pw_name) # Create printer config self._create_printer_config( printer_number=printer_number, serial_port=symlink_path, data_folder=data_folder, config_path=config_path) except Exception: # pylint: disable=broad-except log.exception("Failed adding printer number %s", printer_number) self.remove_printers(numbers_to_remove=[printer_number]) raise def remove_all_printers(self): """Clears the configuration of all printers""" numbers_to_remove = [p.number for p in self.multi_instance_config.printers] self.remove_printers(numbers_to_remove=numbers_to_remove) def is_configured(self, serial_number): """Checks whether a printer with the specified serial number is already configured or not""" for printer in self.multi_instance_config.printers: if printer.serial_number == serial_number: return True return False def _get_highest_printer_number(self): """Gets the highest printer number among configured printers""" highest = 0 for printer in self.multi_instance_config.printers: highest = max(highest, printer.number) return highest def configure_new(self): """ Configure new printers found by scanning USB devices. Returns: list: A list of serial numbers of newly configured printers. """ configured = [] printer_number = self.highest_printer_number for printer in get_usb_printers(): log.debug("Found printer: %s", printer.serial_number) if self.is_configured(printer.serial_number): continue printer_number += 1 log.debug("Configuring: %s", printer.serial_number) try: self.configure_instance(printer, printer_number) except Exception: # pylint: disable=broad-except printer_number -= 1 continue configured.append(printer.serial_number) self.highest_printer_number = printer_number if configured: self.config_changed_signal.send() return configured def setup_connected_trigger(self): """Sets up the udev rule that notifies us about the newly connected printers""" self.teardown_connected_trigger() rule_lines = [] for vendor_id, model_ids in SUPPORTED_PRINTERS.items(): for model_id in model_ids: log.info("Adding rule for %s:%s", vendor_id, model_id) rule_lines.append(CONNECTED_RULE_PATTERN.format( vendor_id=vendor_id, model_id=model_id, username=self.user_info.pw_name, prepend=self.prepend_executables_with, )) contents = "\n".join(rule_lines) log.info("Writing udev rule:\n%s", contents) with open(CONNECTED_RULE_PATH, "w", encoding="UTF-8") as file: file.write(contents) os.chmod(CONNECTED_RULE_PATH, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) self.refresh_udev_rules() def teardown_connected_trigger(self): """Removes the udev rule that notifies us about the newly connected printers""" if os.path.exists(CONNECTED_RULE_PATH): os.remove(CONNECTED_RULE_PATH) self.refresh_udev_rules() def _create_udev_rule(self, printer: PrinterDevice, printer_number): """ Create a udev rule for the specified printer and printer number. Args: printer: PrinterDevice object representing a printer. printer_number: An integer representing the printer number. Returns: str: The path of the created symlink. """ symlink_name = PRINTER_SYMLINK_PATTERN.format(number=printer_number) symlink_path = os.path.join(DEV_PATH, symlink_name) rule = RULE_PATTERN.format( vendor_id=printer.vendor_id, model_id=printer.model_id, serial_number=printer.serial_number, symlink_name=symlink_name, ) log.debug("Udev rule: %s", printer.serial_number) rule_file_path = RULE_PATH_PATTERN.format(number=printer_number) with open(rule_file_path, "w", encoding="UTF-8") as file: file.write(rule) os.chmod(rule_file_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) self.refresh_udev_rules() self.wait_for_symlink(symlink_path) return symlink_path def _create_printer_config(self, printer_number, serial_port, data_folder, config_path): """ Create printer configuration file for the specified printer number, serial port, and other parameters. Args: printer_number: An integer representing the printer number. serial_port: A string representing the serial port for the printer. data_folder: A string representing the path to the printer's data folder. Returns: str: The path of the created configuration file. """ port = self.multi_instance_config.web.port_range_start + printer_number auto_detect_cameras = printer_number == 1 config = Config(FakeArgs(path=config_path)) config.daemon.data_dir = data_folder config.daemon.pid_file = Path(data_folder, "prusalink.pid") config.daemon.power_panic_file = Path(data_folder, "power_panic") config.daemon.threshold_file = Path(data_folder, "threshold.data") config.daemon.user = self.user_info.pw_name config.daemon.group = grp.getgrgid(self.user_info.pw_gid).gr_name config.daemon.printer_number = printer_number config.printer.port = serial_port config.printer.settings = Path(data_folder, "prusa_printer_settings.ini") directory = Path(data_folder, "PrusaLink gcodes").as_posix() config.printer.directory = directory config.http.port = port # Only the first printer gets cameras, whichever that ends up being config.cameras.auto_detect = auto_detect_cameras config.update_sections() with open(config_path, "w", encoding="UTF-8") as file: config.write(file) log.debug(str(config_path)) def remove_printers(self, numbers_to_remove: List[int]): """Remove printer configuration files, udev rules, and printer directories according to multi_instance_config.ini numbers_to_remove: A list of printer numbers to remove""" multi_instance_config = MultiInstanceConfig() to_remove = [] valid_numbers = set() for printer in multi_instance_config.printers: if printer.number in numbers_to_remove: to_remove.append(printer) valid_numbers.add(printer.number) # Check for non-existent printer numbers invalid_numbers = set(numbers_to_remove) - valid_numbers if invalid_numbers: log.warning("Invalid printer numbers: %s. Not cleaning those", invalid_numbers) log.debug("Removing %s", list(map(lambda i: i.name, to_remove))) for printer in to_remove: log.debug("removing printer %s", printer.number) # Delete the printer's data folder contents if os.path.exists(printer.config_path): config = Config(FakeArgs(path=printer.config_path)) data_dir = config.daemon.data_dir # Delete PrusaLink files in the data directory ConfigComponent.delete_file( config.daemon.pid_file) ConfigComponent.delete_file( config.daemon.power_panic_file) ConfigComponent.delete_file( config.daemon.threshold_file) ConfigComponent.delete_folder( config.printer.directory) ConfigComponent.delete_file( config.printer.settings) # If the data directory is now empty, delete it if not os.listdir(data_dir): log.debug("Folder %s empty, deleting it too!", data_dir) os.rmdir(data_dir) # Delete the printer's configuration file ConfigComponent.delete_file( CONFIG_PATH_PATTERN.format(number=printer.number)) # Delete the printer's udev rule ConfigComponent.delete_file( RULE_PATH_PATTERN.format(number=printer.number)) # Delete the printer's multi_instance_config.ini entry multi_instance_config.printers.remove(printer) multi_instance_config.save() ConfigComponent.refresh_udev_rules() self.config_changed_signal.send() @staticmethod def refresh_udev_rules(): """Tells the udev system to load its rules again""" subprocess.run(['udevadm', 'control', '--reload'], check=True) subprocess.run(['udevadm', 'trigger', '-s', 'tty'], check=True) @staticmethod def delete_file(path): """Deletes a file, catching exceptions""" try: os.remove(path) log.debug("Deleted %s", path) except Exception: # pylint: disable=broad-except log.exception("Error deleting %s", path) @staticmethod def delete_folder(path): """Deletes a folder, catching exceptions""" try: shutil.rmtree(path) log.debug("Deleted %s", path) except Exception: # pylint: disable=broad-except log.exception("Error deleting %s", path) @staticmethod def wait_for_symlink(symlink_path): """Waits for a symlink to appear on the specified path""" time_started = monotonic() while not os.path.islink(symlink_path): sleep(0.5) log.debug("Waiting for symlink: %s", symlink_path) if monotonic() - time_started > UDEV_SYMLINK_TIMEOUT: raise TimeoutError("The expected printer symlinks " "didn't appear in tme") ================================================ FILE: prusa/link/multi_instance/const.py ================================================ """Contains constants used by the multi instance manager""" import os import re DEFAULT_UID = 1000 # Default user UID RUN_DIRECTORY = "/run/prusalink" MANAGER_PID_PATH = os.path.join(RUN_DIRECTORY, "manager.pid") SERVER_PID_PATH = os.path.join(RUN_DIRECTORY, "server.pid") # Named pipe for communication from not privileged to the privileged component UDEV_REFRESH_QUEUE_NAME = "/prusalink_mi_udev_refresh" WEB_REFRESH_QUEUE_NAME = "/prusalink_mi_web_refresh" WEB_COMMAND_QUEUE_NAME = "/prusalink_mi_web_cmd" # An udev rule to call a script that will tell us a printer has been connected CONNECTED_RULE_PATH = "/etc/udev/rules.d/99-prusalink-manager-trigger.rules" CONNECTED_RULE_PATTERN = \ 'SUBSYSTEM=="tty", ATTRS{{idVendor}}=="{vendor_id}", ' \ 'ATTRS{{idProduct}}=="{model_id}", ' \ 'RUN+="/bin/su {username} -c \\"{prepend}prusalink-manager rescan\\""' VALID_SN_REGEX = re.compile(r"^(?P^CZPX\d{4}X\d{3}X.\d{5})$") MULTI_INSTANCE_CONFIG_PATH = "/etc/prusalink/multi_instance.ini" PRINTER_NAME_PATTERN = "printer{printer_number}" PRINTER_FOLDER_NAME_PATTERN = "PrusaLink{number}" CONFIG_PATH_PATTERN = "/etc/prusalink/prusalink{number}.ini" DEV_PATH = "/dev/" PRINTER_SYMLINK_PATTERN = "ttyPRINTER{number}" RULE_PATH_PATTERN = "/etc/udev/rules.d/99-printer{number}.rules" RULE_PATTERN = 'SUBSYSTEM=="tty", ' \ 'ATTRS{{idVendor}}=="{vendor_id}", ' \ 'ATTRS{{idProduct}}=="{model_id}", ' \ 'ATTRS{{serial}}=="{serial_number}", ' \ 'SYMLINK+="{symlink_name}"' PRUSALINK_START_PATTERN = \ 'su {username} -c "{prepend}prusalink -i -c {config_path} start"' # How long to wait for the printer symlink to appear in devices UDEV_SYMLINK_TIMEOUT = 30 # seconds # The port of the main site # This plus one, so 8081 will be the port of the first PrusaLink instance PORT_RANGE_START = 8080 ================================================ FILE: prusa/link/multi_instance/controller.py ================================================ """A module implementing the controller of the PrusaLink Instance Manager""" import logging import os from .config_component import ConfigComponent, MultiInstanceConfig from .const import UDEV_REFRESH_QUEUE_NAME, WEB_REFRESH_QUEUE_NAME from .ipc_queue_adapter import IPCConsumer, IPCSender from .runner_component import RunnerComponent log = logging.getLogger(__name__) class Controller: """Glue between the multi instance components""" def __init__(self, user_info, prepend_executables_with): self.user_info = user_info self.multi_instance_config = MultiInstanceConfig() self.config_component = ConfigComponent( self.multi_instance_config, self.user_info, prepend_executables_with) self.runner_component = RunnerComponent( self.multi_instance_config, self.user_info, prepend_executables_with) self.ipc_consumer = IPCConsumer(UDEV_REFRESH_QUEUE_NAME, chown_uid=self.user_info.pw_uid, chown_gid=self.user_info.pw_gid) self.ipc_consumer.add_handler("rescan", self.rescan) self.config_component.config_changed_signal.connect( self.config_changed) def run(self): """Starts the controller""" self.runner_component.start_configured() self.ipc_consumer.start() self.config_component.setup_connected_trigger() self.ipc_consumer.ipc_queue_thread.join() log.info("Multi Instance Controller stopped") def rescan(self): """Handles the rescan notification by attempting to configure all not configured printers and starting instances for them""" log.debug("Rescanning printers") configured = self.config_component.configure_new() for printer in self.multi_instance_config.printers: if printer.serial_number not in configured: continue self.runner_component.load_instance(printer.config_path) def stop(self): """Stops the controller""" self.config_component.teardown_connected_trigger() self.ipc_consumer.stop() def remove_all_printers(self): """Removes all printers from the config""" self.config_component.remove_all_printers() def config_changed(self, *_): """A callback handler for when the config changes""" # Notify the web server that the config has changed IPCSender(WEB_REFRESH_QUEUE_NAME).send("refresh") # Try to prevent config corruption on unexpected shutdown os.sync() ================================================ FILE: prusa/link/multi_instance/ipc_queue_adapter.py ================================================ """A module implementing the IPC queue message consumer""" import logging import os import queue from threading import Thread from typing import Callable from ipcqueue import posixmq # type: ignore from ..const import QUIT_INTERVAL from ..util import prctl_name log = logging.getLogger(__name__) def get_queue_path(queue_name): """Returns the path to a message queue with the given name""" # os path join needs the queue name without the leading slash if queue_name.startswith("/"): queue_name = queue_name[1:] return os.path.join("/dev/mqueue", queue_name) class IPCConsumer: """Class that sets up and consumes a message queue""" def __init__(self, queue_name, chown_uid=None, chown_gid=None): if not queue_name.startswith("/"): raise ValueError("Queue name must start with a slash") self.queue_name = queue_name self.queue_path = get_queue_path(queue_name) self.chown_uid = chown_uid if chown_uid is not None else os.getuid() self.chown_gid = chown_gid if chown_gid is not None else os.getgid() self.running = False self.ipc_queue = None self.command_handlers = {} self.ipc_queue_thread = Thread( target=self._read_commands, name="mi_cmd_reader") def add_handler(self, command: str, handler: Callable[[], None]): """Adds a handler for a text command""" # TODO: add support for args and kwargs self.command_handlers[command] = handler def start(self): """Starts the message queue consumer""" self.running = True self._setup_queue() self.ipc_queue_thread.start() def stop(self): """Stops the consumer""" self.running = False self.ipc_queue_thread.join() self.ipc_queue.unlink() def _setup_queue(self): """Creates the pipe and sets the correct permissions""" if os.path.exists(self.queue_path): os.remove(self.queue_path) # If this fails, we should exit, the queue # could contain malicious messages self.ipc_queue = posixmq.Queue(self.queue_name) os.chown(self.queue_path, uid=self.chown_uid, gid=self.chown_gid) def _read_commands(self): """Reads commands from the pipe and executes their handlers""" # pylint: disable=deprecated-method prctl_name() while self.running: try: message = self.ipc_queue.get(block=True, timeout=QUIT_INTERVAL) except queue.Empty: continue except posixmq.QueueError as exc: if exc.errno == posixmq.QueueError.INTERRUPTED: continue raise command, args, kwargs = message # pylint: disable=logging-too-many-args log.debug("read: '%s' from ipc queue '%s'", message, self.queue_name) try: if command in self.command_handlers: self.command_handlers[command](*args, **kwargs) else: log.debug("Unknown command for multi instance '%s'", command) except Exception: # pylint: disable=broad-except log.exception("Exception occurred while handling an IPC" " command") class IPCSender: """A class that allows for easy sending of messages to message consumers""" @staticmethod def send_and_close(queue_name, command, *args, **kwargs): """Sends a message to the specified queue, if it exists, then detaches from it""" ipc_sender = IPCSender(queue_name) ipc_sender.send(command, *args, **kwargs) ipc_sender.close() def __init__(self, queue_name): self.queue_name = queue_name self.queue_path = get_queue_path(queue_name) if not os.path.exists(self.queue_path): raise FileNotFoundError(f"The ipc queue named {self.queue_path} " f"does not exist") self.ipc_queue = posixmq.Queue(self.queue_name) def send(self, command, *args, **kwargs): """Sends a message to the queue""" message = (command, args, kwargs) while True: try: self.ipc_queue.put(message) except posixmq.QueueError as exc: if exc.errno == posixmq.QueueError.INTERRUPTED: continue raise # pylint: disable=logging-too-many-args log.debug("sent: '%s' to ipc queue '%s'", message, self.queue_name) break def close(self): """Detaches from the queue""" self.ipc_queue.close() def __del__(self): """Make sure the queue got closed on destruct""" try: self.close() except posixmq.QueueError: pass ================================================ FILE: prusa/link/multi_instance/runner_component.py ================================================ """The component that manages PrusaLink instances Sadly stopping cannot be handled here for readability reasons""" import logging import os import shlex import subprocess from pathlib import Path from threading import Thread from ..config import Config, FakeArgs from .const import PRUSALINK_START_PATTERN log = logging.getLogger(__name__) class LoadedInstance: """Keeps info about already running instances""" def __init__(self, config: Config, config_path: str): self.config = config self.config_path = config_path class RunnerComponent: """The component that handles starting instance""" def __init__(self, multi_instance_config, user_info, prepend_executables_with): self.multi_instance_config = multi_instance_config self.user_info = user_info self.prepend_executables_with = prepend_executables_with self.loaded = [] def start_configured(self): """Starts PrusaLink instances for configured printers in multiple threads""" threads = [] for printer in self.multi_instance_config.printers: threads.append( Thread(target=self.load_instance, name=printer.name, args=(printer.config_path,)), ) for thread in threads: thread.start() for thread in threads: thread.join() def load_instance(self, config_path: str): """Starts an instance and gives it the specified config in an argument""" for loaded in self.loaded: if config_path == loaded.config_path: return config = Config(FakeArgs(path=config_path)) pid_file = Path(config.daemon.data_dir, config.daemon.pid_file) try: os.remove(pid_file) except FileNotFoundError: pass start_command = PRUSALINK_START_PATTERN.format( prepend=self.prepend_executables_with, username=self.user_info.pw_name, config_path=config_path, ) log.debug(shlex.split(start_command)) subprocess.run(shlex.split(start_command), check=True, timeout=10, stdin=subprocess.DEVNULL, # DaemonContext needs stdout=subprocess.DEVNULL, # these to not be None stderr=subprocess.DEVNULL) self.loaded.append(LoadedInstance(config, config_path)) ================================================ FILE: prusa/link/multi_instance/web.py ================================================ """Init file for web application module.""" import logging from hashlib import sha256 from multiprocessing import Lock from time import monotonic from typing import Optional import urllib3 # type: ignore from poorwsgi import Application from poorwsgi.response import GeneratorResponse, JSONResponse from poorwsgi.state import METHOD_ALL from ..config import Config, FakeArgs from ..web import WebServer from ..web.errors import not_found from ..web.lib.core import STATIC_DIR from ..web.lib.view import generate_page from .config_component import MultiInstanceConfig from .const import WEB_REFRESH_QUEUE_NAME from .ipc_queue_adapter import IPCConsumer log = logging.getLogger(__name__) ADDRESS = "0.0.0.0" CHUNK_SIZE = 32 * 1024 # 32 kiB class InfoKeeper: """Keeps track of printers defined in the multi instance config file""" class PrinterInfo: """Holds the info crucial for the landing page""" def __init__(self, number, name, port): self.number = number self.name = name self.port = port def __init__(self): self._lock = Lock() self._refresh = True self.ipc_consumer = IPCConsumer(WEB_REFRESH_QUEUE_NAME) self.ipc_consumer.add_handler("refresh", self.refresh) self.ipc_consumer.start() self._printer_info = {} def refresh(self): """Causes the printer info to be refreshed on the next access""" self._refresh = True @property def printer_info(self): """Gets the current printer info, updates it if anything changes on disk""" with self._lock: if not self._refresh: return self._printer_info self._refresh = False multi_instance_config = MultiInstanceConfig() self._printer_info.clear() for printer in multi_instance_config.printers: config = Config(FakeArgs(path=printer.config_path)) self._printer_info[printer.number] = InfoKeeper.PrinterInfo( number=printer.number, name=printer.name, port=config.http.port, ) return self._printer_info class MultInstanceApp(Application): """WSGI application with info_keeper for the multi instance manager""" info_keeper: Optional[InfoKeeper] = None app = MultInstanceApp("PrusaLink Multi Instance") app.keep_blank_values = 1 app.auto_form = False # only POST /api/files/ endpoints get HTML form app.auto_json = False app.auto_data = False app.auto_cookies = False app.secret_key = sha256(str(monotonic()).encode()).hexdigest() app.document_root = STATIC_DIR app.debug = True def single_instance_redirect(func): """Decorator that redirects to the single instance if there is only one printer configured""" def wrapper(req, *args, **kwargs): """Wrapper function""" if len(req.app.info_keeper.printer_info) == 1: first_printer = next(iter( req.app.info_keeper.printer_info.values())) return proxy(req, first_printer.number, req.path, use_proxy_headers=False) return func(req, *args, **kwargs) return wrapper def get_web_server(port): """Returns an instance of the instance manager web server""" app.info_keeper = InfoKeeper() log.info('Starting server for http://%s:%d', ADDRESS, port) web_server = WebServer(app, ADDRESS, port) return web_server @app.route('/') @single_instance_redirect def index(req): """The waypoint to point the user to a PrusaLink instance""" return generate_page(req, "multi-instance.html", printer_info=req.app.info_keeper.printer_info) @app.route('/api/list') def list_printers(req): """Get current S/N of the printer""" # pylint: disable=unused-argument response = [] for printer_number, printer in req.app.info_keeper.printer_info.items(): response.append( { "number": printer_number, "name": printer.name, "port": printer.port, }, ) return JSONResponse(printer_list=response) def get_content_length(headers): """Get content length from headers - 0 if not present""" raw_content_length = headers.get('Content-Length') if not raw_content_length: return None return int(raw_content_length) def file_data_generator(file_like, length): """Pass an object with a read method and its length and get a generator that yields chunks of the file's data.""" transferred = 0 while True: chunk_size = min(CHUNK_SIZE, length - transferred) if chunk_size == 0: break data = file_like.read(chunk_size) log.debug("Chunk-size: %s, Data: %s", chunk_size, data) yield data transferred += chunk_size @app.route(r'//', method=METHOD_ALL) @app.route(r'/', method=METHOD_ALL) def proxy(req, printer_number, path="/", use_proxy_headers=True): """A reverse proxy to pass requests to IP/number to IP:printer_port @param use_proxy_headers: When re-directing to a single instance, we re-use the whole uri path, no need for an extra prefix header""" if path.startswith("/"): path = path[1:] printer_info = req.app.info_keeper.printer_info printer = printer_info.get(int(printer_number)) if printer is not None: pool_manager = urllib3.PoolManager() proxied_headers = dict(req.headers) if use_proxy_headers: proxied_headers["X-Forwarded-Prefix"] = f"/{printer_number}" log.debug("Passing request for path %s", path) request_to_pass = req if (length := get_content_length(req.headers)) is not None: request_to_pass = file_data_generator(req, length) response = pool_manager.request( method=req.method, url=f"http://localhost:{printer.port}/{path}?{req.query}", headers=proxied_headers, preload_content=False, body=request_to_pass, redirect=False, ) log.debug("Response for path %s: %s", path, response.status) response_to_pass = response if (length := get_content_length(response.headers)) is not None: response_to_pass = file_data_generator(response, length) return GeneratorResponse( generator=response_to_pass, content_type=response.headers.get( 'Content-Type', "text/html; charset=utf-8"), status_code=response.status, headers=dict(response.headers)) return not_found(req) @app.default(METHOD_ALL) @single_instance_redirect def fallback(req): """If there's more or less than one printer configured, this is the 404 page""" return not_found(req) ================================================ FILE: prusa/link/printer_adapter/__init__.py ================================================ ================================================ FILE: prusa/link/printer_adapter/auto_telemetry.py ================================================ """Contains implementation of the ReportingEnsurer class""" from re import Match from time import time from ..const import REPORTING_TIMEOUT from ..serial.helpers import enqueue_instruction, wait_for_instruction from ..serial.serial_parser import ThreadedSerialParser from ..serial.serial_queue import SerialQueue from .model import Model from .structures.model_classes import Telemetry from .structures.regular_expressions import ( FAN_REGEX, HEATING_HOTEND_REGEX, HEATING_REGEX, POSITION_REGEX, TEMPERATURE_REGEX, ) from .telemetry_passer import TelemetryPasser from .updatable import ThreadedUpdatable class AutoTelemetry(ThreadedUpdatable): """ Monitors and parses autoreporting output, if any is missing, tries to turn the autoreporting back on """ thread_name = "temp_ensurer" update_interval = 10 def __init__(self, serial_parser: ThreadedSerialParser, serial_queue: SerialQueue, model: Model, telemetry_passer: TelemetryPasser): super().__init__() self.serial_parser = serial_parser self.serial_queue = serial_queue self.model: Model = model self.telemetry_passer = telemetry_passer self.serial_parser.add_decoupled_handler( TEMPERATURE_REGEX, self.temps_recorded) self.serial_parser.add_decoupled_handler( HEATING_REGEX, self.temps_recorded) self.serial_parser.add_decoupled_handler( HEATING_HOTEND_REGEX, self.temps_recorded) self.serial_parser.add_decoupled_handler( POSITION_REGEX, self.positions_recorded) self.serial_parser.add_decoupled_handler(FAN_REGEX, self.fans_recorded) self.last_seen_positions = 0. self.last_seen_fans = 0. self.last_seen_temps = 0. def temps_recorded(self, sender, match: Match): """ Reset the timeout for temperatures and write them through to the model """ assert sender is not None self.last_seen_temps = time() values = match.groupdict() telemetry = Telemetry(temp_nozzle=float(values["ntemp"])) if "btemp" in values: telemetry.temp_bed = float(values["btemp"]) if "set_ntemp" in values and "set_btemp" in values: telemetry.target_nozzle = float(values["set_ntemp"]) telemetry.target_bed = float(values["set_btemp"]) self.telemetry_passer.set_telemetry(telemetry) def positions_recorded(self, sender, match: Match): """ Reset the timeout for positions and write them through to the model """ assert sender is not None self.last_seen_positions = time() values = match.groupdict() self.telemetry_passer.set_telemetry( Telemetry(axis_x=float(values["x"]), axis_y=float(values["y"]), axis_z=float(values["z"]))) def fans_recorded(self, sender, match: Match): """ Reset the timeout for fans and write their RPMs through to the model """ assert sender is not None self.last_seen_fans = time() values = match.groupdict() self.telemetry_passer.set_telemetry( Telemetry(fan_extruder=int(values["hotend_rpm"]), fan_hotend=int(values["hotend_rpm"]), fan_print=int(values["print_rpm"]), target_fan_extruder=int(values["hotend_power"]), target_fan_hotend=int(values["hotend_power"]), target_fan_print=int(values["print_power"]))) def update(self): """ If any one of the report intervals is larger than REPORTING_TIMEOUT calls turn_reporting_on() """ refresh_times = (self.last_seen_temps, self.last_seen_positions, self.last_seen_fans) biggest_interval = time() - min(refresh_times) if biggest_interval > REPORTING_TIMEOUT: self.turn_reporting_on() def turn_reporting_on(self): """ Tries to turn reporting on using the M155 The C argument is the bitmask for type of autoreporting The S argument is the frequency of autoreports """ instruction = enqueue_instruction(self.serial_queue, "M155 S2 C7") wait_for_instruction(instruction, should_wait_evt=self.quit_evt) self._reset_last_seen() def proper_stop(self): """ Stops the autoreporting ensurer and tries to turn the auto-reporting off """ timeout_at = time() + 5 instruction = enqueue_instruction(self.serial_queue, "M155 S0 C0") wait_for_instruction(instruction, lambda: time() < timeout_at) super().stop() def _reset_last_seen(self): """Resets the last seen time of all tracked values""" self.last_seen_positions = time() self.last_seen_fans = time() self.last_seen_temps = time() ================================================ FILE: prusa/link/printer_adapter/command.py ================================================ """Contains implementation of the Command class""" import abc import logging import re from threading import Event from typing import Any, Dict from prusa.connect.printer.const import Source from ..sdk_augmentation.printer import MyPrinter from ..serial.helpers import ( enqueue_instruction, enqueue_matchable, wait_for_instruction, ) from ..serial.serial_adapter import SerialAdapter from ..serial.serial_parser import ThreadedSerialParser from ..serial.serial_queue import MonitoredSerialQueue from .file_printer import FilePrinter from .job import Job from .model import Model from .state_manager import StateManager log = logging.getLogger(__name__) class CommandFailed(Exception): """Exception class for signalling that a command has failed""" class NotStateToPrint(CommandFailed): """Exception class for signalling that printer is not in state to print""" class FileNotFound(CommandFailed): """A specific error for files that have not been found and the command failing because of that""" class Command: """Commands are like controllers, they do stuff and need a lot of info to do it. This class provides most of the components a command could want to access or use.""" # pylint: disable=too-many-instance-attributes command_name = "command" def __init__(self, command_id=None, source=Source.CONNECT) -> None: self.serial_queue: MonitoredSerialQueue = \ MonitoredSerialQueue.get_instance() self.serial_adapter: SerialAdapter = SerialAdapter.get_instance() self.serial_parser: ThreadedSerialParser = \ ThreadedSerialParser.get_instance() self.model: Model = Model.get_instance() self.printer: MyPrinter = MyPrinter.get_instance() self.state_manager: StateManager = StateManager.get_instance() self.file_printer: FilePrinter = FilePrinter.get_instance() self.job: Job = Job.get_instance() self.command_id = command_id self.source = source self.quit_evt = Event() def wait_while_running(self, instruction): """Wait until the instruction is done, or we quit""" wait_for_instruction(instruction, should_wait_evt=self.quit_evt) def do_instruction(self, message): """Shorthand for enqueueing and waiting for an instruction Enqueues everything to front as commands have a higher priority""" instruction = enqueue_instruction(self.serial_queue, message, to_front=True) self.wait_for_instruction(instruction) return instruction def do_matchable(self, message, regexp: re.Pattern): """Shorthand for enqueueing an waiting for a matchable instruction Enqueues everything to front as commands have a higher priority""" instruction = enqueue_matchable(self.serial_queue, message, regexp, to_front=True) self.wait_for_instruction(instruction) return instruction def wait_for_instruction(self, instruction): """Waits for instruction until it gets confirmed or we quit""" self.wait_while_running(instruction) if not instruction.is_confirmed(): raise CommandFailed("Command interrupted") def run_command(self) -> Dict[str, Any]: """Encapsulates the run command, provides default data for returning""" data = self._run_command() default_data = {"source": self.source} if data is not None: default_data.update(data) return default_data @abc.abstractmethod def _run_command(self): """Put implementation here""" def stop(self): """Stops the command""" self.quit_evt.set() ================================================ FILE: prusa/link/printer_adapter/command_handlers.py ================================================ """ Implements all command PrusaLink command handlers Start, pause, resume and stop print as well as one for executing arbitrary gcodes, resetting the printer and sending the job info """ import abc import json import logging import os from pathlib import Path from re import Match from subprocess import STDOUT, CalledProcessError, check_call, check_output from sys import executable from threading import Event from time import monotonic, time from typing import Dict, Optional, Set from prusa.connect.printer.const import Event as EventConst from prusa.connect.printer.const import Source, State from ..const import ( PRINTER_BOOT_WAIT, QUIT_INTERVAL, RESET_PIN, SERIAL_QUEUE_TIMEOUT, STATE_CHANGE_TIMEOUT, ) from ..serial.helpers import enqueue_instruction, enqueue_list_from_str from ..util import ( _parse_little_endian_uint32, file_is_on_sd, get_d3_code, round_to_five, ) from .command import Command, CommandFailed, FileNotFound, NotStateToPrint from .model import Model from .state_manager import StateChange from .structures.model_classes import EEPROMParams, JobState, PPData from .structures.regular_expressions import ( D3_OUTPUT_REGEX, OPEN_RESULT_REGEX, PRINTER_BOOT_REGEX, REJECTION_REGEX, RESET_ACTIVATED_REGEX, RESET_DEACTIVATED_REGEX, ) log = logging.getLogger(__name__) def check_update_prusalink(): """Run the bash script to check for PrusaLink updates and return output""" return check_output( [executable, '-m', 'pip', 'install', '--no-deps', '--dry-run', '-U', 'prusalink'], stderr=STDOUT).decode() def update_prusalink(): """Run the bash script to update PrusaLink and return output""" return check_output( [executable, '-m', 'pip', 'install', '-U', '--upgrade-strategy', 'only-if-needed', '--break-system-packages', 'prusalink'], stderr=STDOUT).decode() def change_reset_mode(model, serial_adapter, serial_parser, quit_evt, timeout=1, enable=True): """Used for enabling or disabling the reset signal propagation of the printer USB interface chip. DTR -> reset line""" # pylint: disable=too-many-arguments # The reset disabling is off - ignore the command if not model.serial_adapter.reset_disabling: return # Already set to the target state, return early if model.serial_adapter.resets_enabled == enable: return # Cannot disable resets from the gpio pins, give up early using_port = model.serial_adapter.using_port if using_port is None or using_port.is_rpi_port: return times_out_at = monotonic() + timeout event = Event() def waiter(sender, match): """Stops the wait for printer boot""" assert sender is not None assert match is not None event.set() confirm_regex = (RESET_ACTIVATED_REGEX if enable else RESET_DEACTIVATED_REGEX) serial_parser.add_decoupled_handler( confirm_regex, waiter) if enable: serial_adapter.enable_dtr_resets() else: serial_adapter.disable_dtr_resets() while not quit_evt.is_set() and monotonic() < times_out_at: if event.wait(QUIT_INTERVAL): break serial_parser.remove_handler(confirm_regex, waiter) if monotonic() > times_out_at: raise CommandFailed("Failed disabling USB DTR resets") model.serial_adapter.resets_enabled = enable class TryUntilState(Command): """A base for commands stop, pause and resume print""" command_name = "pause/stop/resume print" def __init__(self, command_id=None, source=Source.CONNECT): """ Sends a gcode in hopes of getting into a specific state. :param command_id: Which command asked for the state change :param source: Who asked us to change state """ super().__init__(command_id=command_id, source=source) self.right_state = Event() def _try_until_state(self, gcode: str, desired_states: Set[State]): """ Sends a gcode in hopes of reaching a desired_state. :param gcode: Which gcode to send. For example: "M603" :param desired_states: Into which state do we hope to get """ def state_changed(sender, from_state, to_state, *args, **kwargs): # --- pylint section --- """Reacts to every state change, if the desired state has been reached, stops the wait by setting an event""" assert sender is not None assert from_state is not None assert to_state is not None assert args is not None assert kwargs is not None # --- actual code --- if to_state in desired_states: self.right_state.set() if self.state_manager.get_state() not in desired_states: to_states = dict.fromkeys(desired_states, self.source) self.state_manager.expect_change( StateChange(command_id=self.command_id, to_states=to_states)) state_list = list(map(lambda item: item.name, desired_states)) state_names = ", ".join(state_list) log.debug("Trying to get to one of %s states.", state_names) self.state_manager.state_changed_signal.connect(state_changed) self.do_instruction(gcode) # Wait max n seconds for the desired state wait_until = time() + STATE_CHANGE_TIMEOUT succeeded = False # Crush an edge case where we already are in the desired state if self.model.state_manager.current_state in desired_states: self.right_state.set() while (not self.quit_evt.is_set() and time() < wait_until and not succeeded): succeeded = self.right_state.wait(QUIT_INTERVAL) self.state_manager.state_changed_signal.disconnect(state_changed) self.state_manager.stop_expecting_change() if not succeeded: log.debug("Could not get from %s to one of these: %s", self.state_manager.get_state(), desired_states) raise CommandFailed( f"Couldn't get to any of {state_names} states.") @abc.abstractmethod def _run_command(self): ... class StopPrint(TryUntilState): """Class for stopping a print""" command_name = "stop print" def _run_command(self): """ For serial prints, it first stops the flow of new commands using the file printer component, then it uses its parent to go through the stop sequence. """ if self.model.file_printer.printing: self.file_printer.stop_print() self._try_until_state(gcode="M603", desired_states={ State.STOPPED, State.IDLE, State.READY, State.FINISHED, }) class PausePrint(TryUntilState): """Class for pausing a running print""" command_name = "pause print" def _run_command(self): """If a print is in progress, pauses it. When printing from serial, it pauses the file_printer, before telling the printer to do the pause sequence. """ if self.state_manager.get_state() != State.PRINTING: raise CommandFailed("Cannot pause when not printing.") if self.model.file_printer.printing: self.file_printer.pause() self._try_until_state(gcode="M601", desired_states={State.PAUSED}) class ResumePrint(TryUntilState): """Class for resuming a paused print""" command_name = "resume print" def _run_command(self): """ If the print is paused, it gets resumed. The file_printer component picks up on this by itself from the serial line, so no communication here is required """ if self.state_manager.get_state() != State.PAUSED: raise CommandFailed("Cannot resume when not paused.") self._try_until_state(gcode="M602", desired_states={State.PRINTING}) # If we were file printing, the module itself will recognize # it should resume from serial # if self.file_printer.printing: # self.file_printer.resume() class StartPrint(Command): """Class for starting a print from a given path""" command_name = "start print" def __init__(self, path: str, **kwargs): super().__init__(**kwargs) self.path_string = path def _run_command(self): """ Starts a print using a file path. If the file resides on the SD, it tells the printer to print it. If it's on the internal storage, the file_printer component will be used. :return: """ # No new print jobs while already printing # or when there is an Error/Attention state if self.model.state_manager.printing_state is not None: raise NotStateToPrint("Already printing") if self.model.state_manager.override_state is not None: raise NotStateToPrint( f"Cannot print in {self.state_manager.get_state()} state.") self.state_manager.expect_change( StateChange(to_states={State.PRINTING: self.source}, command_id=self.command_id)) path = Path(self.path_string) parts = path.parts if file_is_on_sd(parts): # Cut the first "/" and "SD Card" off sd_path = str(Path("/", *parts[2:])) try: short_path = self.model.sd_card.lfn_to_sfn_paths[sd_path] except KeyError: # If this failed, try to use the supplied path as is # in hopes it was the short path. short_path = sd_path self._load_file(short_path) self._start_print() else: if self.printer.fs.get(self.path_string) is None: raise FileNotFound( f"The file at {self.path_string} does not exist.") self._start_file_print(self.path_string) self.job.set_file_path(str(path), path_incomplete=False, prepend_sd_storage=False) self.state_manager.printing() self.state_manager.stop_expecting_change() def _start_file_print(self, path): """ Converts connect path to os path :param path: """ os_path = self.printer.fs.get_os_path(path) self.file_printer.print(os_path) def _load_file(self, raw_sd_path: str) -> None: """ Sends the gcod required to load the file from a given sd path :param raw_sd_path: The absolute sd path (starts with a "/") """ sd_path = raw_sd_path.lower() # FW requires lower case instruction = self.do_matchable(f"M23 {sd_path}", OPEN_RESULT_REGEX) match: Match = instruction.match() if not match or match.group("ok") is None: # Opening failed raise CommandFailed( f"Wrong file name, or bad file. File name: {sd_path}") def _start_print(self): """Sends a gcode to start the print of an already loaded file""" self.do_instruction("M24") class ExecuteGcode(Command): """Class for executing an arbitrary gcode or gcode list""" command_name = "execute_gcode" def __init__(self, gcode, force=False, **kwargs): """ If all checks pass, runs the specified gcode. :param gcode: "\n" separated gcodes to send to the printer"" :param force: Whether to skip state checks """ super().__init__(**kwargs) self.gcode = gcode self.force = force def _run_command(self): """ Sends the commands set if __init__ if all checks pass. Attributes the first state change to connect. Doesn't renew the expected state change, so the other state changes will fall back onto defaults """ if self.force: log.debug("Force sending gcode: '%s'", self.gcode) state = self.model.state_manager.current_state if not self.force: if state in {State.PRINTING, State.ATTENTION, State.ERROR}: raise CommandFailed( f"Can't run '{self.gcode}' while in f{state.name} state.") self.state_manager.expect_change( StateChange(command_id=self.command_id, default_source=self.source)) line_list = [] for line in self.gcode.split("\n"): if line.strip(): line_list.append(line.replace("\r", "")) # try running every line # Do this manually as it's the only place where a list # has to be enqueued instruction_list = enqueue_list_from_str(self.serial_queue, line_list, REJECTION_REGEX, to_front=True) for instruction in instruction_list: self.wait_while_running(instruction) if not instruction.is_confirmed(): raise CommandFailed("Command interrupted") match = instruction.match() if match: if match.group("unknown") is not None: raise CommandFailed(f"Unknown command '{self.gcode}')") if match.group("cold") is not None: raise CommandFailed("Cold extrusion prevented") # If the gcode execution did not cause a state change # stop expecting it self.state_manager.stop_expecting_change() @staticmethod def _get_state_change(default_source): return StateChange(default_source=default_source) class FilamentCommand(Command): """The shared code for Loading and Unloading of filament""" def __init__(self, parameters: Optional[Dict], **kwargs): super().__init__(**kwargs) self.parameters = parameters def prepare_for_load_unload(self): """ Check if the state allows for this operation Set temperatures for load/unload filament, wait only if it's colder Does not block, the assumption being that the command we're preheating for will wait for its completion """ state = self.model.state_manager.current_state if state in {State.PRINTING, State.ATTENTION, State.ERROR}: raise CommandFailed( f"Can't run {self.command_name} while in {state.name} state.") target_bed = self.parameters["bed_temperature"] target_print_temp = self.parameters["nozzle_temperature"] # Extrusion temperature = 90% of target nozzle temperature target_extrude_temp = round_to_five(target_print_temp * 0.9) # Heat up the bed enqueue_instruction(self.serial_queue, f"M140 S{target_bed}", to_front=True) # M109 is supposed to wait only for heating # when the S argument is given. Since it's broken, # let's check ourselves and skip waiting if we're hotter than required temp_nozzle = self.model.latest_telemetry.temp_nozzle if temp_nozzle is None or temp_nozzle < target_extrude_temp: enqueue_instruction(self.serial_queue, f"M109 S{target_extrude_temp}", to_front=True) enqueue_instruction(self.serial_queue, f"M104 S{target_print_temp}", to_front=True) @abc.abstractmethod def _run_command(self): ... class LoadFilament(FilamentCommand): """Class for load filament command""" command_name = "load_filament" def _run_command(self): """Load filament - see FilamentCommand""" # The load and unload have the same preheat self.prepare_for_load_unload() # A little workaround for M701 not actually supporting our use case enqueue_instruction(self.serial_queue, "M300 P500 S1", to_front=True) enqueue_instruction(self.serial_queue, "M0 Insert the filament", to_front=True) self.do_instruction("M701") class UnloadFilament(FilamentCommand): """Class for unload filament command""" command_name = "unload_filament" def _run_command(self): """Unload filament - see FilamentCommand""" # The load and unload have the same preheat self.prepare_for_load_unload() self.do_instruction("M702") class ResetPrinter(Command): """Class for resetting the printer""" command_name = "reset_printer" timeout = 30 if timeout < PRINTER_BOOT_WAIT or timeout < SERIAL_QUEUE_TIMEOUT: raise RuntimeError("Cannot have smaller timeout than what the printer " "needs to boot.") def _run_command(self): """ Checks whether we have pigpio available, if yes, uses the RESET_PIN, if not, uses USB DTR to reset the printer. Thanks @leptun. Waits until the printer boots and checks, if the printer wrote "start" as it shoul do on every boot. """ if RESET_PIN == 23: raise CommandFailed( "Pin BCM_23 is by default connected straight to " "ground. This would destroy your pin.") times_out_at = time() + self.timeout event = Event() def waiter(sender, match): """Stops the wait for printer boot""" assert sender is not None assert match is not None event.set() self.serial_parser.add_decoupled_handler(PRINTER_BOOT_REGEX, waiter) self.state_manager.expect_change( StateChange(default_source=self.source, command_id=self.command_id)) # Make sure the USB DTR resets are on try: change_reset_mode(self.model, self.serial_adapter, self.serial_parser, self.quit_evt, timeout=self.timeout, enable=True) except CommandFailed: # If we fail for whatever reason, try and reset the printer anyways pass self.serial_adapter.reset_client() while not self.quit_evt.is_set() and time() < times_out_at: if event.wait(QUIT_INTERVAL): break self.serial_parser.remove_handler(PRINTER_BOOT_REGEX, waiter) if time() > times_out_at: raise CommandFailed( "Your printer has ignored the reset signal, your RPi " "is broken or you have configured a wrong pin," "or our serial reading component broke..") class UpgradeLink(Command): """Class for upgrading PrusaLink""" command_name = "upgrade_link" def _run_command(self): try: output = update_prusalink() # No update available if "Installing collected packages" not in output: raise CommandFailed("No update available") # New version was installed correctly - restart PrusaLink check_call([executable, '-m', 'prusalink', 'restart']) log.info("PrusaLink upgraded successfully") # There's a problem with package installation, or it does not exist except CalledProcessError as exception: raise CommandFailed("There's a problem with package installation, " "or it does not exist") from exception class JobInfo(Command): """Class for sending/getting the job info""" command_name = "job_info" def _run_command(self): """Returns job_info from the job component""" if self.model.job.job_state == JobState.IDLE: raise CommandFailed( "Cannot get job info, when there is no job in progress.") if self.model.job.job_id is None: raise CommandFailed( "Cannot get job info, don't know the job id yet.") # Happens when launching into a paused print if self.model.job.selected_file_path is None: raise CommandFailed( "Cannot get job info, don't know the file details yet.") data = self.job.get_job_info_data( for_connect=self.command_id is not None) response = { "job_id": self.model.job.get_job_id_for_api(), "state": self.model.state_manager.current_state, "event": EventConst.JOB_INFO, "source": Source.CONNECT, "time_printing": self.model.latest_telemetry.time_printing, "time_remaining": self.model.latest_telemetry.time_remaining, "progress": self.model.latest_telemetry.progress, **data} log.debug("Job Info retrieved: %s", response) return response class SetReady(Command): """Class for setting the printer into READY""" command_name = "set_ready" def _run_command(self): """Sets the printer into ready, if it's IDLE""" if self.state_manager.get_state() not in {State.IDLE, State.READY}: raise CommandFailed( "Cannot get into READY from anywhere other than IDLE") self.state_manager.expect_change( StateChange(command_id=self.command_id, default_source=self.source)) self.state_manager.ready() self.state_manager.stop_expecting_change() self.do_instruction("M72 S1") class CancelReady(Command): """Class for setting the printer into READY""" command_name = "cancel_ready" def _run_command(self): """Cancels the READY state""" # Sets the LCD menu to reflect reality even if our state is not READY self.do_instruction("M72 S0") if self.model.state_manager.base_state != State.READY: raise CommandFailed("Cannot cancel READY when not actually ready.") self.state_manager.expect_change( StateChange(command_id=self.command_id, default_source=self.source)) self.state_manager.idle() self.state_manager.stop_expecting_change() class RePrint(StartPrint): """Class for starting the last job again""" command_name = "re-print" def __init__(self, **kwargs): # Need to get the model sooner than it's available in self model = Model.get_instance() path = model.job.last_job_path if path is None: path = "" super().__init__(path=path, **kwargs) def _run_command(self): """Re-prints the last job, makes a noise and sends an LCD message if that fails""" try: super()._run_command() except CommandFailed as exception: # Not an ideal way to do this, but less time-consuming enqueue_instruction(self.serial_queue, "M300 P200 S600") enqueue_instruction(self.serial_queue, "M117 \x7ECannot re-print") raise exception class DisableResets(Command): """Class for disabling printer USB DTR resets""" command_name = "disable_resets" timeout = 1 def _run_command(self): """Disables resets""" change_reset_mode(self.model, self.serial_adapter, self.serial_parser, self.quit_evt, timeout=self.timeout, enable=False) class EnableResets(Command): """Class for enabling printer USB DTR resets""" command_name = "enable_resets" timeout = 1 def _run_command(self): """Enables resets""" change_reset_mode(self.model, self.serial_adapter, self.serial_parser, self.quit_evt, timeout=self.timeout, enable=True) class PPRecovery(Command): """Class for recovering from the host power panic""" command_name = "pp_recovery" def _run_command(self): """Recovers from host power panic""" if self.model.file_printer.recovering: return try: if not self.file_printer.pp_exists: raise CommandFailed("No PP file exists, cannot recover.") d_code = get_d3_code(*EEPROMParams.EEPROM_FILE_POSITION.value) match = self.do_matchable(d_code, D3_OUTPUT_REGEX).match() if match is None: raise CommandFailed("Failed to get file position") line_number = _parse_little_endian_uint32(match) self.serial_queue.set_message_number(line_number) if not self.file_printer.pp_exists: log.warning("Cannot recover from power panic, " "no pp state found") raise RuntimeError("Cannot recover from power panic, " "no pp state found") with open(self.model.file_printer.pp_file_path, "r", encoding="UTF-8") as pp_file: pp_data = PPData(**json.load(pp_file)) gcode_number = (pp_data.gcode_number + (line_number - pp_data.message_number)) path = pp_data.file_path connect_path = pp_data.connect_path if not os.path.isfile(path): raise CommandFailed( "The file we were previously printing from has " "disappeared.") except CommandFailed as exception: enqueue_instruction( self.serial_queue, "M117 \x7ERecovery failed", to_front=True) enqueue_instruction( self.serial_queue, "M603", to_front=True) raise exception self.file_printer.print(path, gcode_number - 1) self.job.set_file_path(str(connect_path), path_incomplete=False, prepend_sd_storage=False) ================================================ FILE: prusa/link/printer_adapter/command_queue.py ================================================ """ Implements the CommandQueue with CommandAdapter class, the objects of withch are the queue members """ import logging from queue import Empty, Queue from threading import Event, RLock from typing import Any, Dict, Optional from ..const import QUIT_INTERVAL from ..util import prctl_name from .command import Command, CommandFailed from .telemetry_passer import TelemetryPasser from .updatable import Thread log = logging.getLogger(__name__) CommandResult = Dict[str, Any] class CommandAdapter: """Adapts the command class for processing in a queue""" # pylint: disable=too-few-public-methods def __init__(self, command) -> None: self.processed = Event() self.data: CommandResult = {} self.exception: Optional[Exception] = None self.command: Command = command class CommandQueue: """ Executes commands from queue in its own thread Prevents command racing """ def __init__(self) -> None: self.running = False self.command_queue: Queue[CommandAdapter] = Queue() self.current_command_adapter: Optional[CommandAdapter] = None self.runner_thread = Thread(target=self.process_queue, name="command_queue", daemon=True) self.enqueue_lock = RLock() def start(self) -> None: """Start the command processing""" self.running = True self.runner_thread.start() def stop(self) -> None: """Stop the command processing""" self.running = False self._stop_current() def enqueue_command(self, command: Command) -> CommandAdapter: """ Ask for a command to be processed :param command: The command to be processed """ with self.enqueue_lock: adapter = CommandAdapter(command) self.command_queue.put(adapter) return adapter def do_command(self, command: Command): """ Block until the command gets processed, pass what it returns :param command: The command to be processed """ TelemetryPasser.get_instance().activity_observed() if not self.running: log.warning("Don't wait for commands enqueued in a non-" "running command queue") adapter = self.enqueue_command(command) while self.running: if adapter.processed.wait(QUIT_INTERVAL): break if adapter.exception is not None: raise adapter.exception # pylint: disable=raising-bad-type if not adapter.processed.is_set(): log.warning("Unprocessed command %s!", adapter.command) raise CommandFailed("Command has not been processed because " "PrusaLink is stopping or in an error state") return adapter.data def force_command(self, command: Command): """Drops everything and does the supplied command""" with self.enqueue_lock: self.clear_queue() return self.do_command(command) def process_queue(self) -> None: """ Runs until stopped, processes commands in queue, writes outputs into a dict """ prctl_name() while self.running: try: adapter: CommandAdapter = self.command_queue.get( timeout=QUIT_INTERVAL) except Empty: continue try: self.current_command_adapter = adapter adapter.data = adapter.command.run_command() except Exception as exception: # pylint: disable=broad-except # Don't forget to pass exceptions as well as values adapter.exception = exception adapter.processed.set() def _stop_current(self): """Stops current command, if there is any""" if self.current_command_adapter is not None: self.current_command_adapter.command.stop() def clear_queue(self): """Clears the whole command queue""" with self.enqueue_lock: self._stop_current() while not self.command_queue.empty(): adapter = self.command_queue.get() adapter.command.stop() ================================================ FILE: prusa/link/printer_adapter/file_printer.py ================================================ """Contains implementation of the FilePrinter class""" import json import logging import os from collections import deque from threading import RLock from time import sleep from typing import Optional from blinker import Signal # type: ignore from ..config import Config from ..const import ( HISTORY_LENGTH, PRINT_QUEUE_SIZE, QUIT_INTERVAL, STATS_EVERY, TAIL_COMMANDS, ) from ..serial.helpers import enqueue_instruction, wait_for_instruction from ..serial.instruction import Instruction from ..serial.serial_parser import ThreadedSerialParser from ..serial.serial_queue import SerialQueue from ..util import get_clean_path, get_gcode, get_print_stats_gcode, prctl_name from .model import Model from .print_stats import PrintStats from .structures.mc_singleton import MCSingleton from .structures.model_classes import PPData from .structures.module_data_classes import FilePrinterData from .structures.regular_expressions import ( CANCEL_REGEX, RESUMED_REGEX, ) from .updatable import Thread log = logging.getLogger(__name__) class FilePrinter(metaclass=MCSingleton): """ Facilitates serial printing, its pausing, resuming and stopping as well, controls print_stats, which provide info about progress and time left for gcodes without said info """ # pylint: disable=too-many-arguments def __init__(self, serial_queue: SerialQueue, serial_parser: ThreadedSerialParser, model: Model, cfg: Config) -> None: self.print_stats = PrintStats(model) self.serial_queue = serial_queue self.serial_parser = serial_parser self.model = model self.new_print_started_signal = Signal() self.print_stopped_signal = Signal() self.print_finished_signal = Signal() self.time_printing_signal = Signal() self.byte_position_signal = Signal() # kwargs: current: int # total: int self.layer_trigger_signal = Signal() self.recovery_done_signal = Signal() self.lock = RLock() self.model.file_printer = FilePrinterData( printing=False, paused=False, recovering=False, was_stopped=False, power_panic=False, recovery_ready=False, file_path="", pp_file_path=get_clean_path(cfg.daemon.power_panic_file), enqueued=deque(), gcode_number=0) self.data = self.model.file_printer self.serial_parser.add_decoupled_handler( CANCEL_REGEX, lambda sender, match: self.stop_print()) self.serial_parser.add_decoupled_handler( RESUMED_REGEX, lambda sender, match: self.resume()) self.thread: Optional[Thread] = None def start(self) -> None: """Power panic is not yet implemented, sso this does nothing""" # self.check_failed_print() def stop(self) -> None: """Indicate to the printing thread to stop""" if self.data.printing: self.stop_print() def wait_stopped(self) -> None: """Wait for the printing thread to stop""" if self.thread is not None and self.thread.is_alive(): self.thread.join() @property def pp_exists(self) -> bool: """Checks whether a file created on power panic exists""" return os.path.exists(self.data.pp_file_path) def print(self, os_path: str, from_gcode_number=None) -> None: """Starts a file print for the supplied path""" if self.data.printing: raise RuntimeError("Cannot print two things at once") if from_gcode_number is None and self.pp_exists: os.remove(self.data.pp_file_path) self.data.file_path = os_path self.thread = Thread(target=self._print, name="file_print", args=(from_gcode_number,), daemon=True) self.data.printing = True self.data.recovering = from_gcode_number is not None self.data.was_stopped = False self.data.power_panic = False self.data.paused = False self.data.enqueued.clear() self.print_stats.start_time_segment() self.new_print_started_signal.send(self) self.print_stats.track_new_print(self.data.file_path, from_gcode_number) self.thread.start() def power_panic(self) -> None: """Handle the printer sending us a power panic signal This means halt the serial print, do not send any more instructions Do not delete the power panic file""" self.data.power_panic = True self.data.printing = False log.warning("Power panic!") def _print(self, from_gcode_number=None): """ Parses and sends the gcode commands from the file to serial. Supports pausing, resuming and stopping. param from_gcode_number: the gcode number to start from. Implies power panic recovery - goes into pause when the correct gcode number is reached """ history_accumulator = [] prctl_name() total_size = os.path.getsize(self.data.file_path) with open(self.data.file_path, "r", encoding='utf-8') as file: self.data.gcode_number = 0 self.data.enqueued.clear() if not self.data.recovering: # Reset the line counter, printing a new file self.serial_queue.reset_message_number() self.do_instruction("M75") # start printer's print timer while True: line = file.readline() # Recognise the end of the file if line == "" or not self.data.printing: break gcode = get_gcode(line) # Skip to the part we need to recover from if (self.data.recovering and from_gcode_number > self.data.gcode_number): if gcode: history_from = from_gcode_number - HISTORY_LENGTH if self.data.gcode_number >= history_from: history_accumulator.append(gcode) self.data.gcode_number += 1 continue # Skip finished, pause here, remove the recovering flag if self.data.recovering: history_accumulator.append(gcode) self.serial_queue.replenish_history(history_accumulator) self.pause() # This will make it PRINT_QUEUE_SIZE lines in front of what # is being sent to the printer, which is another as much as # 16 gcode commands in front of what's actually being printed. current_byte = file.tell() self.byte_position_signal.send(self, current=current_byte, total=total_size) if self.data.paused: self._print_pause() if not self.data.printing: break # Trigger cameras on layer change if ";LAYER_CHANGE" in line: self.layer_trigger_signal.send() if gcode: self.print_gcode(gcode) self.wait_for_queue() self.react_to_gcode(gcode) # Print ended self._print_end() def _print_pause(self): """Handles the specific of a paused flie print""" log.debug("Pausing USB print") if self.data.recovering: self.data.recovery_ready = True else: # pause printer's print timer self.do_instruction("M76") while self.data.paused: sleep(QUIT_INTERVAL) if self.data.recovering: self.data.recovering = False self.data.recovery_ready = False self.recovery_done_signal.send() # If we ended the pause by a print stop, do not unpause the timer if self.data.printing: log.debug("Resuming USB print") self.do_instruction("M75") # resume printer's print timer def _print_end(self): """Handles the end of a file print""" self.data.enqueued.clear() self.print_stats.reset_stats() log.debug("Print ended") if self.data.power_panic: return os.remove(self.data.pp_file_path) self.do_instruction("M77") # stop printer's print timer self.data.printing = False if self.data.was_stopped: self.serial_queue.flush_print_queue() # Prevents the print head from stopping in the print enqueue_instruction(self.serial_queue, "M603", to_front=True) self.print_stopped_signal.send(self) else: self.print_finished_signal.send(self) def do_instruction(self, message): """Shorthand for enqueueing and waiting for an instruction Enqueues everything to front as commands have a higher priority""" instruction = enqueue_instruction(self.serial_queue, message, to_front=True) wait_for_instruction(instruction, lambda: self.data.printing) return instruction def print_gcode(self, gcode): """Sends a gcode to print, keeps a small buffer of gcodes and inlines print stats for files without them (estimated time left and progress)""" with self.lock: self.data.gcode_number += 1 divisible = self.data.gcode_number % STATS_EVERY == 0 if divisible: time_printing = int(self.print_stats.get_time_printing()) self.time_printing_signal.send( self, time_printing=time_printing) if self.to_print_stats(self.data.gcode_number): self.send_print_stats() log.debug("USB enqueuing gcode: %s", gcode) instruction = enqueue_instruction(self.serial_queue, gcode, to_front=True, to_checksum=True) self.data.enqueued.append(instruction) def wait_for_queue(self) -> None: """Gets rid of already confirmed messages and waits for any unconfirmed surplus""" # Pop all already confirmed instructions from the queue while self.data.enqueued: # ensure there is at least one item instruction = self.data.enqueued.popleft() if not instruction.is_confirmed(): self.data.enqueued.appendleft(instruction) break log.debug("Throwing out trash %s", instruction.message) # If there are more than allowed and yet unconfirmed messages # Wait for the surplus ones while len(self.data.enqueued) >= PRINT_QUEUE_SIZE: wait_for: Instruction = self.data.enqueued.popleft() wait_for_instruction(wait_for, lambda: self.data.printing) log.debug("%s confirmed", wait_for.message) def react_to_gcode(self, gcode): """ Some gcodes need to be reacted to right after they get enqueued in order to compensate for the file_printer gcode buffer For example M601 - Pause needs to pause the file read process as soon as it's sent :param gcode: gcode to react to """ if gcode.startswith("M601") or gcode.startswith("M25"): self.pause() def send_print_stats(self): """Sends a gcode to the printer, which tells it the progress percentage and estimated time left, the printer is expected to send back its standard print stats output for parsing in telemetry""" percent_done, time_remaining = self.print_stats.get_stats( self.data.gcode_number) # Idk what to do here, idk what would have happened if we used # the other mode, so let's report both modes the same stat_command = get_print_stats_gcode( normal_percent=percent_done, normal_left=time_remaining, quiet_percent=percent_done, quiet_left=time_remaining) instruction = enqueue_instruction(self.serial_queue, stat_command, to_front=True) self.data.enqueued.append(instruction) def to_print_stats(self, gcode_number): """ Decides whether to calculate and send print stats based on the file being printed having stats or not,v the gcode number divisibility, or just before the end of a file print """ divisible = gcode_number % STATS_EVERY == 0 do_stats = not self.model.print_stats.has_inbuilt_stats print_ending = ( gcode_number == self.model.print_stats.total_gcode_count - TAIL_COMMANDS) return do_stats and (divisible or print_ending) def pause(self): """Pauses the print by flipping a flag, pauses print timer""" if self.data.paused: return self.data.paused = True self.print_stats.end_time_segment() def resume(self): """ If paused, resumes the print by flipping a flag, resumes print timer """ # TODO: wrong, needs to be in line with the rest of commands if not self.data.printing: return if not self.data.paused: return self.data.paused = False self.print_stats.start_time_segment() def stop_print(self): """If printing, stops the print and indicates by a flag, that the print has been stopped and did not finish on its own""" # TODO: wrong, needs to be in line with the rest of commands if self.data.printing: self.data.was_stopped = True self.data.printing = False self.data.paused = False def write_file_stats(self, file_path, message_number, gcode_number): """Writes the data needed for power panic recovery""" data = PPData( file_path=file_path, connect_path=self.model.job.selected_file_path, message_number=message_number, gcode_number=gcode_number, using_rip_port=self.model.serial_adapter.using_port.is_rpi_port, ) with open(self.data.pp_file_path, "w", encoding="UTF-8") as pp_file: pp_file.write(json.dumps(data.dict())) os.fsync(pp_file) # make sure this gets written to storage def serial_message_number_changed(self, message_number): """Updates the pairing of the FW message number to gcode line number If all the instructions in the buffer are sent The message number belongs to the next instruction that will be sent Here's an illustration of the situation _________________________________________ |enqueued |gcode_number|message_number| | | current=25 | current=100 | |___________|____________|______________| |next instr.| 26 | 102 | | I0 | *25* | 101 | | I1 | 24 | *100* | | I2 (sent) | 23 | 99 | | I3 (sent) | 22 | 98 | |___________|____________|______________| """ with self.lock: instruction_gcode_number = self.data.gcode_number + 1 for instruction in self.data.enqueued: if instruction.is_sent(): break instruction_gcode_number -= 1 self.write_file_stats(self.data.file_path, message_number, instruction_gcode_number) ================================================ FILE: prusa/link/printer_adapter/filesystem/__init__.py ================================================ ================================================ FILE: prusa/link/printer_adapter/filesystem/sd_card.py ================================================ """Contains implementation of the class for keeping track of the sd status and its files""" import calendar import logging import re from itertools import islice from pathlib import Path from threading import Lock from time import time from typing import Optional from blinker import Signal # type: ignore from prusa.connect.printer.const import State from ...const import ( MAX_FILENAME_LENGTH, SD_INTERVAL, SD_STORAGE_NAME, SFN_TO_LFN_EXTENSIONS, ) from ...sdk_augmentation.file import SDFile from ...serial.helpers import ( enqueue_list_from_str, enqueue_matchable, wait_for_instruction, ) from ...serial.serial_parser import ThreadedSerialParser from ...serial.serial_queue import SerialQueue from ...util import fat_datetime_to_tuple from ..model import Model from ..structures.model_classes import SDState from ..structures.module_data_classes import SDCardData from ..structures.regular_expressions import ( CONFIRMATION_REGEX, LFN_CAPTURE, SD_EJECTED_REGEX, SD_PRESENT_REGEX, ) from ..updatable import ThreadedUpdatable log = logging.getLogger(__name__) def alternative_filename(long_filename: str, short_filename: str, long_extension: Optional[str] = None): """ Ensures uniqueness of a file name by prepending it with its guaranteed to be unique short name """ new_filename = f"{short_filename} - ({long_filename})" if long_extension is not None: new_filename += f".{long_extension}" log.warning("Filename %s too long, using an alternative: %s", long_filename, new_filename) return new_filename def get_root(): """Gets the root node for sd card files""" return SDFile(name=SD_STORAGE_NAME, is_dir=True, read_only=True) class FileTreeParser: """ Parses the file tree from a printer supplied format """ def __init__(self, matches): self.matches = matches self.tree = get_root() self.current_dir = Path("/") self.lfn_to_sfn_paths = {} self.sfn_to_lfn_paths = {} self.mixed_to_lfn_paths = {} if not matches: return first_line_group = matches[0].group("begin") last_line_group = matches[-1].group("end") if first_line_group is None or last_line_group is None: log.warning("Captured unexpected output.") return # Captured can be three distinct lines. # Dir entry, dir exit, or a file listing. for match in islice(matches, 1, len(matches) - 1): groups = match.groupdict() if groups["dir_enter"] is not None: # Dir entry self.parse_dir(groups) elif groups["file"] is not None: # The list item self.parse_file(groups) elif groups["dir_exit"] is not None: # Dir exit self.current_dir = self.current_dir.parent def check_uniqueness(self, path: Path): """Checks, whether the supplied path is not present in the tree""" # Ignores the first "/" if self.tree.get(path.parts[1:]) is not None: log.error("Despite our efforts, there is a name conflict for %s", path) def parse_file(self, groups): """Parses the file listing using the _captured groups""" # pylint: disable=too-many-locals short_path_string = groups["sfn"].lower() if short_path_string[0] != "/": short_path_string = "/" + short_path_string short_filename = Path(short_path_string).name short_dir_path = Path(short_path_string).parent short_extension = groups["extension"] long_extension = SFN_TO_LFN_EXTENSIONS[short_extension] raw_long_filename = groups["lfn"] if raw_long_filename is None: return # --- Parse the long file name --- too_long = len(raw_long_filename) >= MAX_FILENAME_LENGTH if too_long: long_file_name = alternative_filename(raw_long_filename, short_filename, long_extension) else: long_file_name = raw_long_filename long_path = self.current_dir.joinpath(long_file_name) self.check_uniqueness(long_path) long_path_string = str(long_path) mixed_path = short_dir_path.joinpath(raw_long_filename) mixed_path_string = str(mixed_path).lower() # Add translation between the two log.debug("Adding translation between %s and %s", long_path_string, short_path_string) log.debug("Adding translation from %s to %s", mixed_path, long_path_string) self.lfn_to_sfn_paths[long_path_string] = short_path_string self.sfn_to_lfn_paths[short_path_string] = long_path_string self.mixed_to_lfn_paths[mixed_path_string] = long_path_string # --- parse additional properties --- additional_properties = {} str_size = groups["size"] if str_size is not None: additional_properties["size"] = int(str_size) str_m_time = groups["m_time"] if str_m_time is not None: m_time = fat_datetime_to_tuple(int(str_m_time, 16)) m_timestamp = calendar.timegm(m_time) additional_properties["m_timestamp"] = m_timestamp # Add the file to the tree try: self.tree.add_file(self.current_dir, long_file_name, short_filename, filename_too_long=too_long, **additional_properties) except FileNotFoundError as exception: log.exception(exception) def parse_dir(self, groups): """Parses the dir info using the _captured groups""" long_dir_name = groups["ldn"] short_dir_name = Path(groups["sdn"]).name # Sanitize the dir name too_long = len(long_dir_name) >= MAX_FILENAME_LENGTH if too_long: new_name = alternative_filename(long_dir_name, short_dir_name) self.current_dir = self.current_dir.joinpath(new_name) else: self.current_dir = self.current_dir.joinpath(long_dir_name) self.check_uniqueness(self.current_dir) # Add the dir to the tree try: self.tree.add_directory(self.current_dir.parent, self.current_dir.name, short_dir_name, filename_too_long=too_long) except FileNotFoundError as exception: log.exception(exception) class SDCard(ThreadedUpdatable): """ Keeps track of the SD Card presence and content The SD state can start only in the UNSURE state, we know nothing From there, we will ask the printer about the files present. If there are files, the SD card is present. If not, we still know nothing and need to ask the printer to re-init the card that provides the information about SD card presence Now that there's the SD ejection message, no more fortune-telling wizardry needs to be happening Unlikely now, was very likely before: The card removal could've gone unnoticed and the printer is telling us about an SD insertion. Let's tell connect the card got removed and go to the INITIALISING state """ thread_name = "sd_updater" # Cycle fast, but re-scan only on events or in big intervals update_interval = SD_INTERVAL def __init__(self, serial_queue: SerialQueue, serial_parser: ThreadedSerialParser, model: Model): self.tree_updated_signal = Signal() # kwargs: tree: FileTree self.state_changed_signal = Signal() # kwargs: sd_state: SDState self.sd_attached_signal = Signal() # kwargs: files: SDFile self.sd_detached_signal = Signal() self.menu_found_signal = Signal() # kwargs: menu_sfn: str self.serial_parser = serial_parser self.serial_parser.add_decoupled_handler(SD_PRESENT_REGEX, self.sd_inserted) self.serial_parser.add_decoupled_handler(SD_EJECTED_REGEX, self.sd_ejected) self.serial_queue: SerialQueue = serial_queue self.model = model self.model.sd_card = SDCardData(expecting_insertion=False, invalidated=True, last_updated=time(), last_checked_flash_air=time(), sd_state=SDState.UNSURE, files=None, lfn_to_sfn_paths={}, sfn_to_lfn_paths={}, mixed_to_lfn_paths={}, is_flash_air=False) self.data = self.model.sd_card self.lock = Lock() super().__init__() def handle_special_menu(self, file_tree_parser): """If the SD contains a special menu folder, add the menu items and inform others that the menu exists.""" if "PrusaLink menu" not in file_tree_parser.tree.children: return node = file_tree_parser.tree.children["PrusaLink menu"] if not node.is_dir: return menu_sfn = node.attrs["sfn"].lower() if "SETREADY.G" not in node.children: enqueue_list_from_str( self.serial_queue, [f"M28 {menu_sfn}/setready.g", "M84", "M29"], CONFIRMATION_REGEX, to_front=True) del file_tree_parser.tree.children["PrusaLink menu"] self.menu_found_signal.send(menu_sfn=menu_sfn) def update(self): """ Updates the file list on the SD Card. Except: - when the printer state is not IDLE - when we already have a file listing and no FlashAir is connected - When FlashAir is connected and configured, but it hasn't been long enough from the previous update """ # Update only if IDLE if self.model.state_manager.current_state != State.IDLE: return # since_last_update = time() - self.data.last_updated # due_for_update = since_last_update > SD_FILESCAN_INTERVAL # Do not update, if the tree wasn't invalidated. # Also, if there is no flash air, or if there is, but it wasn't long # enough from the last update if not self.data.invalidated: # or due_for_update and self.data.is_flash_air: return self.data.last_updated = time() self.data.invalidated = False if self.data.sd_state == SDState.ABSENT: return file_tree_parser = self._construct_file_tree() self.handle_special_menu(file_tree_parser) to_decide_presence = False with self.lock: self._set_files(file_tree_parser) if self.data.sd_state == SDState.UNSURE: # The files are of type SDFile - the root is always present # Check if it has any children -> files were found on the SD if self.data.files.children: self._sd_state_changed(SDState.PRESENT) else: # If we do not know the sd state and no files were found, # check the SD presence to_decide_presence = True if self.data.sd_state == SDState.INITIALISING: self._sd_state_changed(SDState.PRESENT) if to_decide_presence: self.decide_presence() self.tree_updated_signal.send(self, tree=self.data.files) def _set_files(self, file_tree_parser: FileTreeParser): """Sets the file variables according to the supplied parsing context""" assert self.lock.locked() self.data.files = file_tree_parser.tree # Try to be as atomic as possible self.data.lfn_to_sfn_paths = file_tree_parser.lfn_to_sfn_paths self.data.sfn_to_lfn_paths = file_tree_parser.sfn_to_lfn_paths # 8.3/8.3/LFN format to LFN/LFN/LFN self.data.mixed_to_lfn_paths = file_tree_parser.mixed_to_lfn_paths def set_flash_air(self, is_flash_air): """ Sets the value determining if flash air functionality should be on (temporary) """ self.data.is_flash_air = is_flash_air def _construct_file_tree(self) -> FileTreeParser: """ Uses M20 LT to get the list of paths. Some shorthand terms need explaining here: SFN - short file name LFN - long file name SDN - short directory name LDN - long directory name The readout is a little complicated as SDN paths are provided inline, but SDN -> LDN pairings are provided only when entering a directory The long file names over the size limit of 52 chars have a chance of not being unique, so this also ensures their uniqueness and fills in missing extensions :return: The constructed file tree. Also the translation data for converting between all used path formats get saved at the end """ instruction = enqueue_matchable(self.serial_queue, message="M20 LT", regexp=LFN_CAPTURE) wait_for_instruction(instruction, should_wait_evt=self.quit_evt) matches = instruction.get_matches() file_tree_parser = FileTreeParser(matches) return file_tree_parser def sd_inserted(self, sender, match: re.Match): """ If received while expecting it, stop expecting another one If received unexpectedly, this signalises someone physically inserting a card """ assert sender is not None # Using a multi-purpose regex, only interested in the first group if match.group("ok"): with self.lock: if self.data.expecting_insertion: self.data.expecting_insertion = False else: self.data.invalidated = True self._sd_state_changed(SDState.INITIALISING) def sd_ejected(self, sender, match: re.Match): """ Handler for sd ejected serial messages. Sets the card state to absent and notifies others """ assert sender is not None assert match is not None with self.lock: self.data.invalidated = True self._sd_state_changed(SDState.ABSENT) def _sd_state_changed(self, new_state): """ Transforms the internal state changes to Signals about sd card attaching/detaching. Also sets the internal state to the supplied one :param new_state: the state to switch to """ assert self.lock.locked() log.debug("SD state changed from %s to %s", self.data.sd_state, new_state) if self.data.sd_state in {SDState.INITIALISING, SDState.UNSURE} and \ new_state == SDState.PRESENT: log.debug("SD Card inserted") self.sd_attached_signal.send(self, files=self.data.files) elif self.data.sd_state == SDState.PRESENT and \ new_state in {SDState.ABSENT, SDState.INITIALISING}: log.debug("SD Card removed") self.sd_detached_signal.send(self) self._set_files(FileTreeParser(matches=[])) self.data.sd_state = new_state self.state_changed_signal.send(self, sd_state=self.data.sd_state) def decide_presence(self): """ Calling this can be disruptive to the user experience, the card will reload. If there is nothing on the SD card or if we suspect there is no SD card, calling this should be fine Asks the firmware to re-init the SD card, uses the output, to determine SD presence """ self.data.expecting_insertion = True instruction = enqueue_matchable(self.serial_queue, "M21", SD_PRESENT_REGEX) wait_for_instruction(instruction, should_wait_evt=self.quit_evt) self.data.expecting_insertion = False match = instruction.match() if match is not None: with self.lock: if match.group("ok") is not None: if self.data.sd_state == SDState.UNSURE: self._sd_state_changed(SDState.PRESENT) else: self._sd_state_changed(SDState.ABSENT) else: log.debug("Failed determining the SD presence.") ================================================ FILE: prusa/link/printer_adapter/filesystem/storage.py ================================================ """ Contains the implementation of Storage, FSStorage and FolderStorage for keeping track of Linux and folder storage. """ import abc import logging import os import select from typing import ClassVar, List, Set from blinker import Signal # type: ignore from ...config import Config from ...const import ( BLACKLISTED_NAMES, BLACKLISTED_PATHS, BLACKLISTED_TYPES, DIR_RESCAN_INTERVAL, QUIT_INTERVAL, ) from ...util import ensure_directory, get_clean_path from ..model import Model from ..structures.module_data_classes import StorageData from ..updatable import ThreadedUpdatable log = logging.getLogger(__name__) class Storage(ThreadedUpdatable): """ This module is the base for modules tracking attaching and detaching of storage """ paths_to_storage: ClassVar[List[str]] = [] def __init__(self, model: Model): super().__init__() self.model: Model = model self.attached_signal = Signal() # kwargs = path: str self.detached_signal = Signal() # kwargs = path: str self.data: StorageData = self.get_data_object() self.data.blacklisted_paths = self._get_clean_paths(BLACKLISTED_PATHS) self.data.blacklisted_names = BLACKLISTED_NAMES candidate_storage = self._get_clean_paths(self.paths_to_storage) # Cannot start with blacklisted paths finalist_storage = set( self.filter_blacklisted_paths(candidate_storage, self.data.blacklisted_paths)) # Cannot have a blacklisted name self.data.configured_storage = set( self.filter_blacklisted_names(finalist_storage, self.data.blacklisted_names)) log.debug("Configured mounpoints: %s", self.data.configured_storage) self.data.attached_set = set() def update(self): """ Synchronizes our data model with the OS, produces signals for storage we're interested in """ new_storage_set = self.get_storage() added, removed = self.get_differences(new_storage_set) for path in added: log.info("Newly attached %s", path) self.attached_signal.send(self, path=path) for path in removed: log.info("Detached %s", path) self.detached_signal.send(self, path=path) self.data.attached_set = new_storage_set @staticmethod def filter_blacklisted_paths(candidate_list, black_list): """Filter out anything that is inside of the blacklisted dirs""" filtered = [] for candidate in candidate_list: if not Storage.is_path_blacklisted(candidate, black_list): filtered.append(candidate) return filtered @staticmethod def filter_blacklisted_names(candidate_list, black_list): """Filter out anything that is inside of the blacklisted dirs""" filtered = [] for candidate in candidate_list: if not Storage.is_path_blacklisted(candidate, black_list): filtered.append(candidate) return filtered @staticmethod def is_path_blacklisted(candidate, black_list): """Returns the blacklist item that caused tha candidate to be flagged """ for blacklisted in black_list: if candidate.startswith(blacklisted): log.warning("Ignoring %s because it's blacklisted by %s", candidate, blacklisted) return True return False @staticmethod def is_name_blacklisted(candidate, black_list): """Returns the blacklist item that caused tha candidate to be flagged """ clean_candidate = candidate.strip("/").split("/")[-1] for blacklisted in black_list: if clean_candidate == blacklisted: log.warning("Ignoring %s because it's blacklisted by %s", clean_candidate, blacklisted) return True return False @staticmethod def _get_clean_paths(dirty_paths): """ Cleans a list of paths by converting them to Path objects and back """ return [get_clean_path(path) for path in dirty_paths] def get_differences(self, new_storage_set: Set[str]): """Retur the added and removed items from a given set""" removed = self.data.attached_set.difference(new_storage_set) added = new_storage_set.difference(self.data.attached_set) return added, removed @abc.abstractmethod def get_storage(self) -> Set[str]: """ The implementation is expected to return a set of valid storage based on its configuration """ @abc.abstractmethod def get_data_object(self) -> StorageData: """ There need to be two different object for the two different storage types. This method takes care of that """ class FilesystemStorage(Storage): """ Responsible for reporting which valid linux storage was attached """ thread_name = "filesystem_storage_thread" update_interval = 0 # The waiting is done in epoll timeout instead of here def __init__(self, model: Model, cfg: Config): FilesystemStorage.paths_to_storage = \ list(cfg.printer.storage) model.filesystem_storage = StorageData(blacklisted_paths=[], blacklisted_names=[], configured_storage=set(), attached_set=set()) # Call this after initializing the data super().__init__(model) # Force the update, even if no events are caught, we need to see # which things are attached, before beginning to only observe changes self.force_update = True # pylint: disable=consider-using-with self.mtab = open("/etc/mtab", "r", encoding='utf-8') self.epoll_obj = select.epoll(1) self.epoll_obj.register(self.mtab.fileno(), select.EPOLLOUT) def get_data_object(self) -> StorageData: return self.model.filesystem_storage def get_storage(self) -> Set[str]: """ Checks epoll for storage changes. if there are changes, gets a new storage list from mtab. If not, returns the current storage """ # Non-empty epoll result means something regarding storage has changed epoll_result = self.epoll_obj.poll(QUIT_INTERVAL) if epoll_result or self.force_update: self.force_update = False self.mtab.seek(0) new_storage_set: Set[str] = set() line_list = self.mtab.readlines() for line in line_list: _name, string_path, fs_type, *_ = line.split(" ") clean_path = get_clean_path(string_path) if self.storage_belongs(clean_path, fs_type): new_storage_set.add(clean_path) # If something changed, return the newly constructed dict return new_storage_set # Otherwise, return the same dict return self.data.attached_set def storage_belongs(self, path, fs_type): """Checks if we are interested in tracking a given storage""" is_wanted = str(path) in self.data.configured_storage type_valid = is_wanted and fs_type not in BLACKLISTED_TYPES return is_wanted and type_valid def stop(self): """Stops this component""" super().stop() self.mtab.close() class FolderStorage(Storage): """ Configured directories are reported as storage too, having the fs_type of "directory". """ def __init__(self, model: Model, cfg: Config): FolderStorage.paths_to_storage = [cfg.printer.directory] model.folder_storage = StorageData(blacklisted_paths=[], blacklisted_names=[], configured_storage=set(), attached_set=set()) # Call this after initializing the data super().__init__(model) for directory in self.data.configured_storage: ensure_directory(directory) thread_name = "folder_storage_thread" update_interval = DIR_RESCAN_INTERVAL def get_data_object(self) -> StorageData: """ There need to be two different object for the two different storage types. This method takes care of that """ return self.model.folder_storage def get_storage(self) -> Set[str]: new_directory_set: Set[str] = set() for directory in self.data.configured_storage: # try to create non-existing ones try: ensure_directory(directory) except OSError: log.exception("Cannot create a directory at %s", directory) if self.dir_belongs(directory): new_directory_set.add(directory) else: log.warning("Directory %s does not exist or isn't readable.", directory) return new_directory_set @staticmethod def dir_belongs(directory: str): """ Checks if we are interested in tracking a given directory storage """ exists = os.path.exists(directory) readable = exists and os.access(directory, os.R_OK) return exists and readable ================================================ FILE: prusa/link/printer_adapter/filesystem/storage_controller.py ================================================ """ Contains implementation of the controller for interfacing with the storage "subsystem", which included the linux filesystem and sd card file management, now only sd card and storage tracking remain """ import logging from typing import Optional from blinker import Signal # type: ignore from prusa.connect.printer.files import File from ...printer_adapter.model import Model from ...sdk_augmentation.file import SDFile from ...serial.serial_parser import ThreadedSerialParser from ...serial.serial_queue import SerialQueue from .sd_card import SDCard from .storage import FolderStorage # FilesystemStorage log = logging.getLogger(__name__) class StorageController: """ Sort of an interface layer between the (once larger) storage system and the rest of the app """ # pylint: disable=too-many-arguments def __init__(self, cfg, serial_queue: SerialQueue, serial_parser: ThreadedSerialParser, model: Model): self.folder_attached_signal = Signal() self.folder_detached_signal = Signal() self.sd_attached_signal = Signal() self.sd_detached_signal = Signal() self.menu_found_signal = Signal() self.serial_parser = serial_parser self.serial_queue: SerialQueue = serial_queue self.model = model self.sd_card = SDCard(self.serial_queue, self.serial_parser, self.model) self.sd_card.sd_attached_signal.connect(self.sd_attached) self.sd_card.sd_detached_signal.connect(self.sd_detached) self.sd_card.menu_found_signal.connect(self.menu_found) # self.filesystem_storage = FilesystemStorage(self.model, cfg) self.folder_storage = FolderStorage(self.model, cfg) # self.filesystem_storage.attached_signal.connect(self.folder_attached) # self.filesystem_storage.detached_signal.connect(self.folder_detached) self.folder_storage.attached_signal.connect(self.folder_attached) self.folder_storage.detached_signal.connect(self.folder_detached) self.sd_tree: Optional[SDFile] = None def folder_attached(self, sender, path: str): """Signal pass-through""" assert sender is not None self.folder_attached_signal.send(self, path=path) def folder_detached(self, sender, path: str): """Signal pass-through""" assert sender is not None self.folder_detached_signal.send(self, path=path) def sd_attached(self, sender, files: File): """Signal pass-through""" assert sender is not None self.sd_attached_signal.send(self, files=files) def sd_detached(self, sender): """Signal pass-through""" assert sender is not None self.sd_detached_signal.send(self) def menu_found(self, _, menu_sfn): """Secret menu has been found signal passthrough""" self.menu_found_signal.send(menu_sfn=menu_sfn) def update(self): """Passes the call to update() to all its submodules""" self.sd_card.update() # self.filesystem_storage.update() self.folder_storage.update() def start(self): """Starts submodules""" self.sd_card.start() # self.filesystem_storage.start() self.folder_storage.start() def stop(self): """Stops submodules""" self.sd_card.stop() # self.filesystem_storage.stop() self.folder_storage.stop() def wait_stopped(self): """SWait for storage submodules to quit""" self.sd_card.wait_stopped() # self.filesystem_storage.wait_stopped() self.folder_storage.wait_stopped() ================================================ FILE: prusa/link/printer_adapter/ip_updater.py ================================================ """Contains implementation of the IPUpdater class""" import logging import socket from time import time import pyric # type: ignore from blinker import Signal # type: ignore from prusa.connect.printer.conditions import CondState from pyric import pyw # type: ignore from pyric.pyw import Card # type: ignore from ..conditions import LAN from ..const import IP_UPDATE_INTERVAL, IP_WRITE_TIMEOUT from ..serial.helpers import enqueue_instruction, wait_for_instruction from ..serial.serial_queue import SerialQueue from ..util import get_local_ip, get_local_ip6 from .model import Model from .structures.module_data_classes import IPUpdaterData from .updatable import ThreadedUpdatable log = logging.getLogger(__name__) class IPUpdater(ThreadedUpdatable): """ Keeps track of what ip does the machine currently use when accessing the internet """ thread_name = "ip_updater" update_interval = IP_UPDATE_INTERVAL def __init__(self, model: Model, serial_queue: SerialQueue): self.serial_queue = serial_queue self.updated_signal = Signal() model.ip_updater = IPUpdaterData(local_ip=None, local_ip6=None, is_wireless=False, update_ip_on=time(), ssid=None, mac=None, hostname=None, username=None, digest=None) self.data = model.ip_updater self.first_update = True super().__init__() @staticmethod def get_mac(card): """ Pyric returns an error, but in that case, there probably is no mac to be gotten, so None is the most fitting value to send """ try: return pyw.macget(card) except pyric.error: return None def update_additional_info(self, ip): """Updates the mac address and info about the network being wireless """ if ip is None: return nics = pyw.interfaces() is_wireless = False mac = None ssid = None for nic in nics: try: # A hack to work around a block for non-wireless cards card = Card(None, nic, None) ips = pyw.ifaddrget(card) except pyric.error: continue if ip not in ips: continue mac = self.get_mac(card) is_wireless = pyw.iswireless(nic) if not is_wireless: continue card = pyw.getcard(nic) try: card_info = pyw.link(card) except pyric.error: continue else: if card_info is None: continue ssid_bytes = card_info["ssid"] ssid = ssid_bytes.decode() self.data.ssid = ssid self.data.is_wireless = is_wireless self.data.mac = mac self.data.hostname = socket.gethostname() def update(self): """ Gets the current local ip. Calls update_ip(), if it changed, or if it was over X seconds since the last update """ old_ip = self.data.local_ip old_ip6 = self.data.local_ip6 self.update_ip() self.update_ip6() LAN.state = CondState(self.data.local_ip is not None) if old_ip != self.data.local_ip or old_ip6 != self.data.local_ip6: self.update_additional_info(self.data.local_ip) self.updated_signal.send(self) self.first_update = False def update_ip(self): """ Only updates the IPv4 On ip change, sends the new one to the printer, so it can be displayed in the printer support menu. Generates a signal on ip change """ try: new_ip = get_local_ip() except socket.error: log.warning( "Failed getting the local IP, are we connected to LAN?") self.data.mac = None new_ip = None if self.data.local_ip != new_ip: log.debug( "Our IP has changed, or we reconnected. " "The new one is %s", new_ip) self.data.local_ip = new_ip self.send_ip_to_printer(new_ip) def update_ip6(self): """ Looks on what IPv6 we have and updates it if necessary """ try: new_ip6 = get_local_ip6() except socket.error: if self.data.local_ip6 is not None or self.first_update: log.debug("Failed getting the local IPv6") new_ip6 = None if new_ip6 is not None and new_ip6.startswith("fe80"): new_ip6 = None if self.data.local_ip6 != new_ip6: log.debug( "Our IPv6 has changed, or we reconnected. " "The new one is %s", new_ip6) self.data.local_ip6 = new_ip6 def send_ip_to_printer(self, ip_address=None, reset=False, timeout: float = IP_WRITE_TIMEOUT): """ Uses the M552 gcode, to set the ip for displaying in the printer support menu :param ip_address: the ip to send to the printer, if unfilled, use the current one :param reset: whether to reset the IP to blank even if other is known :param timeout: if supplied changes the timeout from the default IP_WRITE_TIMEOUT """ if ip_address is None: ip_address = self.data.local_ip if ip_address is None or reset: instruction = enqueue_instruction(self.serial_queue, "M552 P0.0.0.0") else: instruction = enqueue_instruction(self.serial_queue, f"M552 P{ip_address}") if timeout > 0: timeout_at = time() + timeout wait_for_instruction( instruction, lambda: not self.quit_evt.is_set() and time() < timeout_at) def proper_stop(self): """ Stops the ip updater and resets the IP shown in the support menu """ self.send_ip_to_printer(None, reset=True) super().stop() ================================================ FILE: prusa/link/printer_adapter/job.py ================================================ """Contains implementation of the Job class""" import logging import os import re from blinker import Signal # type: ignore from prusa.connect.printer import Printer from ..const import ( JOB_DESTROYING_STATES, JOB_ENDING_STATES, JOB_STARTING_STATES, SD_STORAGE_NAME, ) from ..serial.helpers import enqueue_instruction from ..serial.serial_queue import SerialQueue from .model import Model from .structures.mc_singleton import MCSingleton from .structures.model_classes import JobState from .structures.module_data_classes import JobData log = logging.getLogger(__name__) class Job(metaclass=MCSingleton): """Keeps track of print jobs and their properties""" # pylint: disable=too-many-arguments def __init__(self, serial_queue: SerialQueue, model: Model, printer: Printer): # Sent every time the job id should disappear, appear or update self.printer = printer self.serial_queue = serial_queue # Unused self.job_id_updated_signal = Signal() # kwargs: job_id: int self.job_info_updated_signal = Signal() self.model: Model = model self.model.job = JobData(already_sent=False, job_start_cmd_id=None, path_incomplete=True, from_sd=None, inbuilt_reporting=None, selected_file_path=None, selected_file_m_timestamp=None, selected_file_size=None, printing_file_byte=None, job_state=JobState.IDLE, job_id=None, job_id_offset=0, last_job_path=None) self.data = self.model.job self.job_id_updated_signal.send(self, job_id=self.data.get_job_id_for_api()) def file_opened(self, _, match: re.Match): """Handles the M23 output by extracting the mixed path and sends it for parsing""" if match is not None and match.group("sdn_lfn") != "": mixed_path = match.group("sdn_lfn") self.process_mixed_path(mixed_path) def process_mixed_path(self, mixed_path): """Takes the mixed path and tries translating it into the long format Sends the result to set_file_path :param mixed_path: the path in SDR_LFN format (short dir name, long file name)""" log.debug("Processing %s", mixed_path) if mixed_path.lower() in self.model.sd_card.mixed_to_lfn_paths: log.debug("It has been found in the SD card file tree") self.set_file_path( self.model.sd_card.mixed_to_lfn_paths[mixed_path.lower()], path_incomplete=False, prepend_sd_storage=True) else: log.debug("It has not been found in the SD card file tree.") self.set_file_path(mixed_path, path_incomplete=True, prepend_sd_storage=True) def job_started(self, command_id=None): """Reacts to a new job happening, increments job_id and fills out as much info as possible about the print job Also writes the new job_id to a file, so there aren't two jobs with the same id""" self.data.already_sent = False # Try to not increment the job id on PP recovery if not self.model.file_printer.recovering: if self.data.job_id is None: self.data.job_id_offset += 1 else: self.data.job_id += 1 self.data.job_start_cmd_id = command_id # If we don't print from sd, we know this immediately # If not, let's leave it None, it will get filled later if not self.data.from_sd: self.data.inbuilt_reporting = \ self.model.print_stats.has_inbuilt_stats self.change_state(JobState.IN_PROGRESS) self.write() self.update_last_job_path() log.debug("New job started, id = %s", self.data.job_id) self.job_id_updated_signal.send(self, job_id=self.data.get_job_id_for_api()) def job_ended(self): """Resets the job info """ self.data.already_sent = False self.data.job_start_cmd_id = None self.data.path_incomplete = True self.data.inbuilt_reporting = None self.change_state(JobState.IDLE) log.info("Job ended") self.job_id_updated_signal.send(self, job_id=self.data.get_job_id_for_api()) def state_changed(self, command_id=None): """Called before anything regarding state is sent""" to_state = self.model.state_manager.current_state if to_state in JOB_STARTING_STATES and \ self.data.job_state == JobState.IDLE: self.job_started(command_id) if to_state in JOB_ENDING_STATES and \ self.data.job_state is JobState.IN_PROGRESS: self.change_state(JobState.ENDING) if to_state in JOB_DESTROYING_STATES and \ self.data.job_state is JobState.IN_PROGRESS: self.job_ended() def tick(self): """Called after sending, if the job was ending, it ends now""" if self.data.job_state == JobState.ENDING: self.job_ended() def change_state(self, state: JobState): """ Previously wrote the state into a file, now only logs the state change """ log.debug("Job changed state to %s", state) self.data.job_state = state def write(self): """Writes_the job_id into the printer EEPROM""" # TODO: prime candidate for refactoring, it's awful # Cannot block if self.data.job_id is None: return enqueue_instruction(self.serial_queue, f"D3 Ax0D05 X{self.data.job_id:08x}", to_front=True) def set_file_path(self, path, path_incomplete, prepend_sd_storage): """Decides if the supplied file path is better, than what we had previously, and updates the job info file parameters accordingly :param path: the path/file name to assign to the job :param path_incomplete: flag for distinguishing between paths which could not be linked to an SD file and those which could :param prepend_sd_storage: Whether to prepend the SD Card storage name""" # If asked to, prepend the SD storage name if prepend_sd_storage: # Path joins don't work on paths with leading slashes if path.startswith("/"): path = path[1:] log.debug("prepending %s, result = %s", SD_STORAGE_NAME, os.path.join(f"/{SD_STORAGE_NAME}", path)) path = os.path.join(f"/{SD_STORAGE_NAME}", path) log.debug( "Processing a file path: %s, incomplete path=%s, " "already known path is incomplete=%s, job state=%s, " "known path=%s", path, path_incomplete, self.data.path_incomplete, self.data.job_state, self.data.selected_file_path) # If we have a full path, don't overwrite it with an incomplete one if path_incomplete and not self.data.path_incomplete: return log.debug("Overwriting file path with %s", path) self.data.selected_file_path = path self.data.path_incomplete = path_incomplete if not path_incomplete: file_obj = self.printer.fs.get(self.data.selected_file_path) if file_obj: if "m_timestamp" in file_obj.attrs: self.data.selected_file_m_timestamp = file_obj.attrs[ "m_timestamp"] if 'size' in file_obj.attrs: self.data.selected_file_size = file_obj.attrs["size"] self.model.job.from_sd = path.startswith( os.path.join("/", SD_STORAGE_NAME)) self.update_last_job_path() self.job_info_updated() def update_last_job_path(self): """Updates the last job path to be used for the re-print menu item""" if self.data.job_state != JobState.IN_PROGRESS: return self.data.last_job_path = self.data.selected_file_path def get_job_info_data(self, for_connect=False): """Compiles the job info data into a dict""" if for_connect: self.data.already_sent = True data = {} if self.data.path_incomplete: data["path_incomplete"] = self.data.path_incomplete if self.data.job_start_cmd_id is not None: data["start_cmd_id"] = self.data.job_start_cmd_id if self.data.selected_file_path is not None: data["path"] = self.data.selected_file_path if self.data.selected_file_m_timestamp is not None: data["m_timestamp"] = self.data.selected_file_m_timestamp if self.data.selected_file_size is not None: data["size"] = self.data.selected_file_size if self.data.from_sd is not None: data["from_sd"] = self.data.from_sd if self.printer.mbl is not None: data["mbl"] = self.printer.mbl return data def progress_broken(self, progress_broken): """Uses the info about whether the progress percentage reported by the printer is broken, to deduce, whether the gcode has inbuilt percentage reporting for sd prints.""" if self.data.from_sd: old_inbuilt_reporting = self.data.inbuilt_reporting if self.data.inbuilt_reporting is None and progress_broken: self.data.inbuilt_reporting = False elif not progress_broken: self.data.inbuilt_reporting = True if old_inbuilt_reporting != self.data.inbuilt_reporting: self.job_info_updated() def file_position(self, current, total): """Call to report a position in a file that's being printed :param current: The byte number being printed :param total: The file size""" self.data.printing_file_byte = current if self.data.selected_file_size is not None and \ self.data.selected_file_size != total: log.warning("Reported file sizes differ %s vs %s", self.data.selected_file_size, total) if self.data.selected_file_size is None: # In the future, this should be pointless, now it may get used self.data.selected_file_size = total self.job_info_updated() def job_info_updated(self): """If a job is in progress, a signal about an update will be sent""" # The same check as in the job info command, se we aren't trying # to send the job info, when it'll just fail instantly if self.data.job_state == JobState.IN_PROGRESS \ and self.data.selected_file_path is not None \ and self.data.already_sent \ and self.data.job_id is not None: self.job_info_updated_signal.send(self) def select_file(self, path): """For Octoprint API to select a file to print supply only existing file paths :param path: The connect path to a file, including the storage name""" if self.printer.fs.get(path) is None: raise RuntimeError(f"Cannot select a non existing file {path}") self.set_file_path(path, path_incomplete=False, prepend_sd_storage=False) def deselect_file(self): """For Octoprint API to deselect a file Only works when IDLE""" if self.data.job_state != JobState.IDLE: raise RuntimeError("Cannot deselect a file while printing it") self.data.selected_file_path = None self.model.job.from_sd = None def job_id_from_eeprom(self, job_id): """Sets the job id read from the printer EEPROM""" if self.data.job_id is not None: return self.data.job_id = job_id if self.data.job_id_offset > 0: self.data.job_id += self.data.job_id_offset self.data.job_id_offset = 0 self.write() self.job_info_updated() ================================================ FILE: prusa/link/printer_adapter/keepalive.py ================================================ """Contains the keepalive implementation""" from enum import Enum from threading import Event, Thread from time import monotonic from ..const import KEEPALIVE_INTERVAL from ..serial.helpers import enqueue_instruction, wait_for_instruction from ..serial.serial_queue import SerialQueue from .structures.mc_singleton import MCSingleton class KeepaliveMode(Enum): """The modes the keepalive can be in""" PL = "PL" # PrusaLink PC = "PC" # PrusaConnect class Keepalive(metaclass=MCSingleton): """Its job is to keep the PrusaLink printer mode on""" def __init__(self, serial_queue: SerialQueue): self.serial_queue: SerialQueue = serial_queue self.mode = KeepaliveMode.PL self.quit_evt = Event() self.wait_evt = Event() self.keepalive_thread: Thread = Thread(target=self._keepalive, name="Keepalive") self.last_keepalive = monotonic() - KEEPALIVE_INTERVAL def start(self): """Starts the module""" self.keepalive_thread.start() def set_use_connect(self, use_connect: bool): """Changes the mode of the keepalive""" self.mode = KeepaliveMode.PC if use_connect else KeepaliveMode.PL self.last_keepalive = monotonic() - KEEPALIVE_INTERVAL self.wait_evt.set() def _keepalive(self): """Keep sending out a signal, that PrusaLink is connected""" while not self.quit_evt.is_set(): instruction = enqueue_instruction( self.serial_queue, f"M79 S\"{self.mode.value}\"", to_front=True) self.last_keepalive = monotonic() wait_for_instruction(instruction, should_wait_evt=self.quit_evt) to_wait = self.last_keepalive + KEEPALIVE_INTERVAL - monotonic() if to_wait >= 0: self.wait_evt.wait(to_wait) if self.wait_evt.is_set(): self.wait_evt.clear() def stop(self): """Stops the keepalive sender""" self.quit_evt.set() self.wait_evt.set() def wait_stopped(self): """Waits for Keepalive thread to stop""" self.keepalive_thread.join() ================================================ FILE: prusa/link/printer_adapter/lcd_printer.py ================================================ """ Should inform the user about everything important in PrusaLink while nod obstructing anything else the printer wrote. """ import logging import math from functools import partial from pathlib import Path from queue import Queue from threading import Event from time import time from typing import Callable import unidecode from prusa.connect.printer import Printer from prusa.connect.printer.conditions import ( API, COND_TRACKER, HTTP, INTERNET, TOKEN, ) from prusa.connect.printer.const import State, TransferType from ..conditions import ( DEVICE, FW, ID, JOB_ID, LAN, NET_TRACKER, PHY, SN, UPGRADED, ) from ..config import Settings from ..const import ( FW_MESSAGE_TIMEOUT, PRINTING_STATES, QUIT_INTERVAL, SLEEP_SCREEN_TIMEOUT, ) from ..serial.helpers import enqueue_instruction, wait_for_instruction from ..serial.serial_queue import SerialQueue from ..util import prctl_name from .model import Model from .structures.carousel import Carousel, LCDLine, Screen from .structures.mc_singleton import MCSingleton from .structures.model_classes import JobState from .updatable import Thread log = logging.getLogger(__name__) WELCOME_CHIME = [ "M300 P100 S3200", "M300 P25 S0", "M300 P25 S4800", "M300 P75 S0", "M300 P25 S4800", ] ERROR_CHIME = ["M300 P600 S5"] UPLOAD_CHIME = ["M300 P14 S50"] ERROR_MESSAGES = { ID: "Unsupported printer", FW: "Err unsupported FW", SN: "Err obtaining S/N", UPGRADED: "Upgraded - re-reg.", JOB_ID: "Err reading job id", HTTP: "HTTP error 5xx", TOKEN: "Error bad token", # This needs updating, but currently there's nothing better to say API: "HTTP error 4xx", INTERNET: "No Internet access", LAN: "No LAN access", PHY: "No usable NIC", DEVICE: "No network hardware", } FROM_TRANSFER_TYPES = { TransferType.FROM_PRINTER, TransferType.FROM_WEB, TransferType.FROM_CONNECT, TransferType.FROM_CLIENT, TransferType.FROM_SLICER, } TO_TRANSFER_TYPES = {TransferType.TO_CONNECT, TransferType.TO_CLIENT} ERROR_GRACE = 15 RECOVERY_PRIORITY = 60 PRINT_PRIORITY = 50 WIZARD_PRIORITY = 40 ERROR_PRIORITY = 30 ERROR_WAIT_PRIORITY = 31 UPLOAD_PRIORITY = 20 READY_PRIORITY = 11 IDLE_PRIORITY = 10 NETWORK_ERROR_GRACE = 20 def through_queue(func): """A decorator to mke functions use the LCDPrinter event queue when called Prevents thread racing and notifies the CLDPrinter to check what to print next""" def wrapper(self, *args, **kwargs): func_with_args = partial(func, self, *args, **kwargs) self.add_event(func_with_args) return wrapper class LCDPrinter(metaclass=MCSingleton): """Reports PrusaLink status on the printer LCD whenever possible""" # pylint: disable=too-many-arguments def __init__(self, serial_queue: SerialQueue, model: Model, settings: Settings, printer: Printer, printer_number): self.serial_queue: SerialQueue = serial_queue self.model: Model = model self.settings: Settings = settings self.printer: Printer = printer self.printer_number = printer_number self.event_queue: Queue[Callable[[], None]] = Queue() self.quit_evt = Event() self.display_thread: Thread = Thread(target=self._lcd_printer, name="LCDPrinter") self.notiff_event = Event() self.error_screen = Screen(chime_gcode=ERROR_CHIME) self.upload_screen = Screen(chime_gcode=UPLOAD_CHIME) self.wizard_screen = Screen(chime_gcode=WELCOME_CHIME) self.print_screen = Screen(order=1) self.wait_screen = Screen(resets_idle=False) self.ready_screen = Screen(resets_idle=False) self.idle_screen = Screen(resets_idle=False) self.recovery_screen = Screen(resets_idle=False) self.carousel = Carousel([ self.print_screen, self.wizard_screen, self.wait_screen, self.error_screen, self.upload_screen, self.ready_screen, self.idle_screen, self.recovery_screen, ]) self.carousel.set_priority(self.print_screen, PRINT_PRIORITY) self.carousel.set_priority(self.wizard_screen, WIZARD_PRIORITY) self.carousel.set_priority(self.error_screen, ERROR_PRIORITY) self.carousel.set_priority(self.upload_screen, UPLOAD_PRIORITY) self.carousel.set_priority(self.ready_screen, READY_PRIORITY) self.carousel.set_priority(self.idle_screen, IDLE_PRIORITY) self.carousel.set_priority(self.recovery_screen, RECOVERY_PRIORITY) wait_zip = zip(["Please wait"] * 7, ["." * i for i in range(1, 8)]) wait_text = "".join(("".join(i).ljust(19) for i in wait_zip)) self.carousel.set_text(self.wait_screen, wait_text, scroll_delay=1.5, scroll_amount=19, first_line_extra=0, last_line_extra=0) self.carousel.set_text(self.ready_screen, "Ready to print", scroll_delay=5, first_line_extra=0, last_line_extra=0) self.carousel.set_text(self.recovery_screen, "Ready to recover", scroll_delay=5, first_line_extra=0, last_line_extra=0) # Need to implement this in state manager. Only problem is, it's driven # Cannot update itself. For now, this is the workaround self.ignore_errors_to = 0 self.reset_error_grace() # The error reporting for connection problems is too eager # and cannot be turned off. Let's put a rug over the intermittent # issues here. # pylint: disable=fixme # THIS HAS TO GO! FIXME!!!! self.network_error_at = None self.fw_msg_end_at = time() self.idle_from = time() # Used for ignoring LCD status updated that we generate self.ignore = 0 self.current_line = None def start(self): """Starts the module""" self.display_thread.start() def lcd_updated(self, sender, match): """ Gets called each time the firmware prints out "LCD status changed The ignore parameter counts how many messages have we sent, so we don't misrecognize our messages as FW printing something by itself """ assert sender is not None assert match is not None if self.ignore > 0: self.ignore -= 1 else: self._reset_idle() self.fw_msg_end_at = time() + FW_MESSAGE_TIMEOUT self.add_event(self.carousel.set_rewind) def _message_and_disable(self, screen: Screen, message): """If the screen is enabled, disable it, and print out a message""" if not self.carousel.is_enabled(screen): return self.carousel.add_message(LCDLine(message)) self.carousel.disable(screen) def whats_going_on(self): """Get a grip on the situation and set up the screens and carousel accordingly""" self._check_printing() self._check_errors() self._check_wizard() self._check_upload() self._check_ready() self._check_idle() self._check_recovery() def _check_printing(self): """Should a printing display be activated? And what should it say?""" if self.model.job.job_state == JobState.IN_PROGRESS and \ self.model.job.selected_file_path is not None: # We're printing! Display the file name self.carousel.enable(self.print_screen) filename = Path(self.model.job.selected_file_path).name # MK3 cannot print semicolons, replace them with an approximation safe_filename = filename.replace(";", ",:") rewinding = self.model.file_printer.recovering conditions = {"filename": safe_filename, "rewinding": rewinding} if self.print_screen.conditions != conditions: self.print_screen.conditions = conditions if rewinding: self.carousel.set_text( self.print_screen, "Preparing recovery") else: self.carousel.set_text(self.print_screen, safe_filename) else: self.carousel.disable(self.print_screen) def _filter_http(self, error): """Filter any network errors for the first X seconds""" if error is None: self.network_error_at = None if NET_TRACKER.is_tracked(error): # Silence the error until timeout if self.network_error_at is None: self.network_error_at = time() return None time_since_error = time() - self.network_error_at if time_since_error < NETWORK_ERROR_GRACE: return None return error def _check_errors(self): """Should an error display be activated? And what should it say?""" unfiltered_error = COND_TRACKER.get_worst() error_grace_ended = time() - self.ignore_errors_to > 0 error = self._filter_http(unfiltered_error) if error is not None and not error_grace_ended: self.carousel.enable(self.wait_screen) else: self._message_and_disable(self.wait_screen, "PrusaLink OK") if error is None: self._message_and_disable(self.error_screen, "Errors resolved") elif error not in ERROR_MESSAGES: self.carousel.disable(self.error_screen) elif error is not None and error_grace_ended: # An error has been discovered, tell the user what it is current_state = self.model.state_manager.current_state silence_because_network = ( NET_TRACKER.is_tracked(error) and not self.settings.printer.network_error_chime ) silence_because_printing = current_state == State.PRINTING self.carousel.enable( screen=self.error_screen, silent=silence_because_network or silence_because_printing, ) conditions = { "lan": LAN.state, "error": error, } if self.error_screen.conditions != conditions: self.error_screen.conditions = conditions # No scrolling errors, just a screen worth of explanations # and another one for the IP address text = ERROR_MESSAGES[error][:19].ljust(19) log.warning("Displaying an error message %s", text) ip = self.model.ip_updater.local_ip if ip is not None: text += f"see {ip}".ljust(19) self.carousel.set_text(self.error_screen, text, scroll_amount=19, last_line_extra=8) if self.model.job.job_state == JobState.IN_PROGRESS: self.carousel.set_priority(self.error_screen, 50) else: self.carousel.set_priority(self.error_screen, ERROR_PRIORITY) def _check_wizard(self): """Should a welcome display be shown? What should it say?""" wizard_needed = self.settings.is_wizard_needed() if wizard_needed and LAN: self.carousel.enable(self.wizard_screen) ip = self.model.ip_updater.local_ip conditions = { "lan": LAN.state, "wizard_needed": wizard_needed, "ip": ip, } if self.wizard_screen.conditions != conditions: self.wizard_screen.conditions = conditions local_ip = self.model.ip_updater.local_ip if self.printer_number is not None: text = f"{local_ip}/{self.printer_number}" else: # Can't have a capital G because old FW doesn't understand # What's a print command and what's not. It differentiated # between them using `"G" in command` condition text = f"Go: {local_ip}" self.carousel.set_text( self.wizard_screen, text, last_line_extra=10) else: self._message_and_disable(self.wizard_screen, "Setup completed") def _get_progress_graphic(self, progress, sync_type: TransferType): bar_length = 12 # Have 12 characters for the load bar, # increased to 14 by the arrow visibility # [Sync|->: 0% ] # [Sync|->:> 5% ] # [Sync|->:=====95%===>] # [Sync|->:====100%====] # index of 0 and 13 means a hidden arrow rough_index = progress / (100 / (bar_length + 2)) index = min(math.floor(rough_index), bar_length + 1) display_arrow = 0 < index < 13 progress_background = "=" * max(0, (index - 1)) if display_arrow: progress_background += ">" progress_background = progress_background.ljust(bar_length) # Put percentage over the background int_progress = round(progress) string_progress = f"{int_progress}%" centered_progress = string_progress.center(bar_length) centering_index = centered_progress.index(string_progress) progress_graphic = "Sync :" if sync_type in FROM_TRANSFER_TYPES: progress_graphic = "Sync\x7E|:" if sync_type in TO_TRANSFER_TYPES: progress_graphic = "Sync|\x7E:" progress_graphic += progress_background[:centering_index] progress_graphic += string_progress progress_graphic += progress_background[centering_index + len(string_progress):] return progress_graphic def _check_upload(self): """Should an upload display be visible? And what should it say?""" state = self.model.state_manager.current_state if state in PRINTING_STATES and state != State.PRINTING: self.carousel.set_priority(self.upload_screen, PRINT_PRIORITY + 10) else: self.carousel.set_priority(self.upload_screen, PRINT_PRIORITY) if self.printer.transfer.in_progress: self.carousel.enable(self.upload_screen) progress_graphic = self._get_progress_graphic( progress=self.printer.transfer.progress, sync_type=self.printer.transfer.type) self.carousel.set_text(self.upload_screen, progress_graphic, scroll_delay=0.5, last_line_extra=0, first_line_extra=0) elif self.carousel.is_enabled(self.upload_screen): transfer = self.printer.transfer finished = transfer.transferred == transfer.size if finished: self._message_and_disable(self.upload_screen, "Transfer finished") else: self._message_and_disable(self.upload_screen, "Transfer stopped") else: self.carousel.disable(self.upload_screen) def _check_ready(self): """Should the ready screen be shown?""" if self.model.state_manager.current_state == State.READY and LAN: self.carousel.enable(self.ready_screen) ip = self.model.ip_updater.local_ip conditions = {"ip": ip} if self.ready_screen.conditions != conditions: self.ready_screen.conditions = conditions self.carousel.set_text(self.ready_screen, "Ready to print".ljust(19) + f"{ip}".ljust(19), scroll_amount=19, scroll_delay=4, last_line_extra=5) else: self.carousel.disable(self.ready_screen) def _check_recovery(self): """Should the ready screen be shown?""" if self.model.file_printer.recovery_ready: self.carousel.enable(self.recovery_screen) else: self.carousel.disable(self.recovery_screen) def _check_idle(self): """Should the idle screen be shown? And what should it say?""" if time() - self.idle_from > SLEEP_SCREEN_TIMEOUT and LAN: self.carousel.enable(self.idle_screen) local_ip = self.model.ip_updater.local_ip if self.printer_number is not None: ip_text = f"{local_ip}/{self.printer_number}" else: ip_text = f"{local_ip}" speed = self.model.latest_telemetry.speed conditions = {"ip": local_ip, "speed": speed} if self.idle_screen.conditions != conditions: self.idle_screen.conditions = conditions if speed != 42: self.carousel.set_text(self.idle_screen, "PrusaLink OK.".ljust(19) + f"{ip_text}".ljust(19), scroll_amount=19, last_line_extra=12) else: self.carousel.set_text( self.idle_screen, "The Answer to the Great Question... Of Life, the " "Universe and Everything... Is... Forty-Two.", scroll_delay=0.3, scroll_amount=1, first_line_extra=2, last_line_extra=5) else: self.carousel.disable(self.idle_screen) def get_wait_interval(self): """How long to wait until the next line might want to be shown""" current_time = time() wait_for = QUIT_INTERVAL wait_for = max(wait_for, self.fw_msg_end_at - current_time) if self.current_line is not None: wait_for = max(wait_for, self.current_line.ends_at - current_time) return wait_for def should_advance_carousel(self): """Should we get a new line from the carousel?""" to_advance_carousel = True line = self.current_line if time() < self.fw_msg_end_at: to_advance_carousel = False elif line is not None and time() < line.ends_at: if not self.carousel.to_rewind: to_advance_carousel = False return to_advance_carousel def _lcd_printer(self): """This is the thread controlling what gets displayed""" prctl_name() self._print(LCDLine("PrusaLink started")) while not self.quit_evt.is_set(): self.notiff_event.wait(self.get_wait_interval()) self.notiff_event.clear() if not self.event_queue.empty(): handler = self.event_queue.get() handler() # Lets update our state self.whats_going_on() if self.should_advance_carousel(): self.current_line = self.carousel.get_next() # Get the line and send it to the printer if self.current_line is not None: self._print(self.current_line) def _print(self, line: LCDLine, to_wait=None): """ Sends the given message using M117 gcode and waits for its confirmation :param line: Text to be shown in the status portion of the printer LCD """ if line.resets_idle: self._reset_idle() ascii_text = unidecode.unidecode(line.text) self.ignore += 1 instruction = enqueue_instruction(self.serial_queue, f"M117 \x7E{ascii_text}", to_front=True) # Play a sound accompanying the newly shown thing if line.chime_gcode: for command in line.chime_gcode: enqueue_instruction(self.serial_queue, command) if to_wait is None: success = wait_for_instruction(instruction, should_wait_evt=self.quit_evt) else: success = wait_for_instruction(instruction, to_wait) if success: log.debug("Printed: '%s' on the LCD.", line.text) line.reset_end() def _reset_idle(self): """Reset the idle time form to the current time""" self.idle_from = time() def stop(self, fast=False): """ Stops the module, if not required to go fast, prints a goodbye message """ self.quit_evt.set() if not fast: time_out_at = time() + 5 self.wait_stopped() self._print(LCDLine("PrusaLink stopped"), lambda: time() < time_out_at) def wait_stopped(self): """Waits for LCD Printer to quit""" self.display_thread.join() def add_event(self, handler): """Adds a handler to the LCDPrinter event queue""" self.event_queue.put(handler) self.notify() def notify(self): """Wakes up the LCD printer, so it checks its state""" self.notiff_event.set() def reset_error_grace(self): """Resets the grace period for errors to clear""" self.ignore_errors_to = time() + ERROR_GRACE @through_queue def print_message(self, line: LCDLine): """Print a message at most 19 chars long""" self.carousel.add_message(line) ================================================ FILE: prusa/link/printer_adapter/mmu_observer.py ================================================ """Contains the mmu output observing code, that compiles the readouts into telemetry values""" from re import Match from blinker import Signal from prusa.connect.printer.const import Event, Source from ..const import MMU_ERROR_MAP, MMU_PROGRESS_MAP from ..sdk_augmentation.printer import MyPrinter from ..serial.serial_parser import ThreadedSerialParser from .model import Model from .structures.model_classes import Slot, Telemetry from .structures.module_data_classes import MMUObserverData from .structures.regular_expressions import ( MMU_PROGRESS_REGEX, MMU_Q0_REGEX, MMU_Q0_RESPONSE_REGEX, MMU_SLOT_REGEX, ) from .telemetry_passer import TelemetryPasser class MMUObserver: """The class that observes the MMU output and sends passes the info from it as telemetry""" def __init__(self, serial_parser: ThreadedSerialParser, model: Model, printer: MyPrinter, telemetry_passer: TelemetryPasser): self.serial_parser = serial_parser self.model = model self.model.mmu_observer = MMUObserverData(current_error_code=None) self.data = self.model.mmu_observer self.printer = printer self.telemetry_passer = telemetry_passer self.capture_q0 = False self.serial_parser.add_decoupled_handler( MMU_PROGRESS_REGEX, self._handle_mmu_progress) self.serial_parser.add_decoupled_handler( MMU_SLOT_REGEX, self._handle_active_slot) self.serial_parser.add_decoupled_handler( MMU_Q0_RESPONSE_REGEX, self._handle_q0_response) self.serial_parser.add_decoupled_handler( MMU_Q0_REGEX, self._prime_q0) self.error_changed_signal = Signal() self.telemetry_passer.set_telemetry( Telemetry( slot=Slot( active=0, ), ), ) def _prime_q0(self, _, match: Match) -> None: """Starts listening for the Q0 response""" assert match is not None self.capture_q0 = True def _handle_mmu_progress(self, _, match: Match): message = match.group("message") code = MMU_PROGRESS_MAP.get(message) self.telemetry_passer.set_telemetry( Telemetry( slot=Slot( state=code, ), ), ) def _handle_active_slot(self, _, match: Match): raw_active_slot = int(match.group("slot")) if raw_active_slot == 99: active_slot = 0 else: active_slot = raw_active_slot + 1 self.telemetry_passer.set_telemetry( Telemetry( slot=Slot( active=active_slot, ), ), ) def _handle_mmu_error(self, error_code): """Report an mmu error""" prusa_error_code = "04" + str(MMU_ERROR_MAP.get(error_code)) if self.data.current_error_code == prusa_error_code: return self.data.current_error_code = prusa_error_code self.printer.event_cb( Event.SLOT_EVENT, source=Source.SLOT, code=prusa_error_code, ) self.error_changed_signal.send() def _handle_mmu_no_error(self): """Clear the mmu error""" self.data.current_error_code = None self.error_changed_signal.send() def _handle_q0_response(self, _, match: Match): """Parse the mmu Q0 status response""" if not self.capture_q0: return self.capture_q0 = False command_code = match.group("command") progress_code = match.group("progress") # Is there a command in progress? If yes, send it if progress_code[0] in "PE": self.telemetry_passer.set_telemetry( Telemetry( slot=Slot( command=command_code, ), ), ) # Figure out if there's an error being reported if progress_code.startswith("E"): error_code = int(progress_code[1:], 16) self._handle_mmu_error(error_code) else: self._handle_mmu_no_error() ================================================ FILE: prusa/link/printer_adapter/model.py ================================================ """Contains implementation of the Model class""" from .structures.mc_singleton import MCSingleton from .structures.model_classes import Telemetry from .structures.module_data_classes import ( FilePrinterData, IPUpdaterData, JobData, MMUObserverData, PrintStatsData, SDCardData, SerialAdapterData, StateManagerData, StorageData, ) class Model(metaclass=MCSingleton): """ This class should collect every bit of info from all the informer classes Some values are reset upon reading, other, more state oriented should stay """ latest_telemetry: Telemetry = Telemetry() # Let's try and share inner module states for cooperation # The idea is, every module will get the model. # Every component HAS TO write its OWN INFO ONLY but can read # everything serial_adapter: SerialAdapterData file_printer: FilePrinterData print_stats: PrintStatsData state_manager: StateManagerData job: JobData ip_updater: IPUpdaterData sd_card: SDCardData folder_storage: StorageData filesystem_storage: StorageData mmu_observer: MMUObserverData def __init__(self) -> None: self.latest_telemetry: Telemetry = Telemetry() ================================================ FILE: prusa/link/printer_adapter/print_stat_doubler.py ================================================ """Implements the print stat line doubling""" import re from typing import List from ..serial.serial_parser import ThreadedSerialParser from .printer_polling import PrinterPolling from .structures.regular_expressions import ( CONFIRMATION_REGEX, PRINT_INFO_REGEX, ) class PrintStatDoubler: """ The print stats are coming automatically, as we read a line at a time, we lose the info of which one is valid and so cannot decide on which one to use. With this, we can handle both lines at the same time without heavily modifying the underlying serial communication layers """ def __init__(self, serial_parser: ThreadedSerialParser, printer_polling: PrinterPolling): self.printer_polling = printer_polling self.serial_parser = serial_parser self.matches: List[re.Match] = [] self.serial_parser.add_decoupled_handler( PRINT_INFO_REGEX, self.matched) self.serial_parser.add_decoupled_handler( CONFIRMATION_REGEX, self.reset) def reset(self, sender, match): """Resets the accumulated stat lines from the list""" assert sender is not None assert match is not None self.matches.clear() def matched(self, sender, match): """A print stat line was matched, add it to the list. If we have both, send them along to the handler""" assert sender is not None self.matches.append(match) if len(self.matches) >= 2: self.printer_polling.print_info_handler(self, self.matches) self.matches.clear() ================================================ FILE: prusa/link/printer_adapter/print_stats.py ================================================ """Contains implementation of the PrintStats class""" import logging from time import time from ..const import TAIL_COMMANDS from ..util import get_gcode from .model import Model from .structures.module_data_classes import PrintStatsData log = logging.getLogger(__name__) class PrintStats: """ For serial prints without inbuilt progress and estimated time left reporting, this component tries to estimate those values """ def __init__(self, model: Model): self.model = model self.model.print_stats = PrintStatsData( print_time=0, segment_start=time(), has_inbuilt_stats=False, total_gcode_count=0, ) self.data = self.model.print_stats def track_new_print(self, file_path, from_gcode_number=None): """ Analyzes the file, to determine whether it contains progress and time reporting :param file_path: path of the file to analyze :param from_gcode_number: the number of gcode already printed to account for pp recoveries """ self.reset_stats() self.data.start_gcode_number = from_gcode_number or 0 with open(file_path, encoding='utf-8') as gcode_file: for line in gcode_file: gcode = get_gcode(line) if gcode: self.data.total_gcode_count += 1 if "M73" in gcode: self.data.has_inbuilt_stats = True break log.info( "New file analyzed. It %s inbuilt percent and time reporting.", 'has' if self.data.has_inbuilt_stats else 'does not have') def reset_stats(self): """resets the tracked print stats""" self.data.total_gcode_count = 0 self.data.print_time = 0 self.data.has_inbuilt_stats = False def end_time_segment(self): """ Ends the current time segment and adds its length to the print time """ if self.data.segment_start is None: return self.data.print_time += time() - self.data.segment_start self.data.segment_start = None def start_time_segment(self): """ Starts a new time segment for the print time measuring """ self.data.segment_start = time() def get_stats(self, gcode_number): """ Based on which gcode are we now processing and how long is the print running, estimates the progress and time left :param gcode_number: the gcode number being printed :return tuple containing the percentage and the estimated minutes remaining """ self.end_time_segment() self.start_time_segment() gcode_number_after_pp = gcode_number - self.data.start_gcode_number time_per_command = self.data.print_time / gcode_number_after_pp total_gcodes_after_pp = (self.data.total_gcode_count - self.data.start_gcode_number) total_time = time_per_command * total_gcodes_after_pp sec_remaining = total_time - self.data.print_time min_remaining = round(sec_remaining / 60) log.debug("sec: %s, min: %s}, print_time: %s", sec_remaining, min_remaining, self.data.print_time) fraction_done = gcode_number / self.data.total_gcode_count percent_done = round(fraction_done * 100) log.debug("Print stats: %s%% done, %s", percent_done, min_remaining) if gcode_number == self.data.total_gcode_count - TAIL_COMMANDS: return 100, min_remaining return percent_done, min_remaining def get_time_printing(self): """Returns for how long was the print running""" return self.data.print_time + (time() - self.data.segment_start) ================================================ FILE: prusa/link/printer_adapter/printer_polling.py ================================================ """ Uses info updater to keep up with the printer info. Hope I can get most of printer polling to use this mechanism. """ import itertools import logging import re import struct from datetime import timedelta from typing import List from packaging.version import Version from prusa.connect.printer import Printer from prusa.connect.printer.conditions import CondState from ..conditions import FW, ID, JOB_ID, SN from ..const import ( FAST_POLL_INTERVAL, MINIMAL_FIRMWARE, MK25_PRINTERS, MMU3_TYPE_CODE, PRINT_MODE_ID_PAIRING, PRINT_STATE_PAIRING, PRINTER_TYPES, QUIT_INTERVAL, SLOW_POLL_INTERVAL, VERY_SLOW_POLL_INTERVAL, ) from ..serial.helpers import enqueue_matchable, wait_for_instruction from ..serial.serial_parser import ThreadedSerialParser from ..serial.serial_queue import SerialQueue from ..util import _parse_little_endian_uint32, get_d3_code, make_fingerprint from .filesystem.sd_card import SDCard from .job import Job from .model import Model from .structures.item_updater import ( ItemUpdater, SideEffectOnly, WatchedGroup, WatchedItem, ) from .structures.model_classes import ( EEPROMParams, NetworkInfo, PrintMode, Telemetry, ) from .structures.module_data_classes import Sheet from .structures.regular_expressions import ( D3_OUTPUT_REGEX, FW_REGEX, M27_OUTPUT_REGEX, MBL_REGEX, MMU_BUILD_REGEX, MMU_MAJOR_REGEX, MMU_MINOR_REGEX, MMU_REVISION_REGEX, NOZZLE_REGEX, PERCENT_REGEX, PRINT_INFO_REGEX, PRINTER_TYPE_REGEX, SN_REGEX, VALID_SN_REGEX, ) from .telemetry_passer import TelemetryPasser log = logging.getLogger(__name__) # pylint: disable=too-many-lines class InfoGroup(WatchedGroup): """A WatchedGroup with a flag for sending""" def __init__(self, *args, **kwargs): self.to_send = False super().__init__(*args, **kwargs) def mark_for_send(self): """Marks printer info for sending""" self.to_send = True # TODO: Don't like how parsing and result signal handling are mixed # instead, i would put the signal handling elsewhere # Also, having the external validators and whatnot seems unnecessarily complex # subclass WatchedItems and move them inside class PrinterPolling: """Sets up the tracked values for info_updater""" quit_interval = QUIT_INTERVAL # pylint: disable=too-many-statements, too-many-arguments def __init__(self, serial_queue: SerialQueue, serial_parser: ThreadedSerialParser, printer: Printer, model: Model, telemetry_passer: TelemetryPasser, job: Job, sd_card: SDCard) -> None: super().__init__() self.item_updater = ItemUpdater() self.serial_queue = serial_queue self.serial_parser = serial_parser self.printer = printer self.model = model self.telemetry_passer = telemetry_passer self.job = job self.sd_card = sd_card # Printer info (for init and SEND_INFO) self.network_info = WatchedItem("network_info", gather_function=self._get_network_info, write_function=self._set_network_info) self.printer_type = WatchedItem( "printer_type", gather_function=self._get_printer_type, write_function=self._set_printer_type, validation_function=self._validate_printer_type, interval=VERY_SLOW_POLL_INTERVAL, on_fail_interval=SLOW_POLL_INTERVAL) self.printer_type.became_valid_signal.connect( self._printer_type_became_valid) self.printer_type.val_err_timeout_signal.connect( lambda _: self._set_id_condition(CondState.NOK), weak=False) self.firmware_version = WatchedItem( "firmware_version", gather_function=self._get_firmware_version, write_function=self._set_firmware_version, validation_function=self._validate_fw_version) self.firmware_version.became_valid_signal.connect( self._firmware_version_became_valid) self.firmware_version.val_err_timeout_signal.connect( lambda _: self._set_fw_condition(CondState.NOK), weak=False) self.nozzle_diameter = WatchedItem( "nozzle_diameter", gather_function=self._get_nozzle_diameter, write_function=self._set_nozzle_diameter) self.nozzle_diameter.interval = 10 self.serial_number = WatchedItem( "serial_number", gather_function=self._get_serial_number, write_function=self._set_serial_number, validation_function=self._validate_serial_number) self.serial_number.timeout = 25 self.serial_number.became_valid_signal.connect( lambda _: self._set_sn_condition(CondState.OK), weak=False) self.serial_number.val_err_timeout_signal.connect( lambda _: self._set_sn_condition(CondState.NOK), weak=False) self.sheet_settings = WatchedItem( "sheet_settings", gather_function=self._get_sheet_settings, ) self.active_sheet = WatchedItem( "active_sheet", gather_function=self.get_active_sheet, ) self.mmu_connected = WatchedItem( "mmu_connected", ) self.mmu_connected.became_valid_signal.connect( self._mmu_connected_became_valid) self.mmu_version = WatchedItem( "mmu_version", gather_function=self._get_mmu_version, ) self.mmu_version.became_valid_signal.connect( self._printer_info_became_valid) self.printer_info = InfoGroup([ self.network_info, self.printer_type, self.firmware_version, self.nozzle_diameter, self.serial_number, self.sheet_settings, self.active_sheet, self.mmu_connected, ]) for item in self.printer_info: self.item_updater.add_item(item, start_tracking=False) self.item_updater.add_item(self.mmu_version, start_tracking=False) # TODO: Put this outside for item in self.printer_info: if item.name in {"active_sheet", "sheet_settings"}: continue item.value_changed_signal.connect( lambda value: self.printer_info.mark_for_send(), weak=False) self.printer_info.became_valid_signal.connect( self._printer_info_became_valid) # Other stuff self.job_id = WatchedItem( "job_id", gather_function=self._get_job_id, write_function=self._set_job_id, ) self.job_id.became_valid_signal.connect( lambda _: self._set_job_id_condition(CondState.OK), weak=False) self.job_id.val_err_timeout_signal.connect( lambda _: self._set_job_id_condition(CondState.NOK), weak=False) self.print_mode = WatchedItem( "print_mode", gather_function=self._get_print_mode, interval=SLOW_POLL_INTERVAL, ) self.mbl = WatchedItem( "mbl", gather_function=self._get_mbl, validation_function=self._validate_mbl, on_fail_interval=None, ) self.flash_air = WatchedItem( "flash_air", gather_function=self._get_flash_air, write_function=self._set_flash_air, validation_function=lambda value: isinstance(value, bool), ) self.other_stuff = WatchedGroup([ self.job_id, self.print_mode, self.mbl, self.flash_air]) for item in self.other_stuff: self.item_updater.add_item(item, start_tracking=False) self.item_updater.set_value(self.flash_air, False) # Make silent the default for when we fail to get the value in time self.item_updater.set_value(self.print_mode, PrintMode.SILENT) # Telemetry self.speed_multiplier = WatchedItem( "speed_multiplier", gather_function=self._get_speed_multiplier, write_function=self._set_speed_multiplier, validation_function=self._validate_percent, interval=FAST_POLL_INTERVAL) self.flow_multiplier = WatchedItem( "flow_multiplier", gather_function=self._get_flow_multiplier, write_function=self._set_flow_multiplier, validation_function=self._validate_percent, interval=FAST_POLL_INTERVAL) # Print info can be autoreported or polled # Only the progress gets an interval # Its gatherer sets all the other values manually while other # get set in cascade, converted from sooner acquired values self.print_progress = WatchedItem( "print_progress", gather_function=self._get_print_info, validation_function=self._validate_progress, write_function=self._set_print_progress, ) self.progress_broken = WatchedItem("progress_broken") self.print_progress.validation_error_signal.connect( lambda _: self.set_progress_broken(True), weak=False) self.print_progress.became_valid_signal.connect( lambda _: self.set_progress_broken(False), weak=False) self.time_remaining = WatchedItem( "time_remaining", validation_function=self._validate_time_till, write_function=self._set_time_remaining) self.time_broken = WatchedItem("time_broken") self.time_remaining.validation_error_signal.connect( lambda _: self.set_time_broken(True), weak=False) self.time_remaining.value_changed_signal.connect( lambda _: self.set_time_broken(False), weak=False) self.filament_change_in = WatchedItem( "filament_change_in", validation_function=self._validate_time_till, write_function=self._set_filament_change_in, on_fail_interval=None, ) self.filament_change_in.validation_error_signal.connect( lambda _: self.telemetry_passer.reset_value( ("filament_change_in",)), weak=False) self.inaccurate_estimates = WatchedItem("inaccurate_estimates") self.time_broken.value_changed_signal.connect( lambda _: self._infer_estimate_accuracy(), weak=False) self.speed_multiplier.value_changed_signal.connect( lambda _: self._infer_estimate_accuracy(), weak=False) self.inaccurate_estimates.value_changed_signal.connect( self._set_inaccurate_estimates, ) # M27 results # These are sometimes auto reported, but due to some technical # limitations, I'm not able to read them when auto reported self.print_state = WatchedItem("print_state", gather_function=self._get_m27, interval=FAST_POLL_INTERVAL, on_fail_interval=SLOW_POLL_INTERVAL) # short (8.3) folder names, long file name (52 chars) self.mixed_path = WatchedItem("mixed_path") self.byte_position = WatchedItem("byte_position") self.progress_from_bytes = WatchedItem( "progress_from_bytes", write_function=self._set_progress_from_bytes) self.byte_position.value_changed_signal.connect( self._get_progress_from_byte_position) self.sd_seconds_printing = WatchedItem( "sd_seconds_printing", write_function=self._set_sd_seconds_printing) self.time_remaining_guesstimate = WatchedItem( "time_remaining_guesstimate", write_function=self._set_time_remaining_guesstimate) self.byte_position.value_changed_signal.connect( self._guess_time_remaining) self.sd_seconds_printing.value_changed_signal.connect( self._guess_time_remaining) self.total_filament = WatchedItem( "total_filament", gather_function=self._get_total_filament, write_function=self._set_total_filament, on_fail_interval=SLOW_POLL_INTERVAL) self.total_print_time = WatchedItem( "total_print_time", gather_function=self._get_total_print_time, write_function=self._set_total_print_time, on_fail_interval=SLOW_POLL_INTERVAL) self.telemetry = WatchedGroup([ self.speed_multiplier, self.flow_multiplier, self.print_progress, self.time_remaining, self.filament_change_in, self.print_state, self.mixed_path, self.byte_position, self.progress_from_bytes, self.time_remaining_guesstimate, self.sd_seconds_printing, self.total_filament, self.total_print_time, self.progress_broken, self.time_broken, self.inaccurate_estimates, ]) for item in self.telemetry: self.item_updater.add_item(item, start_tracking=False) self.invalidate_printer_info() def start(self): """Starts the item updater""" self.item_updater.start() def stop(self): """Stops the item updater""" self.item_updater.stop() def wait_stopped(self): """Waits for the item updater to stop""" self.item_updater.wait_stopped() def invalidate_printer_info(self): """Invalidates all unnecessary watched items""" for item in itertools.chain(self.telemetry, self.other_stuff, self.printer_info): self.item_updater.disable(item) self.item_updater.disable(self.mmu_version) self.item_updater.enable(self.printer_type) def invalidate_network_info(self): """Invalidates just the network info""" self.item_updater.invalidate(self.network_info) def invalidate_serial_number(self): """Invalidates just the serial number""" self.item_updater.invalidate(self.serial_number) def invalidate_mbl(self): """Invalidates the mbl_data, so it will get updated.""" self.item_updater.invalidate(self.mbl) def invalidate_statistics(self): """Invalidates the statistics, so they get updated.""" self.item_updater.invalidate(self.total_filament) self.item_updater.invalidate(self.total_print_time) def schedule_printer_type_invalidation(self): """Marks printer_type gor gathering in X seconds""" self.item_updater.schedule_invalidation(self.printer_type, SLOW_POLL_INTERVAL) def _change_interval(self, item: WatchedItem, interval): """Changes the item interval and schedules depending on the new one""" item.interval = interval if interval is None: self.item_updater.cancel_scheduled_invalidation(item) else: self.item_updater.schedule_invalidation(item) def polling_not_ok(self): """Stops polling of some values""" self._change_interval(self.nozzle_diameter, None) self._change_interval(self.flow_multiplier, SLOW_POLL_INTERVAL) self._change_interval(self.speed_multiplier, SLOW_POLL_INTERVAL) self._change_interval(self.print_progress, SLOW_POLL_INTERVAL) self._change_interval(self.sheet_settings, None) self._change_interval(self.active_sheet, None) self._change_interval(self.flash_air, None) self._change_interval(self.printer_type, None) def polling_ok(self): """Re-starts polling of some values""" self._change_interval(self.nozzle_diameter, SLOW_POLL_INTERVAL) self._change_interval(self.flow_multiplier, FAST_POLL_INTERVAL) self._change_interval(self.speed_multiplier, FAST_POLL_INTERVAL) self._change_interval(self.print_progress, None) self._change_interval(self.sheet_settings, VERY_SLOW_POLL_INTERVAL) self._change_interval(self.active_sheet, SLOW_POLL_INTERVAL) self._change_interval(self.flash_air, VERY_SLOW_POLL_INTERVAL) self._change_interval(self.printer_type, VERY_SLOW_POLL_INTERVAL) def ensure_job_id(self): """This is an oddball, I don't have anything able to ensure the job_id stays in sync, I cannot wait for it, that would block the read thread I cannot just write it either, I wouldn't know if it failed.""" def job_became_valid(item): self.job_id.became_valid_signal.disconnect(job_became_valid) if self.model.job.job_id != item.value: log.warning( "Job id on the printer: %s differs from the local" " one: %s!", item.value, self.model.job.job_id) self.job.write() self.ensure_job_id() self.item_updater.schedule_invalidation(self.job_id, interval=1) self.job_id.became_valid_signal.connect(job_became_valid) # -- Gather -- def should_wait(self): """Gather helper returning if the component is still running""" return self.item_updater.running def do_matchable(self, gcode, regex, to_front=False, has_to_match=True): """Analog to the command one, as the getters do this over and over again""" instruction = enqueue_matchable(self.serial_queue, gcode, regex, to_front=to_front, has_to_match=has_to_match) wait_for_instruction(instruction, self.should_wait) match = instruction.match() if match is None: raise RuntimeError("Printer responded with something unexpected") return match def do_multimatch(self, gcode, regex, to_front=False): """Send an instruction with multiple lines as output""" instruction = enqueue_matchable( self.serial_queue, gcode, regex, to_front=to_front) wait_for_instruction(instruction, self.should_wait) matches = instruction.get_matches() if not matches: raise RuntimeError(f"There are no matches for {gcode}. " f"That is weird.") return matches def _get_network_info(self): """Gets the mac and ip addresses and packages them into an object.""" network_info = NetworkInfo() ip_data = self.model.ip_updater if ip_data.local_ip is not None: if ip_data.is_wireless: log.debug("WIFI - mac: %s", ip_data.mac) network_info.wifi_ipv4 = ip_data.local_ip network_info.wifi_ipv6 = ip_data.local_ip6 network_info.wifi_mac = ip_data.mac network_info.wifi_ssid = ip_data.ssid network_info.lan_ipv4 = None network_info.lan_ipv6 = None network_info.lan_mac = None else: log.debug("LAN - mac: %s", ip_data.mac) network_info.lan_ipv4 = ip_data.local_ip network_info.lan_ipv6 = ip_data.local_ip6 network_info.lan_mac = ip_data.mac network_info.wifi_ipv4 = None network_info.wifi_ipv6 = None network_info.wifi_mac = None network_info.wifi_ssid = None network_info.hostname = ip_data.hostname network_info.username = ip_data.username network_info.digest = ip_data.digest return network_info.dict() def _get_printer_type(self): """Gets the printer code using the M862.2 Q gcode.""" match = self.do_matchable("M862.2 Q", PRINTER_TYPE_REGEX, to_front=True) code = int(match.group("code")) mmu_connected = code == MMU3_TYPE_CODE self.item_updater.set_value(self.mmu_connected, mmu_connected) return code def _get_firmware_version(self): """Try to get firmware version from the printer.""" match = self.do_matchable("PRUSA Fir", FW_REGEX, to_front=True) return match.group("version") def _get_nozzle_diameter(self): """Gets the printers nozzle diameter using M862.1 Q""" match = self.do_matchable("M862.1 Q", NOZZLE_REGEX, to_front=True) return float(match.group("size")) def _get_serial_number(self): """Returns the SN regex match""" # If we're connected through USB and we know the SN, use that one serial_port = self.model.serial_adapter.using_port if serial_port is not None and serial_port.sn is not None: try: if self._validate_serial_number(serial_port.sn): return serial_port.sn except RuntimeError: pass # Do not ask MK2.5 for its SN, it would break serial communications if self.printer.type in MK25_PRINTERS | {None}: return "" match = self.do_matchable("PRUSA SN", SN_REGEX, to_front=True) return match.group("sn") def _get_sheet_settings(self) -> List[Sheet]: """Gets all the sheet settings from the EEPROM""" # TODO: How do we deal with default settings? matches = self.do_multimatch( get_d3_code(*EEPROMParams.SHEET_SETTINGS.value), D3_OUTPUT_REGEX, to_front=True) sheets: List[Sheet] = [] str_data = "" for match in matches: str_data += match.group("data").replace(" ", "") data = bytes.fromhex(str_data) for i in range(0, 8*11, 11): sheet_data = data[i:i+11] z_offset_u16 = struct.unpack("H", sheet_data[7:9])[0] max_uint16 = 2**16-1 if z_offset_u16 in {0, max_uint16}: z_offset_workaround = max_uint16 else: z_offset_workaround = z_offset_u16 - 1 z_offset = (z_offset_workaround-max_uint16)/400 sheets.append(Sheet( name=sheet_data[:7].decode("ascii"), z_offset=z_offset, bed_temp=struct.unpack("B", sheet_data[9:10])[0], pinda_temp=struct.unpack("B", sheet_data[10:11])[0], )) return sheets def get_active_sheet(self): """Gets the active sheet from the EEPROM""" matches = self.do_matchable( get_d3_code(*EEPROMParams.ACTIVE_SHEET.value), D3_OUTPUT_REGEX, to_front=True) str_data = matches.group("data").replace(" ", "") data = bytes.fromhex(str_data) active_sheet = struct.unpack("B", data)[0] return active_sheet def _get_mmu_version(self): """Gets the mmu_version""" major_match = self.do_matchable( "M707 A0x00", MMU_MAJOR_REGEX, has_to_match=False) minor_match = self.do_matchable( "M707 A0x01", MMU_MINOR_REGEX, has_to_match=False) revision_match = self.do_matchable( "M707 A0x02", MMU_REVISION_REGEX, has_to_match=False) build_match = self.do_matchable( "M707 A0x03", MMU_BUILD_REGEX, has_to_match=False) matches = [major_match, minor_match, revision_match, build_match] numbers = list(map(lambda match: str(int(match.group("number"), 16)), matches)) return ".".join(numbers[:-1]) + "+" + numbers[-1] def _get_job_id(self): """Gets the current job_id from the printer""" match = self.do_matchable( get_d3_code(*EEPROMParams.JOB_ID.value), D3_OUTPUT_REGEX, to_front=True) return int(match.group("data").replace(" ", ""), base=16) def _get_mbl(self): """Gets the current MBL data""" matches = self.do_multimatch("M420", MBL_REGEX, to_front=True) groups = matches[0].groupdict() data = {} if groups["no_mbl"] is None: num_x = int(groups["num_x"]) num_y = int(groups["num_y"]) data["shape"] = (num_x, num_y) data["data"] = [] for i, match in enumerate(matches): if i == 0: continue line = match.group("mbl_row") str_values = line.split() values = [float(val) for val in str_values] data["data"].extend(values) return data def _get_flash_air(self): """Determines if the Flash Air functionality is on""" match = self.do_matchable( get_d3_code(*EEPROMParams.FLASH_AIR.value), D3_OUTPUT_REGEX) return match.group("data") == "01" def _get_print_mode(self): """Gets the print mode from the printer""" match = self.do_matchable( get_d3_code(*EEPROMParams.PRINT_MODE.value), D3_OUTPUT_REGEX, to_front=True) index = int(match.group("data").replace(" ", ""), base=16) return PRINT_MODE_ID_PAIRING[index] def _get_speed_multiplier(self): match = self.do_matchable("M220", PERCENT_REGEX) return int(match.group("percent")) def _get_flow_multiplier(self): match = self.do_matchable("M221", PERCENT_REGEX) return int(match.group("percent")) def _get_print_info(self): """Polls the print info, but instead of returning it, it uses another method, that will eventually set it""" matches = self.do_multimatch("M73", PRINT_INFO_REGEX) self.print_info_handler(self, matches) raise SideEffectOnly() def _get_m27(self): """Polls M27, sets all values got from it manually, and returns its own""" matches = self.do_multimatch("M27 P", M27_OUTPUT_REGEX, to_front=True) if len(matches) >= 3: third_match = matches[2] self._parse_sd_seconds_printing(third_match.groupdict()) if len(matches) >= 2: second_match = matches[1] self._parse_byte_position(second_match.groupdict()) if len(matches) >= 1: first_match = matches[0] self._parse_mixed_path(first_match.groupdict()) return self._parse_print_state(first_match.groupdict()) raise RuntimeError("Failed to gather print info") @staticmethod def _parse_print_state(groups): """Parse a printer tracked state depending on which match group is present""" for group, state in PRINT_STATE_PAIRING.items(): if groups[group] is not None: return state return None def _parse_mixed_path(self, groups): """Here we get a printer print state and if printing a mixed length path of the file being printed from the SD card""" if groups["sdn_lfn"] is not None: self.item_updater.set_value(self.mixed_path, groups["sdn_lfn"]) def _parse_byte_position(self, groups): """Gets the byte position of the file being sd printed""" byte_position = (int(groups["current"]), int(groups["sum"])) self.item_updater.set_value(self.byte_position, byte_position) def _parse_sd_seconds_printing(self, groups): """Gets the time for which we've been printing already""" printing_time = timedelta(hours=int(groups["hours"]), minutes=int(groups["minutes"])) self.item_updater.set_value(self.sd_seconds_printing, printing_time.seconds) def _get_progress_from_byte_position(self, value): """Gets a progress value out of byte position""" current, total = value progress = int((current / total) * 100) self.item_updater.set_value(self.progress_from_bytes, progress) def _guess_time_remaining(self, _): """Tracking is nonexistant, guess a time_remaining value I'd just write out "On Friday" but people don't like that""" if not self.time_broken.value: return if not self.sd_seconds_printing.valid: return sd_seconds_printing = self.sd_seconds_printing.value if self.progress_broken.value: if not self.progress_from_bytes.valid: return progress = self.progress_from_bytes.value else: if not self.print_progress.valid: return progress = self.print_progress.value if progress == 0: return percent_remaining = 100 - progress multiplier = percent_remaining / progress guesstimation = sd_seconds_printing * multiplier self.item_updater.set_value(self.time_remaining_guesstimate, guesstimation) def print_info_handler(self, sender, matches: List[re.Match]): """One special handler supporting polling and spontaneous unsolicited reporting of progress and minutes remaining""" assert sender is not None class PrintInfo: """A shell for print stat data""" def __init__(self): self.valid = False self.progress = -1 self.remaining = -1 self.filament_change_in = -1 silent, normal = PrintInfo(), PrintInfo() for match in matches: groups = match.groupdict() info = PrintInfo() info.progress = int(groups["progress"]) # Convert both time values to seconds and adjust by print speed secs_remaining_unadjusted = int(groups["remaining"]) * 60 info.remaining = self._speed_adjust_time_value( secs_remaining_unadjusted) secs_change_in_unadjusted = int(groups["change_in"]) * 60 info.filament_change_in = self._speed_adjust_time_value( secs_change_in_unadjusted) try: info.valid = self._validate_progress(info.progress) except ValueError: pass if match.group("mode") == PrintMode.SILENT.value: silent = info elif match.group("mode") == PrintMode.NORMAL.value: normal = info use_normal = False if self.print_mode.value == PrintMode.NORMAL: if not normal.valid and silent.valid: log.warning("We are in normal mode but only silent print " "tracking info is valid. That's weird") else: use_normal = True elif not silent.valid: # The file must have been sliced in a semi-compatible slicer use_normal = True # Yes, this solution ignores MK25 auto mode. Sorry # Gladly reports even the wrong values # just to set off handlers that depend on the validation failing if use_normal: self.item_updater.set_value(self.print_progress, normal.progress) self.item_updater.set_value(self.time_remaining, normal.remaining) self.item_updater.set_value(self.filament_change_in, normal.filament_change_in) else: self.item_updater.set_value(self.print_progress, silent.progress) self.item_updater.set_value(self.time_remaining, silent.remaining) self.item_updater.set_value(self.filament_change_in, silent.filament_change_in) # -- From other watched items -- def _speed_adjust_time_value(self, value): """Multiplies tha value by the inverse of the speed multiplier""" if self.model.latest_telemetry.speed is not None: speed_multiplier = self.model.latest_telemetry.speed / 100 else: speed_multiplier = 1 inverse_speed_multiplier = 1 / speed_multiplier adjusted_value = int(value * inverse_speed_multiplier) log.debug("Secs without speed scaling %s, secs otherwise %s", value, adjusted_value) return adjusted_value def _eeprom_little_endian_uint32(self, dcode): """Reads and decodes the D-Code specified little-endian uint32_t eeprom variable""" match = self.do_matchable(dcode, D3_OUTPUT_REGEX, to_front=True) return _parse_little_endian_uint32(match) def _get_total_filament(self): """Gets the total filament used from the eeprom""" total_filament = self._eeprom_little_endian_uint32( get_d3_code(*EEPROMParams.TOTAL_FILAMENT.value)) return total_filament * 1000 def _get_total_print_time(self): """Gets the total print time from the eeprom""" total_minutes = self._eeprom_little_endian_uint32( get_d3_code(*EEPROMParams.TOTAL_PRINT_TIME.value)) return total_minutes * 60 # -- Validate -- def _validate_serial_number(self, value): """Validates the serial number, throws error because a more descriptive error message can be shown this way""" if VALID_SN_REGEX.match(value) is None: return False if self.printer.sn is not None and value != self.printer.sn: log.error("The new serial number is different from the old one!") raise RuntimeError(f"Serial numbers differ. Original: " f"{self.printer.sn} new one: {value}.") return True def _validate_printer_type(self, value): """Validates the printer type, throws error because a more descriptive error message can be shown this way""" if value not in PRINTER_TYPES: raise ValueError(f"The printer with type {value} is not supported") printer_type = PRINTER_TYPES[value] if self.printer.type is not None and printer_type != self.printer.type: log.error("The printer type changed while running.") raise RuntimeError(f"Printer type cannot change! Original: " f"{self.printer.type} current: {value}.") return True @staticmethod def _validate_fw_version(value): """Validates that the printer fw version is up to date enough""" without_buildnumber = value.split("-")[0] if Version(without_buildnumber) < MINIMAL_FIRMWARE: raise ValueError("The printer firmware is outdated") return True @staticmethod def _validate_mbl(value): """Validates the mesh bed leveling data""" num_x, num_y = value["shape"] number_of_points = num_x * num_y data = value["data"] if len(data) != number_of_points: raise ValueError(f"The mbl data matrix was reported to have " f"{num_x} x {num_y} values, but " f"{len(data)} were observed.") return True @staticmethod def _validate_percent(value): """Validates the speed multiplier as well as the flow rate""" if not 0 <= value <= 999: raise ValueError("The speed multiplier or flow rate is not " "between 0 and 999") return True @staticmethod def _validate_progress(value): """Validates progress""" if not 0 <= value <= 100: raise ValueError("The progress value is outside 0 and 100, this is" " usually a perfectly normal behaviour") return True @staticmethod def _validate_time_till(value): """Validates both time values because negative time till something is impossible""" if value < 0: raise ValueError("There cannot be negative time till something") return True # -- Write -- def _set_network_info(self, value): """Sets network info""" self.printer.network_info = value def _set_printer_type(self, value): """Do not try and overwrite the printer type, that would raise an error""" if self.printer.type is None: self.printer.type = PRINTER_TYPES[value] def _set_firmware_version(self, value): """It's a setter, what am I expected to write here? Sets the firmware version duh""" self.printer.firmware = value def _set_nozzle_diameter(self, value): """Sets the nozzle diameter""" self.printer.nozzle_diameter = value def _set_serial_number(self, value): """Set serial number and fingerprint""" if self.printer.sn is None: self.printer.sn = value self.printer.fingerprint = make_fingerprint(value) def _set_job_id(self, value): """Set the job id""" self.job.job_id_from_eeprom(value) def _set_flash_air(self, value): """Passes the flash air value to sd updater""" self.sd_card.set_flash_air(value) def _set_speed_multiplier(self, value): """Write the speed multiplier to model""" self.telemetry_passer.set_telemetry(Telemetry(speed=value)) def _set_flow_multiplier(self, value): """Write the flow multiplier to model""" self.telemetry_passer.set_telemetry(Telemetry(flow=value)) def _set_print_progress(self, value): """Write the progress""" self.telemetry_passer.set_telemetry(Telemetry(progress=value)) def _set_time_remaining(self, value): """Sets the time remaining adjusted for speed""" self.telemetry_passer.set_telemetry(Telemetry(time_remaining=value)) def _set_filament_change_in(self, value): """Write the filament change in""" self.telemetry_passer.set_telemetry( Telemetry(filament_change_in=value)) def _set_sd_seconds_printing(self, value): """sets the time we've been printing""" self.telemetry_passer.set_telemetry(Telemetry(time_printing=value)) def _set_progress_from_bytes(self, value): """Sets the progress gathered from the byte position, But only if it's broken in the printer""" if self.progress_broken.value: log.debug( "SD print has no inbuilt percentage tracking, " "falling back to getting progress from byte " "position in the file. " "Progress: %s%% Byte %s/%s", value, self.byte_position.value[0], self.byte_position.value[1]) self.telemetry_passer.set_telemetry(Telemetry(progress=value)) def _set_time_remaining_guesstimate(self, value): """Set the guesstimated time remaining if the real one's broken""" if self.time_broken.value: log.debug("SD print has no time remaining tracking. " "Guesstimating") self.telemetry_passer.set_telemetry( Telemetry(time_remaining=value)) def _set_total_filament(self, value): """Write the total filament used into the model""" self.telemetry_passer.set_telemetry(Telemetry(total_filament=value)) def _set_total_print_time(self, value): """Write the total print time into the model""" self.telemetry_passer.set_telemetry(Telemetry(total_print_time=value)) def _set_inaccurate_estimates(self, value): """Write whether out time estimates are inaccurate into the model""" self.telemetry_passer.set_telemetry( Telemetry(inaccurate_estimates=value)) # -- Signal handlers -- def set_progress_broken(self, value: bool): """Sets progress as being broken or functioning normally""" self.item_updater.set_value(self.progress_broken, value) def set_time_broken(self, value: bool): """Sets time_remaining as being broken or functioning normally""" self.item_updater.set_value(self.time_broken, value) @staticmethod def _set_sn_condition(state: CondState): """Needs to exist because we cannot assign in lambdas""" SN.state = state @staticmethod def _set_id_condition(state: CondState): """Needs to exist because we cannot assign in lambdas""" ID.state = state @staticmethod def _set_fw_condition(state: CondState): """Needs to exist because we cannot assign in lambdas""" FW.state = state @staticmethod def _set_job_id_condition(state: CondState): """Needs to exist because we cannot assign in lambdas""" JOB_ID.state = state def _printer_type_became_valid(self, _): """Printer type became valid, set the condition and enable the fw check""" self.item_updater.enable(self.firmware_version) self._set_id_condition(CondState.OK) def _firmware_version_became_valid(self, _): """Firmware version became valid, enable polling of the rest of the info""" for item in self.printer_info: self.item_updater.enable(item) self._set_fw_condition(CondState.OK) def _mmu_connected_became_valid(self, _): """MMU connected became valid, enable polling of its version""" if self.mmu_connected.value: self.item_updater.enable(self.mmu_version) else: self.item_updater.set_value(self.mmu_version, None) self.item_updater.disable(self.mmu_version) def _printer_info_became_valid(self, _): """Printer info became valid, we can start looking at telemetry and other stuff Also activated when the mmu version becomes valid This only works because the mmu_version cannot become valide unless the printer_info is valid already """ if self.mmu_connected.value: if not self.mmu_version.valid: return # We'll get here again when it becomes valid self._send_info_if_changed() for item in itertools.chain(self.telemetry, self.other_stuff): self.item_updater.enable(item) def _send_info_if_changed(self): """Sends printer info if a value change marked it for sending""" # This relies on update being called after became_valid_signal if self.printer_info.valid and self.printer_info.to_send: self.printer.event_cb(**self.printer.get_info()) self.printer_info.to_send = False def _infer_estimate_accuracy(self): """Looks at the current state of things and infers whether the time estimates are accurate or not""" if self.time_broken.value in {None, True}: self.item_updater.set_value(self.inaccurate_estimates, True) elif self.speed_multiplier.value != 100: self.item_updater.set_value(self.inaccurate_estimates, True) else: self.item_updater.set_value(self.inaccurate_estimates, False) ================================================ FILE: prusa/link/printer_adapter/prusa_link.py ================================================ """Implements the PrusaLink class""" import logging import os import re from enum import Enum from threading import Event from threading import enumerate as enumerate_threads from time import sleep from typing import Any, Dict, List, Optional, Type from prusa.connect.printer import Command as SDKCommand from prusa.connect.printer import DownloadMgr from prusa.connect.printer.camera_configurator import CameraConfigurator from prusa.connect.printer.camera_driver import CameraDriver from prusa.connect.printer.conditions import API, CondState from prusa.connect.printer.const import MMU_SLOT_COUNTS, MMUType, Source, State from prusa.connect.printer.const import Command as CommandType from prusa.connect.printer.const import Event as EventType from prusa.connect.printer.files import File from prusa.connect.printer.models import Sheet as SDKSheet from .. import __version__ from ..camera_governor import CameraGovernor from ..cameras.picamera_driver import PiCameraDriver from ..cameras.v4l2_driver import V4L2Driver from ..conditions import HW, ROOT_COND, UPGRADED, use_connect_errors from ..config import Config, Settings from ..const import ( BASE_STATES, MK25_PRINTERS, PATH_WAIT_TIMEOUT, PRINTER_CONF_TYPES, PRINTER_TYPES, PRINTING_STATES, SD_STORAGE_NAME, ) from ..interesting_logger import InterestingLogRotator from ..sdk_augmentation.printer import MyPrinter from ..serial.helpers import enqueue_instruction, enqueue_matchable from ..serial.serial import SerialException from ..serial.serial_adapter import SerialAdapter from ..serial.serial_parser import ThreadedSerialParser from ..serial.serial_queue import MonitoredSerialQueue from ..service_discovery import ServiceDiscovery from ..util import ( get_print_stats_gcode, is_potato_cpu, make_fingerprint, power_panic_delay, prctl_name, ) from .auto_telemetry import AutoTelemetry from .command_handlers import ( CancelReady, DisableResets, EnableResets, ExecuteGcode, JobInfo, LoadFilament, PausePrint, PPRecovery, RePrint, ResetPrinter, ResumePrint, SetReady, StartPrint, StopPrint, UnloadFilament, UpgradeLink, ) from .command_queue import CommandQueue, CommandResult from .file_printer import FilePrinter from .filesystem.sd_card import SDState from .filesystem.storage_controller import StorageController from .ip_updater import IPUpdater from .job import Job, JobState from .keepalive import Keepalive from .lcd_printer import LCDPrinter from .mmu_observer import MMUObserver from .model import Model from .print_stat_doubler import PrintStatDoubler from .printer_polling import PrinterPolling from .special_commands import SpecialCommands from .state_manager import StateChange, StateManager from .structures.item_updater import WatchedItem from .structures.model_classes import ( PrintState, Telemetry, ) from .structures.module_data_classes import Sheet from .structures.regular_expressions import ( LCD_UPDATE_REGEX, MBL_TRIGGER_REGEX, NOT_READY_REGEX, PAUSE_PRINT_REGEX, POWER_PANIC_REGEX, PP_AUTO_RECOVER_REGEX, PP_RECOVER_REGEX, PRINTER_BOOT_REGEX, READY_REGEX, REPRINT_REGEX, RESUME_PRINT_REGEX, TM_CAL_END_REGEX, TM_CAL_START_REGEX, TM_ERROR_LOG_REGEX, ) from .telemetry_passer import TelemetryPasser from .updatable import Thread log = logging.getLogger(__name__) # pylint: disable=too-many-lines # pylint: disable=too-many-lines class TransferCallbackState(Enum): """Return values form download_finished_cb.""" SUCCESS = 0 NOT_IN_TREE = 1 ANOTHER_PRINTING = 2 PRINTER_IN_ATTENTION = 3 class PrusaLink: """ This class is the controller for PrusaLink, more specifically the part that communicates with the printer. It connects signals with their handlers """ def __init__(self, cfg: Config, settings: Settings) -> None: # pylint: disable=too-many-statements self.cfg: Config = cfg log.info('Starting adapter for port %s', self.cfg.printer.port) self.settings: Settings = settings use_connect_errors(self.settings.use_connect()) self.quit_evt = Event() self.stopped_event = Event() HW.state = CondState.OK self.model = Model() # These start by themselves self.service_discovery = ServiceDiscovery(self.cfg.http.port) self.serial_parser = ThreadedSerialParser() # Wait for power panic recovery to reach a stable state power_panic_delay(cfg) self.serial = SerialAdapter( self.serial_parser, self.model, configured_port=cfg.printer.port, baudrate=cfg.printer.baudrate, reset_disabling=cfg.printer.reset_disabling) self.serial_queue = MonitoredSerialQueue( serial_adapter=self.serial, serial_parser=self.serial_parser, threshold_path=self.cfg.daemon.threshold_file) # ----- self.keepalive = Keepalive(self.serial_queue) self.keepalive.set_use_connect(self.settings.use_connect()) self.printer = MyPrinter() self.printer.software = __version__ drivers: List[Type[CameraDriver]] = [V4L2Driver] if PiCameraDriver.supported: drivers.append(PiCameraDriver) self.camera_configurator = CameraConfigurator( config=self.settings, config_file_path=self.cfg.printer.settings, camera_controller=self.printer.camera_controller, drivers=drivers, auto_detect=self.cfg.cameras.auto_detect, ) self.camera_governor = CameraGovernor(self.camera_configurator, self.printer.camera_controller) self.printer.register_handler = self.printer_registered self.printer.connection_from_settings(settings) # Set download callbacks self.printer.printed_file_cb = self.printed_file_cb self.printer.download_mgr.download_finished_cb \ = self.download_finished_cb # Bind command handlers self.printer.set_handler(CommandType.GCODE, self.execute_gcode) self.printer.set_handler(CommandType.PAUSE_PRINT, self.pause_print) self.printer.set_handler(CommandType.RESET_PRINTER, self.reset_printer) self.printer.set_handler(CommandType.UPGRADE, self.upgrade_link) self.printer.set_handler(CommandType.RESUME_PRINT, self.resume_print) self.printer.set_handler(CommandType.START_PRINT, self.start_print) self.printer.set_handler(CommandType.STOP_PRINT, self.stop_print) self.printer.set_handler(CommandType.SEND_JOB_INFO, self.job_info) self.printer.set_handler(CommandType.LOAD_FILAMENT, self.load_filament) self.printer.set_handler(CommandType.UNLOAD_FILAMENT, self.unload_filament) self.printer.set_handler(CommandType.SET_PRINTER_READY, self.set_printer_ready) self.printer.set_handler(CommandType.CANCEL_PRINTER_READY, self.cancel_printer_ready) self.serial_parser.add_decoupled_handler( PAUSE_PRINT_REGEX, lambda sender, match: self.fw_pause_print()) self.serial_parser.add_decoupled_handler( RESUME_PRINT_REGEX, lambda sender, match: self.fw_resume_print()) self.serial_parser.add_decoupled_handler( READY_REGEX, lambda sender, match: self.fw_set_ready()) self.serial_parser.add_decoupled_handler( NOT_READY_REGEX, lambda sender, match: self.fw_cancel_ready()) self.serial_parser.add_decoupled_handler( REPRINT_REGEX, lambda sender, match: self.fw_reprint()) # Init components first, so they all exist for signal binding stuff # TODO: does not need printer, the transfer object should be # viewable from elsewhere imo self.lcd_printer = LCDPrinter(self.serial_queue, self.model, self.settings, self.printer, self.cfg.daemon.printer_number) self.serial_parser.add_decoupled_handler( LCD_UPDATE_REGEX, self.lcd_printer.lcd_updated) self.job = Job(self.serial_queue, self.model, self.printer) self.state_manager = StateManager(self.serial_parser, self.model) self.file_printer = FilePrinter(self.serial_queue, self.serial_parser, self.model, self.cfg) self.storage_controller = StorageController(cfg, self.serial_queue, self.serial_parser, self.model) self.ip_updater = IPUpdater(self.model, self.serial_queue) self.telemetry_passer = TelemetryPasser(self.model, self.printer) self.printer_polling = PrinterPolling(self.serial_queue, self.serial_parser, self.printer, self.model, self.telemetry_passer, self.job, self.storage_controller.sd_card) self.command_queue = CommandQueue() self.special_commands = SpecialCommands(self.serial_parser, self.command_queue) # Set Transfer callbacks self.printer.transfer.started_cb = self.transfer_activity_observed self.printer.transfer.progress_cb = self.transfer_activity_observed self.printer.transfer.stopped_cb = self.transfer_activity_observed for state in ROOT_COND: state.add_broke_handler(lambda *_: self.lcd_printer.notify()) state.add_fixed_handler(lambda *_: self.lcd_printer.notify()) self.serial_parser.add_decoupled_handler( MBL_TRIGGER_REGEX, lambda sender, match: self.printer_polling.invalidate_mbl()) self.serial_parser.add_decoupled_handler( TM_CAL_START_REGEX, self.block_serial_queue) self.serial_parser.add_decoupled_handler( TM_CAL_END_REGEX, self.unblock_serial_queue) self.serial_parser.add_decoupled_handler( POWER_PANIC_REGEX, self.power_panic_observed) self.serial_parser.add_decoupled_handler( PP_RECOVER_REGEX, self.recover_from_pp) self.serial_parser.add_decoupled_handler( PP_AUTO_RECOVER_REGEX, self.recover_from_pp) self.print_stat_doubler = PrintStatDoubler(self.serial_parser, self.printer_polling) # Bind signals self.serial_queue.serial_queue_failed.connect(self.serial_queue_failed) self.serial.failed_signal.connect(self.serial_failed) self.serial.renewed_signal.connect(self.serial_renewed) self.serial_queue.instruction_confirmed_signal.connect( self.instruction_confirmed) self.serial_queue.message_number_changed.connect( self.serial_message_number_changed) self.serial_parser.add_decoupled_handler(PRINTER_BOOT_REGEX, self.printer_reconnected) self.serial_parser.add_decoupled_handler(TM_ERROR_LOG_REGEX, self.log_tm_error) # Set up the signals for special menu handling # And for passthrough self.special_commands.open_result_signal.connect(self.job.file_opened) self.special_commands.start_print_signal.connect( lambda _, match: self.state_manager.printing(), weak=False) self.special_commands.print_done_signal.connect( lambda _, match: self.state_manager.finished(), weak=False) self.storage_controller.menu_found_signal.connect( self.special_commands.menu_folder_found) self.storage_controller.sd_detached_signal.connect( self.special_commands.menu_folder_gone) self.printer.command.stop_cb = self.command_queue.clear_queue self.job.job_info_updated_signal.connect(self.job_info_updated) self.job.job_id_updated_signal.connect(self.job_id_updated) self.state_manager.pre_state_change_signal.connect( self.pre_state_change) self.state_manager.post_state_change_signal.connect( self.post_state_change) self.state_manager.state_changed_signal.connect(self.state_changed) self.state_manager.pause_signal.connect( lambda match: self.file_printer.pause(), weak=False) self.file_printer.time_printing_signal.connect( self.time_printing_updated) self.file_printer.new_print_started_signal.connect( self.file_printer_started_printing) self.file_printer.print_stopped_signal.connect( self.file_printer_stopped_printing) self.file_printer.print_finished_signal.connect( self.file_printer_finished_printing) self.file_printer.byte_position_signal.connect( self.byte_position_changed) self.file_printer.layer_trigger_signal.connect(self.layer_trigger) self.file_printer.recovery_done_signal.connect( lambda _: self.lcd_printer.notify(), weak=False) self.storage_controller.folder_attached_signal.\ connect(self.folder_attach) self.storage_controller.folder_detached_signal.\ connect(self.folder_detach) self.storage_controller.sd_attached_signal.connect(self.sd_attach) self.storage_controller.sd_detached_signal.connect(self.sd_detach) self.printer_polling.printer_type.became_valid_signal.connect( self.printer_type_changed) self.printer_polling.print_state.became_valid_signal.connect( self.print_state_changed) self.printer_polling.byte_position.value_changed_signal.connect( lambda value: self.byte_position_changed(self.printer_polling, value[0], value[1])) self.printer_polling.mixed_path.value_changed_signal.connect( self.mixed_path_changed) self.printer_polling.progress_broken.value_changed_signal.connect( self.progress_broken) self.printer_polling.mbl.value_changed_signal.connect( self.mbl_data_changed) self.printer_polling.sheet_settings.value_changed_signal.connect( self.sheet_settings_changed) self.printer_polling.active_sheet.value_changed_signal.connect( self.active_sheet_changed) self.printer_polling.mmu_connected.value_changed_signal.connect( self.mmu_connection_changed) self.printer_polling.mmu_version.value_changed_signal.connect( self.mmu_info_changed) self.printer_polling.speed_multiplier.value_changed_signal.connect( lambda val: self.lcd_printer.notify(), weak=False) API.add_fixed_handler(self.connection_renewed) # get the ip, then poll the rest of the network info self.ip_updater.update() self.ip_updater.updated_signal.connect(self.ip_updated) self.camera_governor.start() # Leave the non-polled telemetry split from the rest self.auto_telemetry = AutoTelemetry(self.serial_parser, self.serial_queue, self.model, self.telemetry_passer) self.auto_telemetry.start() self.mmu_observer = MMUObserver(self.serial_parser, self.model, self.printer, self.telemetry_passer) self.mmu_observer.error_changed_signal.connect(self.mmu_error_changed) self.keepalive.start() self.printer_polling.start() self.storage_controller.start() self.ip_updater.start() self.lcd_printer.start() self.command_queue.start() self.telemetry_passer.start() self.printer.start() log.debug("Initialization done") debug = False if debug: Thread(target=self.debug_shell, name="debug_shell", daemon=True).start() # pylint: disable=too-many-branches def debug_shell(self) -> None: """ Calling this in a thread that receives stdin enables th user to give PrusaLink commands through the terminal """ print("Debug shell") while not self.quit_evt.is_set(): try: command = input("[PrusaLink]: ") result: Any = "" if command == "pause": result = self.command_queue.do_command(PausePrint()) elif command == "reprint": result = self.command_queue.do_command(RePrint()) elif command == "resume": result = self.command_queue.do_command(ResumePrint()) elif command == "stop": result = self.command_queue.do_command(StopPrint()) elif command.startswith("gcode"): result = self.command_queue.do_command( ExecuteGcode(command.split(" ", 1)[1])) elif command.startswith("print"): result = self.command_queue.do_command( StartPrint(command.split(" ", 1)[1])) elif command.startswith("trigger"): InterestingLogRotator.trigger("a debugging command") elif command.startswith("faststop"): self.stop(True) elif command == "break comms": result = enqueue_matchable( self.serial_queue, "M117 Breaking", re.compile(r"something the printer will not tell us")) if result: print(result) # pylint: disable=bare-except except: # noqa: E722 log.exception("Debug console errored out") def stop(self, fast: bool = False) -> None: """ Calls stop on every module containing a thread, for debugging prints out all threads which are still running and sets an event to signalize that PrusaLink has stopped. """ # pylint: disable=too-many-statements log.debug("Stop start%s", ' fast' if fast else '') was_printing = self.model.file_printer.printing was_sd_printing = (self.printer_polling.print_state == PrintState.SD_PRINTING) self.quit_evt.set() self.camera_governor.stop() self.file_printer.stop() self.command_queue.stop() self.telemetry_passer.stop() self.printer.stop_loop() self.printer.indicate_stop() self.printer_polling.stop() self.storage_controller.stop() self.keepalive.stop() self.lcd_printer.stop(fast) # This is for pylint to stop complaining, I'd like stop(fast) more if fast: self.ip_updater.stop() self.auto_telemetry.stop() else: self.ip_updater.proper_stop() self.auto_telemetry.proper_stop() if was_sd_printing: self.serial.enable_dtr_resets() self.serial_queue.stop() self.serial_parser.stop() if was_printing and not fast: try: self.serial.write(b"M603\n") except SerialException: pass self.serial.stop() log.debug("Stop signalled") if not fast: self.service_discovery.unregister() self.file_printer.wait_stopped() self.telemetry_passer.wait_stopped() self.printer.wait_stopped() self.printer_polling.wait_stopped() self.storage_controller.wait_stopped() self.keepalive.wait_stopped() self.lcd_printer.wait_stopped() self.ip_updater.wait_stopped() self.camera_governor.wait_stopped() self.auto_telemetry.wait_stopped() self.serial_queue.wait_stopped() self.serial_parser.wait_stopped() self.serial.wait_stopped() log.debug("Remaining threads, that might prevent stopping:") for thread in enumerate_threads(): log.debug(thread) self.stopped_event.set() log.info("Stop completed%s", ' fast!' if fast else '') # --- Download callbacks --- def printed_file_cb(self) -> Optional[str]: """Return absolute path of the currently printed file.""" if self.job.data.job_state == JobState.IN_PROGRESS: return self.job.data.selected_file_path return None # Not type annotated, has problems def download_finished_cb(self, transfer): """Called when download is finished successfully""" if not transfer.to_print: return TransferCallbackState.SUCCESS if self.printer.state == State.ATTENTION: return TransferCallbackState.PRINTER_IN_ATTENTION if self.job.data.job_state == JobState.IDLE: self.job.deselect_file() if not self.printer.fs.wait_until_path(transfer.path, PATH_WAIT_TIMEOUT): log.warning("Transferred file %s not found in tree", transfer.path) return TransferCallbackState.NOT_IN_TREE self.job.select_file(transfer.path) self.command_queue.do_command( StartPrint(self.job.data.selected_file_path)) return TransferCallbackState.SUCCESS log.warning("Printer is printing another file.") return TransferCallbackState.ANOTHER_PRINTING # --- Command handlers --- def execute_gcode(self, caller: SDKCommand) -> CommandResult: """ Connects the command to exectue gcode from CONNECT with its handler """ assert caller.kwargs command = ExecuteGcode(gcode=caller.kwargs["gcode"], force=caller.force, command_id=caller.command_id) return self.command_queue.do_command(command) def start_print(self, caller: SDKCommand) -> CommandResult: """ Connects the command to start print from CONNECT with its handler """ assert caller.kwargs command = StartPrint(path=caller.kwargs["path"], command_id=caller.command_id) return self.command_queue.do_command(command) def pause_print(self, caller: SDKCommand) -> CommandResult: """ Connects the command to pause print from CONNECT with its handler """ command = PausePrint(command_id=caller.command_id) return self.command_queue.do_command(command) def resume_print(self, caller: SDKCommand) -> CommandResult: """ Connects the command to resume print from CONNECT with its handler """ command = ResumePrint(command_id=caller.command_id) return self.command_queue.do_command(command) def stop_print(self, caller: SDKCommand) -> CommandResult: """ Connects the command to stop print from CONNECT with its handler """ command = StopPrint(command_id=caller.command_id) return self.command_queue.do_command(command) def reset_printer(self, caller: SDKCommand) -> CommandResult: """ Connects the command to reset printer from CONNECT with its handler """ command = ResetPrinter(command_id=caller.command_id) return self.command_queue.force_command(command) def upgrade_link(self, caller: SDKCommand) -> CommandResult: """ Connects the command to upgrade link from CONNECT with its handler """ command = UpgradeLink(command_id=caller.command_id) return self.command_queue.do_command(command) def job_info(self, caller: SDKCommand) -> CommandResult: """ Connects the command to send job info from CONNECT with its handler """ command = JobInfo(command_id=caller.command_id) return self.command_queue.do_command(command) def load_filament(self, caller: SDKCommand) -> CommandResult: """Load filament""" command = LoadFilament(parameters=caller.kwargs, command_id=caller.command_id) return self.command_queue.do_command(command) def unload_filament(self, caller: SDKCommand) -> CommandResult: """Unload filament""" command = UnloadFilament(parameters=caller.kwargs, command_id=caller.command_id) return self.command_queue.do_command(command) def set_printer_ready(self, caller: SDKCommand) -> CommandResult: """Set printer ready""" command = SetReady(command_id=caller.command_id) return self.command_queue.do_command(command) def cancel_printer_ready(self, caller: SDKCommand) -> CommandResult: """Cancel printer ready""" command = CancelReady(command_id=caller.command_id) return self.command_queue.do_command(command) # --- FW Command handlers --- def fw_pause_print(self) -> None: """ Pauses the print, when fw asks to through serial This is activated by the user most of the time """ # FIXME: The source is wrong for the LCD pause prctl_name() command = PausePrint(source=Source.FIRMWARE) self.command_queue.enqueue_command(command) def fw_resume_print(self) -> None: """ Pauses the print, when fw asks to through serial This happens, when the user presses resume on the LCD """ prctl_name() command = ResumePrint(source=Source.USER) self.command_queue.enqueue_command(command) def fw_set_ready(self) -> None: """Set printer ready from the printer LCD menu""" prctl_name() command = SetReady(source=Source.USER) self.command_queue.enqueue_command(command) def fw_cancel_ready(self) -> None: """Cancel printer ready from the printer LCD menu""" prctl_name() command = CancelReady(source=Source.USER) self.command_queue.enqueue_command(command) def fw_reprint(self) -> None: """Prints the last job again, activated from the printer LCD screen""" prctl_name() command = RePrint(source=Source.USER) self.command_queue.enqueue_command(command) # --- Signal handlers --- def layer_trigger(self, _): """Passes the call to trigger to the camera controller""" self.printer.camera_controller.layer_trigger() def mbl_data_changed(self, data) -> None: """Sends the mesh bed leveling data to Connect""" self.printer.mbl = data["data"] self.printer.event_cb(event=EventType.MESH_BED_DATA, source=Source.MARLIN, mbl=data["data"]) def sheet_settings_changed(self, printer_sheets: List[Sheet]) -> None: """Sends the new sheet settings""" sdk_sheets: List[SDKSheet] = [] sheet: Sheet for sheet in printer_sheets: sdk_sheets.append({ "name": sheet.name, "z_offset": sheet.z_offset, }) self.printer.sheet_settings = sdk_sheets if not self.printer.is_initialised(): return self.printer.event_cb(event=EventType.INFO, source=Source.USER, sheet_settings=sdk_sheets) def active_sheet_changed(self, active_sheet) -> None: """Sends the new active sheet""" self.printer.active_sheet = active_sheet if not self.printer.is_initialised(): return self.printer.event_cb(event=EventType.INFO, source=Source.USER, active_sheet=active_sheet) def mmu_connection_changed(self, _) -> None: """Notifies the telemetry passer about the new state of the mmu connection ans continues ba calling the info sending method """ self.telemetry_passer.state_changed() self.mmu_info_changed(_) def mmu_info_changed(self, _) -> None: """Sends the mmu connection status""" mmu_connected = self.printer_polling.mmu_connected.value mmu_version = self.printer_polling.mmu_version.value if not mmu_connected: mmu_version = None self.printer.mmu_enabled = mmu_connected # Hardcoded MMU3, sorry self.printer.mmu_type = MMUType.MMU3 if mmu_connected else None if not self.printer_polling.mmu_version.valid: return self.printer.mmu_fw = mmu_version if not self.printer.is_initialised(): return kwargs: Dict[str, Any] = {} mmu = {"enabled": mmu_connected} if mmu_connected and self.printer.mmu_type is not None: mmu["version"] = mmu_version kwargs["slots"] = MMU_SLOT_COUNTS[self.printer.mmu_type] kwargs["mmu"] = mmu self.printer.event_cb(event=EventType.INFO, source=Source.FIRMWARE, **kwargs) def mmu_error_changed(self, _) -> None: """Connect the mmu error code changing to the state manager""" self.state_manager.mmu_error_changed() def job_info_updated(self, _) -> None: """On job info update, sends the updated job info to the Connect""" # pylint: disable=unsupported-assignment-operation,not-a-mapping try: job_info: Dict[str, Any] = self.command_queue.do_command(JobInfo()) except Exception: # pylint: disable=broad-except log.warning("Job update could not get job info") else: job_info["source"] = Source.FIRMWARE self.printer.event_cb(**job_info) def job_id_updated(self, _, job_id: int) -> None: """Passes the job_id into the SDK""" self.printer.job_id = job_id self.printer_polling.ensure_job_id() def printer_type_changed(self, item: WatchedItem) -> None: """Watches for printer type mismatches""" if not self.settings.printer.type: return settings_type = PRINTER_CONF_TYPES[self.settings.printer.type] detected_type = PRINTER_TYPES[item.value] if not settings_type or settings_type == detected_type: UPGRADED.state = CondState.OK return if self.settings.use_connect(): log.warning("Configured printer type does not match the one " "of the printer") UPGRADED.state = CondState.NOK # Keep this getter spinning, so we get called again self.printer_polling.schedule_printer_type_invalidation() else: # If not using connect, update the type straight away self.settings.printer.type = PRINTER_CONF_TYPES.inverse[ detected_type] self.settings.update_sections(connect_skip=True) with open(self.cfg.printer.settings, 'w', encoding='utf-8') as ini: self.settings.write(ini) UPGRADED.state = CondState.OK def print_state_changed(self, item: WatchedItem) -> None: """Handles the newly observed print state""" assert item.value is not None state_to_handler = { PrintState.SD_PRINTING: self.observed_print, PrintState.NOT_SD_PRINTING: self.observed_no_print, PrintState.SD_PAUSED: self.observed_sd_pause, PrintState.SERIAL_PAUSED: self.observed_serial_pause, } state_to_handler[item.value]() def observed_print(self) -> None: """ The telemetry can observe some states, this method connects it observing a print in progress to the state manager """ self.command_queue.enqueue_command(DisableResets()) self.state_manager.expect_change( StateChange(to_states={State.PRINTING: Source.FIRMWARE})) self.state_manager.printing() self.state_manager.stop_expecting_change() def observed_sd_pause(self) -> None: """ Connects telemetry observing a paused sd print to the state manager """ self.state_manager.expect_change( StateChange(to_states={State.PAUSED: Source.FIRMWARE})) self.state_manager.paused() self.state_manager.stop_expecting_change() def observed_serial_pause(self) -> None: """ If the printer says the serial print is paused, but we're not serial printing at all, we'll resolve it by stopping whatever was going on before. If the serial print is recovering, we tell that to connnect """ if self.model.file_printer.recovering: self.state_manager.expect_change( StateChange(to_states={State.PAUSED: Source.FIRMWARE}, reason="Waiting for the user to recover the print " "after a power failure.")) self.state_manager.paused() self.state_manager.stop_expecting_change() def observed_no_print(self) -> None: """ Useful only when not serial printing. Connects telemetry observing there's no print in progress to the state_manager """ self.command_queue.enqueue_command(EnableResets()) # When serial printing, the printer reports not printing # Let's ignore it in that case if not self.model.file_printer.printing: self.state_manager.expect_change( StateChange(from_states={State.PRINTING: Source.FIRMWARE})) self.state_manager.stopped_or_not_printing() self.state_manager.stop_expecting_change() def progress_broken(self, progress_broken: bool) -> None: """ Connects telemetry, which can see the progress returning garbage values to the job component """ self.job.progress_broken(progress_broken) def byte_position_changed(self, _, current: int, total: int) -> None: """Passes byte positions to the job component""" self.job.file_position(current=current, total=total) def mixed_path_changed(self, path: str) -> None: """Connects telemetry observed file path to the job component""" self.job.process_mixed_path(path) def _reset_print_stats(self) -> None: """Reset print stats on the printer and in telemetry""" gcode = get_print_stats_gcode() enqueue_instruction(self.serial_queue, gcode) self.telemetry_passer.set_telemetry(Telemetry( time_printing=0, time_remaining=0, filament_change_in=0, )) def file_printer_started_printing(self, _) -> None: """Tells the state manager about a new print job starting""" self.state_manager.file_printer_started_printing() def file_printer_stopped_printing(self, _) -> None: """Connects file printer stopping with state manager""" self.state_manager.stopped() def file_printer_finished_printing(self, _) -> None: """Connects file printer finishing a print with state manager""" self.state_manager.finished() def serial_failed(self, _) -> None: """Connects serial errors with state manager""" self.state_manager.serial_error() self.file_printer.stop_print() def serial_renewed(self, _) -> None: """Connects serial recovery with state manager""" self.state_manager.serial_error_resolved() self.printer_reconnected() def set_sn(self, _, serial_number: str) -> None: """Set serial number and fingerprint""" # Only do it if the serial number is missing # Setting it for a second time raises an error for some reason if self.printer.sn is None: self.printer.sn = serial_number self.printer.fingerprint = make_fingerprint(serial_number) elif self.printer.sn != serial_number: log.error("The new serial number is different from the old one!") raise RuntimeError(f"Serial numbers differ original: " f"{self.printer.sn} new one: {serial_number}.") def printer_registered(self, token: str) -> None: """Store settings with updated token when printer was registered.""" printer_type_string = PRINTER_CONF_TYPES.inverse[self.printer.type] self.settings.printer.type = printer_type_string self.settings.service_connect.token = token self.settings.update_sections() use_connect = self.settings.use_connect() use_connect_errors(use_connect) self.keepalive.set_use_connect(use_connect) with open(self.cfg.printer.settings, 'w', encoding='utf-8') as ini: self.settings.write(ini) def ip_updated(self, _) -> None: """On every ip change from ip updater sends a new info""" self.printer_polling.invalidate_network_info() def folder_attach(self, _, path: str) -> None: """Connects a folder being attached to PrusaConnect events""" self.printer.attach(path, os.path.basename(path)) def folder_detach(self, _, path: str) -> None: """Connects a folder being detached to PrusaConnect events""" self.printer.detach(os.path.basename(path)) def sd_attach(self, _, files: File) -> None: """Connects the sd being attached to PrusaConnect events""" self.printer.fs.attach(SD_STORAGE_NAME, files, "", use_inotify=False) def sd_detach(self, _) -> None: """Connects the sd being detached to PrusaConnect events""" self.printer.fs.detach(SD_STORAGE_NAME) def instruction_confirmed(self, _) -> None: """ Connects instruction confirmation from serial queue to state manager """ self.state_manager.instruction_confirmed() def serial_message_number_changed(self, message_number): """Connects serial message number change to file printer for power panic to work""" self.file_printer.serial_message_number_changed(message_number) def block_serial_queue(self, *_, **__) -> None: """Blocks the serial queue""" self.serial_queue.block_sending() def unblock_serial_queue(self, *_, **__) -> None: """Unblocks the serial queue""" self.serial_queue.unblock_sending() def power_panic_observed(self, *_, **__): """Routes a power panic message to components""" self.file_printer.power_panic() self.state_manager.power_panic_observed() self.serial.power_panic_observed() self.state_manager.paused() # This is normally a bad idea in a serial handler # But as we are holding the serial disconnected anyways, it's OK sleep(10) self.serial.power_panic_unblock() def recover_from_pp(self, *_, **__) -> None: """Recover from power panic""" self.command_queue.enqueue_command(PPRecovery()) def printer_reconnected(self, *_, **__) -> None: """ Connects the printer reconnect (reset) to many other components. Stops serial prints, flushes the serial queue, updates the state and tries to send its info again. """ was_printing = self.state_manager.get_state() in PRINTING_STATES was_power_panic = self.state_manager.in_power_panic self.file_printer.stop_print() self.file_printer.wait_stopped() self.serial_queue.printer_reconnected(was_printing, was_power_panic) self.command_queue.enqueue_command(CancelReady(source=Source.SERIAL)) # file printer stop print needs to happen before this self.state_manager.reset() self.lcd_printer.reset_error_grace() self.printer_polling.invalidate_printer_info() # Don't wait for the instruction confirmation, we'd be blocking the # thread supposed to provide it self.ip_updater.send_ip_to_printer(timeout=0) self.telemetry_passer.wipe_telemetry() # Re-set the power panic flag once we-re done self.state_manager.reset_power_panic() @property def sd_ready(self) -> bool: """Returns if sd_state is PRESENT.""" return self.model.sd_card.sd_state == SDState.PRESENT def pre_state_change(self, _, command_id: int): """ First step of a two step process. Connects the state change to the job module. Explanation is(will be) in the job module """ self.job.state_changed(command_id=command_id) def post_state_change(self, _) -> None: """Second step of a two step process. Connects the state change to the job module. Explanation is(will be) in the job module""" self.job.tick() # pylint: disable=too-many-arguments # Fix SDK download manager throttle to float, then type annotate def state_changed(self, _, from_state, to_state, source=None, command_id=None, reason=None, ready=False): """Connects the state manager state change to PrusaConnect""" assert from_state is not None assert to_state is not None if source is None: source = Source.WUI InterestingLogRotator.trigger("by an unexpected state change.") log.warning("State change had no source %s", to_state.value) if to_state == State.ERROR: InterestingLogRotator.trigger( "the printer entering the ERROR state.") self.file_printer.stop_print() self.telemetry_passer.state_changed() if from_state in PRINTING_STATES and to_state in BASE_STATES: self._reset_print_stats() # Was printing. Statistics probably changed, let's poll those now if from_state == State.PRINTING: self.printer_polling.invalidate_statistics() # No other trigger exists for these older printers # The printer will dip into BUSY for MBL, so lets use that printer_type = None if self.printer.type is not None: printer_type = self.printer.type.value if to_state in {State.PRINTING, State.IDLE} and \ printer_type in MK25_PRINTERS: self.printer_polling.invalidate_mbl() # The states should be completely re-done i'm told. So this janky # stuff is what we're going to deal with for now if to_state in {State.PRINTING, State.ATTENTION, State.ERROR}: self.printer_polling.polling_not_ok() if to_state not in {State.PRINTING, State.ATTENTION, State.ERROR}: self.printer_polling.polling_ok() # Set download throttling depending on printer state and cpu count if to_state == State.PRINTING and is_potato_cpu(): self.printer.download_mgr.buffer_size = DownloadMgr.SMALL_BUFFER self.printer.download_mgr.throttle = 0.03 else: self.printer.download_mgr.buffer_size = DownloadMgr.BIG_BUFFER self.printer.download_mgr.throttle = 0 extra_data = {} if reason is not None: extra_data["reason"] = reason self.printer.set_state(to_state, command_id=command_id, source=source, job_id=self.model.job.get_job_id_for_api(), ready=ready, **extra_data) def time_printing_updated(self, _, time_printing: int) -> None: """Connects the serial-print print-timer with telemetry""" self.telemetry_passer.set_telemetry(new_telemetry=Telemetry( time_printing=time_printing)) def serial_queue_failed(self, _) -> None: """Handles the serial queue failure by resetting the printer""" reset_command = ResetPrinter() self.state_manager.serial_error() try: self.command_queue.do_command(reset_command) except Exception: # pylint: disable=broad-except log.exception("Failed to reset the printer. Oh my god... " "my attempt at safely failing has failed.") def connection_renewed(self, *_) -> None: """Reacts to the connection with connect being ok again""" self.telemetry_passer.resend_latest_telemetry() def transfer_activity_observed(self, *_) -> None: """Notifies PrusaLink components about a transfer happening""" self.telemetry_passer.activity_observed() self.lcd_printer.notify() def log_tm_error(self, _, match: re.Match) -> None: """Logs the temperature model errors""" groups = match.groupdict() deviation = float(groups["deviation"]) threshold = float(groups["threshold"]) log.warning("The hot-end temperature differs from the expected one. " "|%s|>%s", deviation, threshold) ================================================ FILE: prusa/link/printer_adapter/py.typed ================================================ ================================================ FILE: prusa/link/printer_adapter/special_commands.py ================================================ """An implementation of a hidden menu logic""" import logging import re from time import time from blinker import Signal # type:ignore from ..interesting_logger import InterestingLogRotator from ..serial.serial_parser import ThreadedSerialParser from .command import CommandFailed from .command_handlers import SetReady from .command_queue import CommandQueue from .structures.regular_expressions import ( OPEN_RESULT_REGEX, PRINT_DONE_REGEX, START_PRINT_REGEX, ) log = logging.getLogger(__name__) CMD_TIMEOUT = 1 class SpecialCommands: """Filter print start related serial output and catch special menu item related ones""" def __init__(self, serial_parser: ThreadedSerialParser, command_queue: CommandQueue): self.command_queue = command_queue self.commands = {"setready.g": self.set_ready} self.detected_at = 0 self.menu_folder_sfn = None self.current = None self.open_result_signal = Signal() # kwargs - match: re.Match self.start_print_signal = Signal() self.print_done_signal = Signal() serial_parser.add_decoupled_handler( OPEN_RESULT_REGEX, self.handle_file) serial_parser.add_decoupled_handler( START_PRINT_REGEX, self.handle_start) serial_parser.add_decoupled_handler( PRINT_DONE_REGEX, self.handle_done) def menu_folder_found(self, _, menu_sfn): """An SD with the special menu has been inserted""" log.debug("Registered a menu folder %s", menu_sfn) self.menu_folder_sfn = menu_sfn def menu_folder_gone(self, _): """The special menu was ejected with its SD card""" log.debug("De-registered a menu folder %s", self.menu_folder_sfn) self.menu_folder_sfn = None def _open_is_special(self, match): """Does this match correspond to one of our special menu item files?""" sdn_lfn = match.group("sdn_lfn") if sdn_lfn is None: return False if self.menu_folder_sfn is None: return False path = sdn_lfn.lower() parts = path.rsplit("/", 2) if len(parts) < 2: return False if parts[-2] != self.menu_folder_sfn: return False return parts[-1] in self.commands def handle_file(self, _, match): """A file has been opened, should we pass along that info, or should we prepare our special command""" if self._open_is_special(match): path = match.group("sdn_lfn").lower() parts = path.rsplit("/", 2) self.current = self.commands[parts[-1]] self.detected_at = time() else: self.open_result_signal.send(match=match) def handle_start(self, _, match: re.Match): """If a command is prepared, prolong it's lifetime, otherwise pass through""" assert match is not None since_detected = time() - self.detected_at if self.current is not None and since_detected < CMD_TIMEOUT: self.detected_at = time() else: self.current = None self.start_print_signal.send(match=match) def handle_done(self, _, match: re.Match): """If a command is prepared and the placeholder file print has been done, execute the command""" since_detected = time() - self.detected_at if self.current is not None and since_detected < CMD_TIMEOUT: self.current() else: self.print_done_signal.send(match=match) self.current = None def set_ready(self): """A command handler to set the printer into READY""" try: self.command_queue.do_command(SetReady()) except CommandFailed: InterestingLogRotator.trigger("Attempt to set the printer ready") log.exception("Setting the printer to READY has failed") ================================================ FILE: prusa/link/printer_adapter/state_manager.py ================================================ """Contains implementation of the the StateManager and StateChange classes""" import logging import re from collections import deque from threading import Event, RLock, Thread, Timer from time import monotonic from typing import Dict, Optional, Union from blinker import Signal # type: ignore from prusa.connect.printer.conditions import Condition, CondState from prusa.connect.printer.const import Source, State from ..conditions import HW, SERIAL from ..const import ERROR_REASON_TIMEOUT, STATE_HISTORY_SIZE, \ ATTENTION_CLEAR_INTERVAL, PRINT_END_TIMEOUT from ..serial.serial_parser import ThreadedSerialParser from .model import Model from .structures.mc_singleton import MCSingleton from .structures.module_data_classes import StateManagerData from .structures.regular_expressions import (ATTENTION_REASON_REGEX, ATTENTION_REGEX, BUSY_REGEX, CANCEL_REGEX, ERROR_REASON_REGEX, ERROR_REGEX, FAN_ERROR_REGEX, FAN_REGEX, PAUSED_REGEX, RESUMED_REGEX, TM_ERROR_CLEARED) log = logging.getLogger(__name__) class StateChange: """ Represents a set of state changes that can happen Used for assigning info to observed state changes """ # pylint: disable=too-many-arguments def __init__(self, command_id=None, to_states: Optional[Dict[State, Union[Source, None]]] = None, from_states: Optional[Dict[State, Union[Source, None]]] = None, default_source: Optional[Source] = None, reason: Optional[str] = None, ready: bool = False): self.reason = reason self.to_states: Dict[State, Union[Source, None]] = {} self.from_states: Dict[State, Union[Source, None]] = {} if from_states is not None: self.from_states = from_states if to_states is not None: self.to_states = to_states self.command_id = command_id self.default_source = default_source self.ready = ready def state_influencer(state_change: Optional[StateChange] = None): """ This decorator makes it possible for each state change to have default expected sources This can be overridden by notifying the state manager about an oncoming state change through expect_change """ def inner(func): """It's just how decorators work man""" def wrapper(self, *args, **kwargs): """By nesting function definitions. Shut up Travis!""" with self.state_lock: has_set_expected_change = False if self.expected_state_change is None and \ state_change is not None: has_set_expected_change = True self.expect_change(state_change) else: log.debug("Default expected state change is overridden") func(self, *args, **kwargs) self.state_may_have_changed() if has_set_expected_change: self.stop_expecting_change() return wrapper return inner class StateManager(metaclass=MCSingleton): """ Keeps track of the printer states by observing the serial and by listening to other PrusaLink components """ # pylint: disable=too-many-instance-attributes, # pylint: disable=too-many-public-methods # pylint: disable=too-many-arguments def __init__(self, serial_parser: ThreadedSerialParser, model: Model): self.serial_parser: ThreadedSerialParser = serial_parser self.model: Model = model self.pre_state_change_signal = Signal() # kwargs: command_id: int self.post_state_change_signal = Signal() self.state_changed_signal = Signal() # kwargs: # from_state: State # to_state: State # command_id: int, # source: Sources # reason: str # ready: bool self.pause_signal = Signal() self.model.state_manager = StateManagerData( # The ACTUAL states considered when reporting base_state=State.BUSY, printing_state=None, override_state=None, # Reported state history state_history=deque(maxlen=STATE_HISTORY_SIZE), last_state=State.BUSY, current_state=State.BUSY, awaiting_error_reason=False) self.data = self.model.state_manager # Prevent multiple threads changing the state at once self.state_lock = RLock() # Another anti-ideal thing is, that with this observational # approach to state detection we cannot correlate actions with # reactions nicely. My first approach is to have an action, # that's supposed to change the state and to which state that shall be # if we observe such a transition, we'll say the action # caused the state change self.expected_state_change: Union[None, StateChange] = None # The fan error doesn't fit into this mechanism # When this value isn't none, a fan error has been observed # but not yet reported, the value shall be the name of the fan which # caused the error # New: clear once the error is known resolved self.fan_error_name = None # A thing to detect a false positive attention self.resuming_from_fan_error = False # At startup, we must avoid going to the IDLE state, until # we are sure about not printing self.unsure_whether_printing = True # Errors are a fun bunch, sometimes, the explanation of what has # happened comes before and sometimes after the stop() or kill() # call. Let's start a timer when an unexplained kill() or stop() comes # and if an explanation comes, let's send that as reason, otherwise # do the error state without a reason. self.error_reason_thread: Optional[Thread] = None self.error_reason_event = Event() # Workaround for a bug, where on a start of a SD print from the LCD, # the printer announces it will be printing a file, then says it's not # printing anything and then announces printing the same file again # This makes us ask the user to remove the print while printing # Stopping on the first layer potentially damaging the build plate self.believe_not_printing = False # Another special case - need to ignore a pause when we're # in temperature model triggered error self.tm_ignore_pause = False # There are attention states that end in a BUSY state, # so the attention does not get cleared. # Let's clear it on a timer instead self.attention_clearing_timer = self.new_attention_timer() # We need to stay in the STOPPED and FINISHED states for a while # for Connect to take and save the last print photo self.print_ended_at = None # Flag to keep track of power panic. # If the printer re-sets because of power panic, we don't want to # send an M603, the flag has to be re-set manually self.in_power_panic = False regex_handlers = { BUSY_REGEX: lambda sender, match: self.busy(), ATTENTION_REGEX: lambda sender, match: self.attention(), PAUSED_REGEX: lambda sender, match: self.filter_pause_events(), RESUMED_REGEX: lambda sender, match: self.resumed(), CANCEL_REGEX: lambda sender, match: self.stopped_or_not_printing(), ERROR_REGEX: lambda sender, match: self.error_handler(), ERROR_REASON_REGEX: self.error_reason_handler, ATTENTION_REASON_REGEX: self.attention_reason_handler, FAN_ERROR_REGEX: self.fan_error, TM_ERROR_CLEARED: self.clear_tm_error, } for regex, handler in regex_handlers.items(): self.serial_parser.add_decoupled_handler(regex, handler) for state in SERIAL: state.add_broke_handler(self.link_error_detected) state.add_fixed_handler(self.link_error_resolved) super().__init__() def new_attention_timer(self): """Creates a new attention clearing timer object""" timer = Timer( interval=ATTENTION_CLEAR_INTERVAL, function=self._attention_timer_handler, ) timer.daemon = True return timer def start_attention_timer(self): """Clears the previous timer and starts a new one""" with self.state_lock: self.stop_attention_timer() self.attention_clearing_timer = self.new_attention_timer() self.attention_clearing_timer.start() def stop_attention_timer(self): """Clears the attention clearing timer if it's running""" with self.state_lock: if self.attention_clearing_timer.is_alive(): self.attention_clearing_timer.cancel() def link_error_detected(self, condition: Condition, old_value: CondState): """increments an error counter once an error gets detected""" if old_value == CondState.OK: log.debug("Condition %s broke, causing an ERROR state", condition.name) if self.expected_state_change is None: self.expect_change( StateChange(to_states={State.ERROR: Source.SERIAL}, reason=condition.short_msg)) self.error() def link_error_resolved(self, condition: Condition, old_value: CondState): """decrements an error counter once an error gets resolved""" if old_value == CondState.NOK: log.debug("Condition %s fixed", condition.name) if SERIAL.successors_ok(): log.debug("All printer conditions are OK") self.error_resolved() def file_printer_started_printing(self): """ If the file printer truly is printing and we don't know about it yet, let's change our state to PRINTING. """ if (self.model.file_printer.printing and self.data.printing_state != State.PRINTING): self.printing() def get_state(self): """ State manager has three levels of importance, the most important state is the one returned. The least important is the base state, followed by printing state and then the override state. """ if self.data.override_state is not None: return self.data.override_state if self.data.printing_state is not None: return self.data.printing_state return self.data.base_state def expect_change(self, change: StateChange): """ Pairing state changes with events that could've caused them is done through expected state changes. This method sets it """ with self.state_lock: self.expected_state_change = change def stop_expecting_change(self): """Resets the expected state change""" with self.state_lock: self.expected_state_change = None def is_expected(self): """Figure out if the state change we are experiencing was expected""" with self.state_lock: state_change = self.expected_state_change expecting_change = state_change is not None if expecting_change: # flake8: noqa expected_to = self.data.current_state in state_change.to_states expected_from = self.data.last_state in state_change.from_states has_default_source = state_change.default_source is not None return expected_to or expected_from or has_default_source return False def get_expected_source(self): """ Figures out who or what could have caused the state change :return: """ with self.state_lock: # No change expected, if self.expected_state_change is None: return None state_change = self.expected_state_change # Get the expected sources source_from = None source_to = None if self.data.last_state in state_change.from_states: source_from = state_change.from_states[self.data.last_state] if self.data.current_state in state_change.to_states: source_to = state_change.to_states[self.data.current_state] # If there are conflicting sources, pick the one, paired with # from_state as this is useful for leaving states like # ATTENTION and ERROR if (source_from is not None and source_to is not None and source_to != source_from): source = source_from else: # no conflict here, the sources are the same, # or one or both of them are None try: # make a list throwing out Nones and get the next item # (the first one) source = next(item for item in [source_from, source_to] if item is not None) except StopIteration: # tried to get next from an empty list source = None if source is None: source = state_change.default_source log.debug( "Source has been determined to be %s. Default was: %s, " "from: %s, to: %s", source, state_change.default_source, source_from, source_to) return source def state_may_have_changed(self): """ Should be called after every internal state change. If the internal state change changed the external reported state, updates the state history and lets everyone know the state change details. """ with self.state_lock: # Did our internal state change cause a reported state change? # If yes, update state stuff if self.get_state() != self.data.current_state: self.believe_not_printing = False self.data.last_state = self.data.current_state self.data.current_state = self.get_state() self.data.state_history.append(self.data.current_state) log.debug("Changing state from %s to %s", self.data.last_state, self.data.current_state) # Now let's find out if the state change was expected # and what parameters can we deduce from that command_id = None source = None reason = None ready = False if self.data.printing_state is not None: log.debug("We are printing - %s", self.data.printing_state) if self.data.override_state is not None: log.debug("State is overridden by %s", self.data.override_state) # If the state changed to something expected, # then send the information about it if self.is_expected(): if self.expected_state_change.command_id is not None: command_id = self.expected_state_change.command_id source = self.get_expected_source() reason = self.expected_state_change.reason ready = self.expected_state_change.ready if reason is not None: log.debug("Reason for %s: %s", self.get_state(), reason) else: log.debug("Unexpected state change. This is weird") self.expected_state_change = None self.pre_state_change_signal.send(self, command_id=command_id) self.state_changed_signal.send( self, from_state=self.data.last_state, to_state=self.data.current_state, command_id=command_id, source=source, reason=reason, ready=ready) self.post_state_change_signal.send(self) def fan_error(self, sender, match: re.Match): """ Even though using these two callables is more complicated, I think the majority of the implementation got condensed into here """ assert sender is not None self.fan_error_name = match.group("fan_name") self.serial_parser.add_decoupled_handler(FAN_REGEX, self.fan_error_resolver) log.debug("%s fan error has been observed.", self.fan_error_name) self.expect_change( StateChange(to_states={State.ATTENTION: Source.FIRMWARE}, reason=f"{self.fan_error_name} fan error")) state = self.get_state() if state not in {State.PRINTING, State.ERROR}: self.attention() def mmu_error_changed(self): """ If the MMU error has changed, enter attention if the error is not None, attempt to leave attention otherwise """ current_error_code = self.model.mmu_observer.current_error_code if current_error_code is None: self.expect_change( StateChange(to_states={State.ATTENTION: Source.SLOT}, reason=current_error_code)) self._clear_attention() else: self.expect_change( StateChange(to_states={State.ATTENTION: Source.SLOT}, reason=current_error_code)) self.attention() self.stop_expecting_change() def fan_error_resolver(self, sender, match): """ If the fan speeds are indicative of a fan error being resolved clears the fan error This is very rudimentary, it only counts with one fan failing at a time, and it will quit the attention only if the firmware/user spins up the fan that's been reported or on print resume and stop weird edge cases expected""" assert sender is not None hotend_fan_rpm = int(match.group("hotend_rpm")) hotend_fan_power = int(match.group("hotend_power")) print_fan_rpm = int(match.group("print_rpm")) print_fan_power = int(match.group("print_power")) hotend_fan_works = hotend_fan_rpm > hotend_fan_power > 0 print_fan_works = print_fan_rpm > print_fan_power > 0 fan_name = self.fan_error_name if (fan_name in {"Extruder", "Hotend"} and hotend_fan_works) or \ (fan_name == "Print" and print_fan_works): self.expect_change( StateChange(from_states={State.ATTENTION: Source.USER}, reason=f"{fan_name} fan error resolved")) self._cancel_fan_error() self.clear_attention() if self.data.printing_state == State.PAUSED: self.resuming_from_fan_error = True def _cancel_fan_error(self): """Removes the fan error""" self.fan_error_name = None self.serial_parser.remove_handler(FAN_ERROR_REGEX, self.fan_error_resolver) def error_handler(self): """ Handle a generic error message. Start waiting for a reason an error was raised. If that times out, sets just a generic error """ if self.data.override_state != State.ERROR: self.data.awaiting_error_reason = True self.error_reason_thread = Thread(target=self.error_reason_waiter, daemon=True) self.error_reason_thread.start() def error_reason_handler(self, sender, match: re.Match): """ Handle a specific error, which requires printer reset """ assert sender is not None groups = match.groupdict() # End the previous reason waiting thread self.error_reason_event.set() self.error_reason_event.clear() reason = self.parse_error_reason(groups) self.expect_change( StateChange(to_states={State.ERROR: Source.MARLIN}, reason=reason)) HW.state = CondState.NOK def attention_reason_handler(self, sender, match: re.Match): """ Handle a message, that is sure to cause an ATTENTION state use it as the reason for going into that state """ assert sender is not None groups = match.groupdict() reason = "unknown" if groups["mbl_didnt_trigger"]: reason = "Bed leveling failed. Sensor didn't trigger. " \ "Is there debris on the nozzle?" elif groups["mbl_too_high"]: reason = "Bed leveling failed. Sensor triggered too high. " elif groups["tm_error"]: end_text = "Resolve the error and reset the printer." if self.data.printing_state == State.PRINTING: end_text = "Print paused." reason = f"The nozzle temperature has deviated too far " \ f"from the expected one. {end_text}" self.tm_ignore_pause = True self.expect_change( StateChange(to_states={State.ATTENTION: Source.MARLIN}, reason=reason)) def filter_pause_events(self): """Filters the action: paused events, notifies the rest This is a giant workaround, this state machine should be separated from the state manager""" if self.tm_ignore_pause: return self.pause_signal.send() self.paused() def clear_tm_error(self, _, match: re.Match): """Clear the TM error flag""" assert match is not None self.tm_ignore_pause = False def power_panic_observed(self): """Set the power panic flag""" self.in_power_panic = True def reset_power_panic(self): """Reset the power panic flag""" self.in_power_panic = False @staticmethod def parse_error_reason(groups): """ Provided error parsed groups, put together a reason explaining why it occurred :param groups: re match group dictionary :return: a reason string """ reason = "" if groups["temp"] is not None: if groups["mintemp"] is not None: reason += "Mintemp" elif groups["maxtemp"] is not None: reason += "Maxtemp" reason += " triggered by the " if groups["bed"] is not None: reason += "heatbed thermistor." else: reason += "hotend thermistor." elif groups["runaway"] is not None: if groups["hotend_runaway"] is not None: reason = "Hotend" elif groups["heatbed_runaway"] is not None: reason = "Heatbed" elif groups["preheat_hotend"] is not None: reason = "Hotend preheat" elif groups["preheat_heatbed"] is not None: reason = "Heatbed preheat" reason += " thermal runaway." reason += " Manual restart required!" return reason def error_reason_waiter(self): """ Waits for an error reason to be provided If it times out, it will warn the user and send "404 reason not found" as the reason. """ if not self.error_reason_event.wait(ERROR_REASON_TIMEOUT): log.warning("Did not capture any explanation for the error state") self.expect_change( StateChange(to_states={State.ERROR: Source.MARLIN}, reason="404 Reason not found")) HW.state = CondState.NOK self.data.awaiting_error_reason = False # --- State changing methods --- def stopped_or_not_printing(self): """ Depending on state, clears the printing state or sets the printing state to STOPPED """ if self.believe_not_printing: if self.data.printing_state in (State.PRINTING, State.PAUSED): self.stopped() else: self.not_printing() else: self.believe_not_printing = True def reset(self): """ On printer reset, the printer is not idle yet, so set the base state to busy. After reset it surely can't carry on printing so take care of that as well :return: """ HW.state = CondState.OK self.busy() self.stopped_or_not_printing() # This state change can change the state to "PRINTING" @state_influencer(StateChange(to_states={State.PRINTING: Source.USER})) def printing(self): """ If not printing or paused, sets printing state to PRINTING :return: """ log.debug("Should be PRINTING") if self.data.printing_state is None or \ self.data.printing_state == State.PAUSED: self.unsure_whether_printing = False self.data.printing_state = State.PRINTING else: log.debug("Ignoring switch to PRINTING base: %s, printing: %s", self.data.base_state, self.data.printing_state) @state_influencer( StateChange(from_states={ State.PRINTING: Source.MARLIN, State.PAUSED: Source.MARLIN, })) def not_printing(self): """ We know we're not printing, keeps FINISHED and STOPPED because the user needs to confirm those manually now """ self.unsure_whether_printing = False if self.data.printing_state not in {State.FINISHED, State.STOPPED}: self.data.printing_state = None @state_influencer(StateChange(to_states={State.FINISHED: Source.MARLIN})) def finished(self): """Sets the printing state to FINISHED if we are printing""" if self.data.printing_state == State.PRINTING: self.print_ended_at = monotonic() self.data.printing_state = State.FINISHED @state_influencer(StateChange(to_states={State.READY: Source.USER})) def ready(self): """If we were IDLE, sets te base state to READY""" if self.data.base_state == State.IDLE: self.data.base_state = State.READY @state_influencer(StateChange(to_states={State.IDLE: Source.USER})) def idle(self): """If we were READY, sets te base state to IDLE""" if self.data.base_state == State.READY: self.data.base_state = State.IDLE @state_influencer(StateChange(to_states={State.BUSY: Source.MARLIN})) def busy(self): """If we were idle, sets te base state to BUSY""" if self.data.base_state in {State.IDLE, State.READY}: self.data.base_state = State.BUSY # Cannot distinguish pauses from the user and the gcode @state_influencer(StateChange(to_states={State.PAUSED: Source.USER})) def paused(self): """If we were printing, sets the printing state to PAUSED""" if self.data.printing_state in {State.PRINTING, None}: self.unsure_whether_printing = False self.data.printing_state = State.PAUSED if self.fan_error_name is not None: self.data.override_state = State.ATTENTION @state_influencer(StateChange(to_states={State.PRINTING: Source.USER})) def resumed(self): """If we were paused, sets the printing state to PRINTING""" if self.data.printing_state == State.PAUSED: self.unsure_whether_printing = False self.data.printing_state = State.PRINTING if self.fan_error_name is not None: self._cancel_fan_error() if self.resuming_from_fan_error: self.resuming_from_fan_error = False @state_influencer(StateChange(from_states={State.PRINTING: Source.USER})) def stopped(self): """ If we were printing or paused, sets the printing state to STOPPED """ if self.data.printing_state in {State.PRINTING, State.PAUSED}: self.unsure_whether_printing = False self.print_ended_at = monotonic() self.data.printing_state = State.STOPPED if self.fan_error_name is not None: self._cancel_fan_error() @state_influencer( StateChange(to_states={State.IDLE: Source.MARLIN}, from_states={ State.ATTENTION: Source.USER, State.ERROR: Source.MARLIN, State.BUSY: Source.HW, State.FINISHED: Source.MARLIN, State.STOPPED: Source.MARLIN, }, ready=False)) def instruction_confirmed(self): """ Instruction confirmation shall clear all temporary states Starts at the least important so it generates only one state change """ if self.unsure_whether_printing: return if self.data.base_state == State.BUSY: self.data.base_state = State.IDLE if self.data.printing_state in {State.STOPPED, State.FINISHED} and \ self.data.override_state is not State.ATTENTION: if monotonic() > self.print_ended_at + PRINT_END_TIMEOUT: self.data.printing_state = None # Make sure that if we just finished a print, or we # stopped one, we return to IDLE if self.data.base_state == State.READY: self.data.base_state = State.IDLE self._clear_attention() def _attention_timer_handler(self): """Handles the attention timer running out.""" with self.state_lock: self.expect_change( StateChange(from_states={State.ATTENTION: Source.MARLIN}, reason="The ATTENTION state has stopped being " "reported by the printer")) self.clear_attention() def _clear_attention(self): """Clears the ATTENTION state, if the conditions are right""" if self.data.override_state != State.ATTENTION: return if self.fan_error_name is not None: return if self.model.mmu_observer.current_error_code is not None: return log.debug("Clearing ATTENTION") self.data.override_state = None self.stop_attention_timer() @state_influencer(StateChange(from_states={State.ATTENTION: Source.USER})) def clear_attention(self): """Calls the internal method for clearing the attention state""" self._clear_attention() @state_influencer(StateChange(to_states={State.ATTENTION: Source.USER})) def attention(self): """ Sets the override state to ATTENTION """ if self.resuming_from_fan_error: self.expect_change( StateChange(to_states={State.ATTENTION: Source.MARLIN}, reason="Most likely a false positive. " "Sorry about that 😅")) self.start_attention_timer() log.debug("Overriding the state with ATTENTION") log.warning("State was %s", self.get_state()) self.data.override_state = State.ATTENTION @state_influencer(StateChange(to_states={State.ERROR: Source.WUI})) def error(self): """Sets the override state to ERROR""" log.debug("Overriding the state with ERROR") self.data.override_state = State.ERROR @state_influencer(StateChange(from_states={State.ERROR: Source.USER})) def error_resolved(self): """Removes the override ERROR state""" if self.data.override_state == State.ERROR and \ SERIAL.successors_ok(): log.debug("Cancelling the ERROR state override") self.data.override_state = None @state_influencer( StateChange(to_states={State.ERROR: Source.SERIAL}, reason="Communication with the printer has failed")) def serial_error(self): """ Also sets the override state to ERROR but has a different default source """ log.debug("Serial ERROR overrode state") self.data.override_state = State.ERROR @state_influencer( StateChange(to_states={State.IDLE: Source.SERIAL}, reason="Re-established the communication " "with the printer")) def serial_error_resolved(self): """Resets the error state if there is any""" if self.data.override_state == State.ERROR: log.debug("Serial ERROR resolved, removing override") self.data.override_state = None ================================================ FILE: prusa/link/printer_adapter/structures/__init__.py ================================================ ================================================ FILE: prusa/link/printer_adapter/structures/carousel.py ================================================ """Implements the helper classes for LCD printer. Does not depend on the rest of the PrusaLink app""" import math from collections import deque from copy import copy from time import time from typing import Deque, List, Optional, Set class LCDLine: """Info about the text to show and the chime to play""" def __init__(self, text: str, delay: float = 5.0, resets_idle: bool = False, chime_gcode: Optional[List[str]] = None) -> None: self.text: str = text self.delay: float = delay self.chime_gcode: List[str] = [] if chime_gcode is not None: self.chime_gcode = chime_gcode self.resets_idle = resets_idle self.ends_at = time() + self.delay def reset_end(self): """Resets the message's end time, used, so the delay is the minimum time the message is shown on screen""" self.ends_at = time() + self.delay class Screen: """A Screen - like an error screen, or an easter egg scrolling screen""" def __init__(self, resets_idle=True, chime_gcode=None, order=0): """ :param resets_idle: Do the messages from this screen reset the idle timer? :param chime_gcode: The gcode to play when this screen is enabled :param order: This is static, but could be made dynamic. The order of screens in case there's more with the same priority. Smallest goes first """ self.resets_idle = resets_idle self.chime_gcode = [] if chime_gcode is not None: self.chime_gcode = chime_gcode self.conditions = {} self.changed = False self.text = "" self.scroll_delay = 2.0 self.first_line_extra = 2.0 self.scroll_amount = 10 self.last_line_extra = 1.0 # only the things with the highest priority get displayed self.priority = 0 # if there are more than one, they get ordered by this number self.order = order self.enabled = False self.to_chime = False def __str__(self): return f"A Screen saying {self.text}" def lines(self): """The status display has 19 usable chars (20) Iterating over this cuts the text into displayable messages (lines) to output, so a scrolling or paginated appearance can be achieved""" remaining_text = self.text while (last_index := len(remaining_text) - 19) > 0: line = LCDLine(remaining_text[:19], delay=self.scroll_delay, resets_idle=self.resets_idle) if remaining_text == self.text: line.delay += self.first_line_extra actual_scroll_amount = min(self.scroll_amount, last_index) remaining_text = remaining_text[actual_scroll_amount:] yield line yield LCDLine(remaining_text[:19], delay=self.scroll_delay + self.last_line_extra) class Carousel: """Manages Screens and spurious messages Ignores the timing, focuses just on what line and screen to show if asked """ def __init__(self, screens: List[Screen]): self.screens = set(screens) self.enabled_screens: Set[Screen] = set() self.active_set: Set[Screen] = set() self.active_screens: List[Screen] = [] self.current_screen = None self.to_rewind = False self.messages: Deque[LCDLine] = deque() self.line_generator = self._lines() def _lines(self): """Iterating over this goes over every enabled screen with the highest priority. More screens on the same priority are supported""" for self.current_screen in copy(self.active_screens): for line in self.current_screen.lines(): if self.to_rewind: self.current_screen = None return if self.current_screen.to_chime: self.current_screen.to_chime = False line.chime_gcode = self.current_screen.chime_gcode line.resets_idle = self.current_screen.resets_idle yield line def get_next(self): """Handles giving out lines to show. The spurious messages have priority""" if self.messages: self.set_rewind() return self.messages.popleft() try: return next(self.line_generator) except (StopIteration, TypeError): self._rewind() try: return next(self.line_generator) except (StopIteration, TypeError): return None # nothing to show def _rewind(self): """Re-winds to the start. This updates what lines will get output""" self.to_rewind = False self.line_generator = self._lines() def set_rewind(self): """Marks the carousel for a re-wind. Next time a line will be requested, the carousel will start from the first Line on the first Screen""" self.to_rewind = True def add_message(self, line: LCDLine): """Adds a "spurious" message to be displayed. Long ones (over 19 chars) aren't supported""" self.messages.append(line) def verify_tracked(self, screen): """If the screen isn't tracked, complains""" if screen not in self.screens: raise ValueError("This screen is not in the carousel") # pylint: disable=too-many-arguments def set_text(self, screen, text, scroll_delay=2.0, first_line_extra=2.0, scroll_amount=10, last_line_extra=1.0): """ Given text and parameters, it sets up the "screen" with your text text: Text longer than 19 character gets converted into multiple lines scroll delay: each screen will wait this amount before scrolling again first_line_extra: Extra seconds to wait on the first screen scroll_amount: How many characters to scroll > 0 last_line_extra: How much longer to wait on the last screen If the text fits on a one line, set the extra delays to 0 and use just the scroll delay. Anything else is undefined The splitting functionality is in the Screen itself Setting text to an active screen rewinds the carousel. No way to rewind just the current screen as of now """ self.verify_tracked(screen) screen.changed = True screen.text = text screen.scroll_delay = scroll_delay screen.first_line_extra = first_line_extra screen.scroll_amount = scroll_amount screen.last_line_extra = last_line_extra self._react() def enable(self, screen: Screen, silent=False): """Enables a screen, if it's a one with a greater or equal priority than those currently shown, it will get shown""" self.verify_tracked(screen) if screen in self.enabled_screens: return # Has no effect screen.to_chime = not silent self.enabled_screens.add(screen) self._react() def set_priority(self, screen: Screen, priority): """Sets a priority to a screen, if it ends up with a higher or equal one, than the ones shown, and is enabled, it will get shown""" self.verify_tracked(screen) if priority == screen.priority: return # has no effect screen.priority = priority self._react() def disable(self, screen: Screen): """Disables a Screen, if currently being shown, gets hidden""" self.verify_tracked(screen) if screen not in self.enabled_screens: return # has no effect self.enabled_screens.remove(screen) self._react() def is_enabled(self, screen: Screen): """Is the specified screen enabled?""" return screen in self.enabled_screens def get_set_to_show(self): """What screens should get shown according to the current state""" try: priority_item = max(self.enabled_screens, key=lambda i: i.priority) max_priority = priority_item.priority except ValueError: max_priority = -1 * math.inf return {s for s in self.enabled_screens if s.priority == max_priority} def _react(self): """Reacts to the changes in Screen settings. Sets active screens according to the Screen settings/state""" if (new_set := self.get_set_to_show()) != self.active_set or \ any((s.changed for s in self.active_set)): self.set_rewind() self.active_set = new_set for screen in self.active_set: screen.changed = False self.active_screens = sorted(new_set, key=lambda i: i.order) ================================================ FILE: prusa/link/printer_adapter/structures/heap.py ================================================ """ Contains implementation of the HeapItem, MinHeap and MaxHeap classes I HAVE COPIED THIS FROM THE INTERNET! It turns out that popping the last item in the queue was broken """ from typing import List class HeapItem: """An item in the heap. Needs to be comparable""" def __init__(self, value): self.value = value self.heap_value = None self.heap_index = None def __gt__(self, other): if isinstance(other, HeapItem): return self.heap_value > other.heap_value raise TypeError("HeapItems can be compared only with each other") def __ge__(self, other): if isinstance(other, HeapItem): return self.heap_value >= other.heap_value raise TypeError("HeapItems can be compared only with each other") def __lt__(self, other): if isinstance(other, HeapItem): return self.heap_value < other.heap_value raise TypeError("HeapItems can be compared only with each other") def __le__(self, other): if isinstance(other, HeapItem): return self.heap_value <= other.heap_value raise TypeError("HeapItems can be compared only with each other") def __eq__(self, other): if isinstance(other, HeapItem): return self.heap_value == other.heap_value raise TypeError("HeapItems can be compared only with each other") def __hash__(self): return hash(self.heap_value) class MinHeap: """Min heap implementation with element adding and removing""" def __init__(self) -> None: self.heap: List[HeapItem] = [] def __len__(self): return len(self.heap) def __bool__(self): return bool(self.heap) def __getitem__(self, key): return self.heap[key] def __setitem__(self, key, value): self.heap[key] = value def push(self, item: HeapItem): """Ads an element to the heap""" item.heap_value = item.value self._push(item) def _push(self, item): """ Adds an element to the heap In min heaps this is done by adding the element at the end, then switching places with parents larger than it """ self.heap.append(item) initial_index = len(self.heap) - 1 self.sift_down(0, initial_index) def pop(self, index: int = 0) -> HeapItem: """ Removes an element from the heap In min heaps this is done by swapping the element with the last element in the heap, then removing the last element, (which is now the thing we wanted to delete). After that depending on the value of the element that replaced the deleted one sifting it up or down """ old_item: HeapItem = self.heap[index] old_value = old_item.heap_value if old_item.heap_index != index: raise RuntimeError("Item index and actual index differ. NOOOOO!") new_item = self.heap.pop() # The first one checks if we didn't remove the last item from the # heap, if we did, there is nothing else that needs to be done if index != len(self) and self: new_value = new_item.heap_value self.heap[index] = new_item if new_value > old_value: self.sift_up(index) else: self.sift_down(0, index) return old_item def sift_up(self, pos): """ Compares an element with its children, if the element is larger, its position gets swapped with the smaller child. Continues until there are no children smaller than the element """ endpos = len(self.heap) startpos = pos newitem = self.heap[pos] # Bubble up the smaller child until hitting a leaf. childpos = 2 * pos + 1 # leftmost child position while childpos < endpos: # Set childpos to index of smaller child. rightpos = childpos + 1 if rightpos < endpos and \ not self.heap[childpos] < self.heap[rightpos]: childpos = rightpos # Move the smaller child up. self.heap[pos] = self.heap[childpos] self.heap[pos].heap_index = pos pos = childpos childpos = 2 * pos + 1 # The leaf at pos is empty now. Put newitem there, and bubble it up # to its final resting place (by sifting its parents down). self.heap[pos] = newitem self.heap[pos].heap_index = pos self.sift_down(startpos, pos) def sift_down(self, startpos, pos): """ The element gets compared with its parent, if it's smaller, they get swapped. Continues until it finds a parent smaller than itself, or the element becomes the root of the heap :param startpos: :param pos: :return: """ newitem = self.heap[pos] # Follow the path to the root, moving parents down until finding # a place newitem fits. while pos > startpos: parentpos = (pos - 1) >> 1 parent = self.heap[parentpos] if newitem < parent: self.heap[pos] = parent parent.heap_index = pos pos = parentpos continue break self.heap[pos] = newitem newitem.heap_index = pos class MaxHeap(MinHeap): """ Lazily implemented max heap by using the min heap, just inverting the heap value """ def push(self, item: HeapItem): item.heap_value = -item.value self._push(item) ================================================ FILE: prusa/link/printer_adapter/structures/item_updater.py ================================================ """Implements classes for monitoring and updating arbitrary values""" import logging from math import inf from multiprocessing import Event from queue import Empty, PriorityQueue, Queue from threading import RLock, Thread from time import time from typing import Any, Callable, Iterable, Optional, Set from blinker import Signal # type: ignore from ...util import prctl_name log = logging.getLogger(__name__) class SideEffectOnly(Exception): """An exception to raise in a gatherer that has nothing to return, but its side effects succeeded in setting a value or have otherwise ensured that the value would be received eventually""" class Watchable: """Encapsulates the common stuff between watched values and groups""" def __init__(self): self.valid = False self.became_valid_signal = Signal() self.became_invalid_signal = Signal() class WatchedItem(Watchable): """ A value, that can be polled or set. Can be tracked in the info updater """ # Set to None to disable automatic refreshes on read/validation fails default_on_fail_interval = 5 # pylint: disable=too-many-arguments def __init__(self, name, gather_function: Optional[Callable[[], Any]] = None, write_function: Optional[Callable[[Any], None]] = None, validation_function: Optional[Callable[[Any], bool]] = None, interval=None, timeout=None, on_fail_interval=default_on_fail_interval): super().__init__() self.name = name self.value: Any = None self.lock = RLock() self.in_groups: Set["WatchedGroup"] = set() self.scheduled = False # Are we scheduled for a value refresh # Imprecise timing intended self.interval = interval # If set, gets invalidated each interval self.disabled = False # If True, the interval is overridden with None self.on_fail_interval = on_fail_interval # Refresh reschedule timeout self.timeout = timeout # How long can we be invalid, before timing out # internal timestamps self.invalidate_at = inf self.times_out_at = inf # pylint: disable=unused-argument def _default_validation(value): return True # pylint: disable=unused-argument def _default_write(value): ... if validation_function is None: validation_function = _default_validation if write_function is None: write_function = _default_write # A function that returns a value, or throws an error # If it returns None, The value is not written and the item gets # re-scheduled self.gather_function: Optional[Callable[[], Any]] = gather_function # If valid, returns Ture, if not, throws an error or returns False self.validation_function: Callable[[Any], bool] = validation_function # Takes care of putting the value in the right places # Shall not throw anything EVER! self.write_function: Callable[["WatchedItem"], None] = write_function # -- Signals -- self.timed_out_signal = Signal() self.error_refreshing_signal = Signal() self.validation_error_signal = Signal() # kwargs: validation exception self.value_changed_signal = Signal() # sender is the value # Combined gather error signal self.val_err_timeout_signal = Signal() def __repr__(self): return super().__repr__() + ": " + self.name def __lt__(self, other): if not isinstance(other, WatchedItem): return NotImplemented return self.name < other.name def __eq__(self, other): if not isinstance(other, WatchedItem): return NotImplemented return self.name == other.name def __hash__(self): return hash(self.name) class WatchedGroup(Watchable): """ A group of watched items. Aggregates the validity signals from its members """ def __init__(self, items: Iterable[WatchedItem]): super().__init__() if not items: raise ValueError( "Supply at least one item, or group to be watched") self.all_items = list(items) self.valid_items = set() self.invalid_items = set() for item in items: # Tracking using these signals, item.in_groups.add(self) if item.valid: self.valid_items.add(item) else: self.invalid_items.add(item) if not self.invalid_items: self.valid = True def __iter__(self): return self.all_items.__iter__() def invalid_handler(self, item): """ A member became invalid. Moves the member to the invalid pile If the group was valid, it's not anymore and that gets signalled """ self.valid_items.remove(item) self.invalid_items.add(item) if self.valid: self.valid = False self.became_invalid_signal.send(self) def valid_handler(self, item): """ A member became valid. Moves the member to the valid pile If all members are valid, sends a signal """ self.invalid_items.remove(item) self.valid_items.add(item) if not self.valid and not self.invalid_items: self.valid = True self.became_valid_signal.send(self) class ItemUpdater: """ This governs some defined variables Variables can be made to be refreshed manually, or on a timer Variable getters can time out, which sends out a signal Variables can be validated On validation or read error, variable refresh can be re-scheduled automatically on a timer """ def __init__(self, quit_interval=0.2): self.quit_interval = quit_interval self.running = True self.invalidate_timers = PriorityQueue() self.invalidate_queue_event = Event() self.timeout_timers = PriorityQueue() self.timeout_queue_event = Event() self.refresh_queue = Queue() self.refresher_thread = Thread(target=self._refresher, name="polling", daemon=True) self.invalidator_thread = Thread(target=self._process_invalidations, name="item_invalidator", daemon=True) self.timeout_thread = Thread(target=self._process_timeouts, name="polling_timeout", daemon=True) self.items = set() def start(self): """Starts up the governing threads""" self.refresher_thread.start() self.invalidator_thread.start() self.timeout_thread.start() def stop(self): """Stops the value tracker""" self.running = False self.invalidate_queue_event.set() self.timeout_queue_event.set() def wait_stopped(self): """waits for the value tracker to quit""" self.invalidator_thread.join() self.timeout_thread.join() self.refresher_thread.join() def add_item(self, item: WatchedItem, start_tracking=True): """ Only invalid items can be added for now :param item: The item to add to watched ones :param start_tracking: Whether to invalidate the item. Without this, the item does not gather its value and has to be invalidated manually """ if not issubclass(type(item), WatchedItem): raise TypeError("Can't track something, that isn't a WatchedItem.") self.items.add(item) if start_tracking: self.invalidate(item) def invalidate_group(self, group: WatchedGroup): """ Invalidates every item of the supplied WatchedGroup """ for group_item in group: self.invalidate(group_item) def invalidate(self, item: WatchedItem): """ Invalidates the item, putting it into the queue for validation If the object has a timeout, sets up the timer for it Calling repeatedly should not affect anything, the first invalidation matters If the item already is invalidated but is not scheduled for a refresh, it gets scheduled """ self._validate_is_tracked(item) with item.lock: if item.disabled: log.debug("Will not invalidate item %s because it's disabled.", item.name) return log.debug("Item %s has been invalidated", item.name) item.invalidate_at = inf if item.valid: item.valid = False for group in item.in_groups: group.invalid_handler(item) item.became_invalid_signal.send(item) if not item.scheduled: self._enqueue_refresh(item) def disable(self, item: WatchedItem): """Disables the item polling without changing its interval""" self._validate_is_tracked(item) with item.lock: if item.disabled: return item.disabled = True self.cancel_scheduled_invalidation(item) def enable(self, item: WatchedItem): """Enables the item polling without changing its interval""" self._validate_is_tracked(item) with item.lock: if not item.disabled: return item.disabled = False self.invalidate(item) def set_value(self, item: WatchedItem, value): """ Validates the value and writes it Forcefully re-schedules invalidation. This can be used to enable polling, when auto reporting stops for example """ self._validate_is_tracked(item) with item.lock: try: if not item.validation_function(value): raise ValueError(f"Invalid value for {item.name}: {value}") # pylint: disable=broad-except except Exception: log.debug("Validation of item %s has failed", item.name) item.validation_error_signal.send(item) item.val_err_timeout_signal.send(item) # If the item is valid, do not schedule a gather, as this # probably was a setter from the outside with a bad value if not item.valid: self._gather_error_reschedule(item) else: log.debug("Value of item %s has been determined to be %s", item.name, value) self._set_value(item, value) def schedule_invalidation(self, item: WatchedItem, interval=None, reschedule=False): """ Schedules an item invalidation at a certain time Will not shift already scheduled invalidation unless forced to If an already invalid item is scheduled for example after a gather/validation error, it is just added to the refresh queue without emitting any additional signals :param item: The item to schedule invalidation for. :param interval: How long in the future should we invalidate? If left empty, the default is used, if that's None an error will be raised :param reschedule: If an invalidation is already scheduled, it won't get re-scheduled unless this is True """ self._validate_is_tracked(item) with item.lock: if item.disabled: log.debug("Will not schedule item %s because it is disabled.", item.name) return if item.invalidate_at != inf and not reschedule: log.debug( "Will not schedule an invalidation for item %s because " "another is already scheduled", item.name) return if interval is None: interval = item.interval if interval is None: raise AttributeError(f"No interval specified for item " f"{item.name} has no default and none" f" has been provided!") log.debug( "Scheduling invalidation of item %s for %ss in " "the future", item.name, interval) item.invalidate_at = time() + interval self.invalidate_timers.put((item.invalidate_at, item)) self.invalidate_queue_event.set() def cancel_scheduled_invalidation(self, item: WatchedItem): """ Cancels the scheduled invalidation. The timer itself cannot be cancelled, but the invalidate_at value has to match before anything is executed. Changing it to infinity will accomplish that nicely """ self._validate_is_tracked(item) with item.lock: if item.invalidate_at == inf: return log.debug("Cancelling scheduled invalidation of item %s ", item.name) item.invalidate_at = inf # -- Private -- @staticmethod def _time_out(item: WatchedItem): """ Times out the item, notifying everyone of the fail :return: """ with item.lock: log.warning("Timed out when getting item %s", item.name) item.times_out_at = inf item.timed_out_signal.send(item) item.val_err_timeout_signal.send(item) def _validate_is_tracked(self, item: WatchedItem): if item not in self.items: raise ValueError( f"Item {item.name} is not tracked by this instance.") def _gather(self, item: WatchedItem): """ Refreshes the item value, if the item has a refresh interval, sets up the timed invalidation If the value gathering throws an error, it re-schedules its refresh and notifies of a fail """ if item.valid: return # Items without gather functions have no point in spinning, # something else needs to take care of them if item.gather_function is None: return log.debug("Gathering new value for item %s", item.name) try: value = item.gather_function() # pylint: disable=broad-except except SideEffectOnly: # Special case for gatherers with just side effects # Useful for when the value is autoreported and gather needs # to only turn the reporting on # If the gatherer sets its own items value, then let's not # re-schedule anything if not item.valid: # Counting on set_item cancelling the re-schedule self._gather_error_reschedule(item) except Exception: with item.lock: log.exception("Gather of %s has failed", item.name) item.error_refreshing_signal.send(item) item.val_err_timeout_signal.send(item) self._gather_error_reschedule(item) else: with item.lock: self.set_value(item, value) def _gather_error_reschedule(self, item): """ Reschedules the value refresh on gather or validation errors Reschedules only if the reschedule interval is set (default = 5s) """ with item.lock: if item.on_fail_interval is not None: log.debug( "Rescheduling gather of item %s for " "%ss in the future", item.name, item.on_fail_interval) self.schedule_invalidation(item, item.on_fail_interval) def _set_value(self, item, value): """ Internal, only sets the value without validation Should be pre-validate before this gets called """ with item.lock: changed = value != item.value if changed: log.debug("Item %s got a new value! old: %s new: %s", item.name, item.value, value) item.value = value item.write_function(value) was_invalid = not item.valid item.valid = True item.times_out_at = inf if item.interval is not None: self.schedule_invalidation(item, reschedule=True) if was_invalid: for group in item.in_groups: group.valid_handler(item) item.became_valid_signal.send(item) if changed: item.value_changed_signal.send(value) def _enqueue_refresh(self, item): """ Forcefully enqueues the item for refresh Does not re-schedule the time out. If the item failed to gather for example, it gets re-scheduled. But has to time out in the set time since it is invalid for more than X seconds :param item: :return: """ with item.lock: if item.timeout is not None and item.times_out_at == inf: item.times_out_at = time() + item.timeout self.timeout_timers.put((item.times_out_at, item)) item.scheduled = True self.refresh_queue.put(item) def _refresher(self): """ Processes all values queued up for refreshing """ prctl_name() while self.running: try: item = self.refresh_queue.get(timeout=self.quit_interval) except Empty: pass else: with item.lock: item.scheduled = False self._gather(item) def _process_invalidations(self): """ Processes the invalidation queue. If a timer is checked and does not match with the set timer on an item, it is discarded, so only valid timers call their callbacks :return: """ prctl_name() while self.running: try: invalidate_at, item = self.invalidate_timers.get( timeout=self.quit_interval) except Empty: pass else: # Check if the timer is valid if invalidate_at != item.invalidate_at: continue current_time = time() if invalidate_at > current_time: self.invalidate_timers.put((invalidate_at, item)) self.invalidate_queue_event.wait(invalidate_at - current_time) self.invalidate_queue_event.clear() else: self.invalidate(item) def _process_timeouts(self): """ Same as invalidators, except its timeouts """ prctl_name() while self.running: try: times_out_at, item = self.timeout_timers.get( timeout=self.quit_interval) except Empty: pass else: # Check if the timer is valid if times_out_at != item.times_out_at: continue current_time = time() if times_out_at > current_time: self.timeout_timers.put((times_out_at, item)) self.timeout_queue_event.wait(times_out_at - current_time) self.timeout_queue_event.clear() else: self._time_out(item) ================================================ FILE: prusa/link/printer_adapter/structures/mc_singleton.py ================================================ """Contains implementation of the MCSingleton class""" class MCSingleton(type): """ Classes that use this metaclass are singletons """ def __init__(cls, name, bases, dic): cls.__instance = None cls.get_instance = lambda: cls.__instance super().__init__(name, bases, dic) def __call__(cls, *args, **kwargs): if cls.__instance is not None: raise RuntimeError("There can be only one singleton in existence") instance = cls.__new__(cls) instance.__init__(*args, **kwargs) cls.__instance = instance return instance ================================================ FILE: prusa/link/printer_adapter/structures/model_classes.py ================================================ """ Contains models that were originally intended for sending to the connect. Pydantic makes a great tool for cleanly serializing simple python objects, while enforcing their type """ from enum import Enum from typing import Dict, Optional from pydantic import BaseModel class IndividualSlot(BaseModel): """Support the slot number specific telemetry structure""" material: Optional[str] = None temp: Optional[float] = None fan_hotend: Optional[int] = None fan_print: Optional[int] = None class Slot(BaseModel): """Support the telemetry item described here: https://connect.prusa3d.com/docs/mmu (Internal doc)""" active: Optional[int] = None state: Optional[int] = None progress: Optional[int] = None command: Optional[str] = None slots: Optional[Dict[str, IndividualSlot]] = None def dict(self, **kwargs) -> Dict: """Override the dict method to respect the Connect telemetry API""" data = super().dict(**kwargs) if "slots" in data and data["slots"] is not None: slots = data.pop("slots") data.update(slots) return data class Telemetry(BaseModel): """The Telemetry model""" # time_remaining is deprecated, kept for compatibility temp_nozzle: Optional[float] = None temp_bed: Optional[float] = None target_nozzle: Optional[float] = None target_bed: Optional[float] = None axis_x: Optional[float] = None axis_y: Optional[float] = None axis_z: Optional[float] = None fan_extruder: Optional[int] = None fan_hotend: Optional[int] = None fan_print: Optional[int] = None target_fan_extruder: Optional[int] = None target_fan_hotend: Optional[int] = None target_fan_print: Optional[int] = None progress: Optional[int] = None filament: Optional[str] = None flow: Optional[int] = None speed: Optional[int] = None time_printing: Optional[int] = None time_transferring: Optional[int] = None time_remaining: Optional[int] = None odometer_x: Optional[int] = None odometer_y: Optional[int] = None odometer_z: Optional[int] = None odometer_e: Optional[int] = None material: Optional[str] = None total_filament: Optional[int] = None total_print_time: Optional[int] = None filament_change_in: Optional[int] = None inaccurate_estimates: Optional[bool] = None slot: Optional[Slot] = None def dict(self, **kwargs) -> Dict: data = super().dict(**kwargs) if self.slot is not None: data['slot'] = self.slot.dict(**kwargs) return data class NetworkInfo(BaseModel): """The Network Info model""" lan_ipv4: Optional[str] = None # not implemented yet lan_ipv6: Optional[str] = None # not implemented yet lan_mac: Optional[str] = None # not implemented yet wifi_ipv4: Optional[str] = None wifi_ipv6: Optional[str] = None # not implemented yet wifi_mac: Optional[str] = None wifi_ssid: Optional[str] = None # not implemented yet hostname: Optional[str] = None username: Optional[str] = None digest: Optional[str] = None class FileType(Enum): """File type enum""" FILE = "FILE" FOLDER = "FOLDER" STORAGE = "STORAGE" class JobState(Enum): """Job state enum""" IDLE = "IDLE" IN_PROGRESS = "IN_PROGRESS" ENDING = "ENDING" class SDState(Enum): """SD State enum""" PRESENT = "PRESENT" INITIALISING = "INITIALISING" UNSURE = "UNSURE" ABSENT = "ABSENT" class PrintState(Enum): """States which the printer can report on its own""" SD_PRINTING = "SD_PRINTING" SD_PAUSED = "SD_PAUSED" SERIAL_PAUSED = "SERIAL_PAUSED" NOT_SD_PRINTING = "NOT_SD_PRINTING" class PrintMode(Enum): """The "Mode" from the printer LCD settings""" SILENT = "SILENT" NORMAL = "NORMAL" AUTO = "AUTO" class EEPROMParams(Enum): """List of EEPROM addresses read by PrusaLink""" JOB_ID = 0x0D05, 4 FLASH_AIR = 0x0FBB, 1 PRINT_MODE = 0x0FFF, 1 SHEET_SETTINGS = 0x0D49, 88 ACTIVE_SHEET = 0x0DA1, 1 TOTAL_FILAMENT = 0x0FF1, 4 TOTAL_PRINT_TIME = 0x0FED, 4 EEPROM_FILE_POSITION = 0x0F91, 4 class PPData(BaseModel): """Not things like length or diameter, just path and the command number -> gcode command number""" file_path: str connect_path: str message_number: int # N number on the printer gcode_number: int # From file printer using_rip_port: bool = False ================================================ FILE: prusa/link/printer_adapter/structures/module_data_classes.py ================================================ """ Decided that keeping module data externally will aid with gathering them for the api, definitions of which is what this module contains """ from typing import Any, Deque, Dict, List, Optional, Set from prusa.connect.printer.const import State from pydantic import BaseModel from .model_classes import JobState, SDState # pylint: disable=too-few-public-methods class Port(BaseModel): """Data known about a port""" path: str is_rpi_port: bool = False checked: bool = False # False if it has not been finished checking usable: bool = False # We can probably use this port for communication selected: bool = False # PrusaLink selected to use this port description: str = "Unknown" # A nice human-readable status baudrate: int = 115200 timeout: int = 2 sn: Optional[str] = None # Save the USB descriptor SN if valid def __str__(self): return (f"Port: {self.path}, " f"Checked: {self.checked}, " f"Usable: {self.usable}, " f"Selected: {self.selected}, " f"RPi port: {self.is_rpi_port}, " f"Description: {self.description}") class SerialAdapterData(BaseModel): """Data of the SerialAdapter class""" ports: List[Port] = [] using_port: Optional[Port] reset_disabling: bool = True resets_enabled: Optional[bool] = None class FilePrinterData(BaseModel): """Data of the FilePrinter class""" file_path: str pp_file_path: str printing: bool recovering: bool paused: bool was_stopped: bool power_panic: bool recovery_ready: bool # In reality Deque[Instruction] but that cannot be validated by pydantic enqueued: Deque[Any] gcode_number: int class StateManagerData(BaseModel): """Data of the StateManager class""" # The ACTUAL states considered when reporting base_state: State printing_state: Optional[State] override_state: Optional[State] # Reported state history last_state: State current_state: State state_history: Deque[State] awaiting_error_reason: bool class JobData(BaseModel): """Data of the Job class""" job_id: Optional[int] job_id_offset: int already_sent: Optional[bool] job_start_cmd_id: Optional[int] selected_file_path: Optional[str] selected_file_m_timestamp: Optional[int] selected_file_size: Optional[str] printing_file_byte: Optional[int] path_incomplete: Optional[bool] from_sd: Optional[bool] inbuilt_reporting: Optional[bool] last_job_path: Optional[str] job_state: JobState def get_job_id_for_api(self): """ The API does not send None values. This function returns None when no job is running, otherwise it gives the job_id """ if self.job_state == JobState.IDLE: return None return self.job_id class IPUpdaterData(BaseModel): """Data of the IpUpdater class""" local_ip: Optional[str] local_ip6: Optional[str] mac: Optional[str] is_wireless: bool update_ip_on: float ssid: Optional[str] hostname: Optional[str] username: Optional[str] digest: Optional[str] class SDCardData(BaseModel): """Data of the SDCard class""" expecting_insertion: bool invalidated: bool is_flash_air: bool last_updated: float last_checked_flash_air: float sd_state: SDState files: Any # We cannot type-check SDFile, only basic ones sfn_to_lfn_paths: Dict[str, str] lfn_to_sfn_paths: Dict[str, str] mixed_to_lfn_paths: Dict[str, str] class StorageData(BaseModel): """Data of the Storage class""" blacklisted_paths: List[str] blacklisted_names: List[str] configured_storage: Set[str] attached_set: Set[str] class MMUObserverData(BaseModel): """Data of the MMUObserver""" current_error_code: Optional[str] class PrintStatsData(BaseModel): """Data of the PrintStats class""" print_time: float segment_start: float has_inbuilt_stats: bool total_gcode_count: int # is not computed for files containg reporting # to speed stuff up start_gcode_number: int = 0 class Sheet(BaseModel): """Data available for sheets in the printer EEPROM""" name: str = "" z_offset: float = 0.0 # temps at the time of calibration bed_temp: int = 0 pinda_temp: int = 0 ================================================ FILE: prusa/link/printer_adapter/structures/regular_expressions.py ================================================ """Contains every regular expression used in the app as a constant""" import re from ...const import MMU_PROGRESS_MAP OPEN_RESULT_REGEX = re.compile( r"^((?PFile opened): (?P.*) Size: (?P\d+))" r"|(?Popen failed).*") PRINTER_TYPE_REGEX = re.compile(r"^(?P\d{3,5})$") FW_REGEX = re.compile(r"^(?P\d+\.\d+\.\d+-.*)$") SN_REGEX = re.compile(r"^(?P^CZPX\d{4}X\d{3}X.\d{5})|" r"(?PSN invalid)|(?P.*)$") VALID_SN_REGEX = re.compile(r"^(?P^CZPX\d{4}X\d{3}X.\d{5})$") NEW_SN_REGEX = re.compile( r"^(?P^SN(?!20)[2-9][0-9](004|017|022|023|024|025)[K,C]\d{6})$") NOZZLE_REGEX = re.compile(r"^(?P\d\.\d+)$") PERCENT_REGEX = re.compile(r"^(?P\d{0,3})%$") VALID_USERNAME_REGEX = re.compile(r"^[!#-9;-~][ -!#-9;-~]{1,254}[!#-9;-~]$") # Three options of the password format # >= 8 chars, one lowercase letter, one uppercase letter, one number PASS_OPT1 = r"((?=.*[a-z])(?=.*[A-Z])(?=.*\d))[\w]{8,}$" # >= 8 chars, one non-alphanumeric character PASS_OPT2 = r"((?=.*\W)(?=.*[\w])[\w\W]{8,})$" # >= 15 chars PASS_OPT3 = r"[\w\W]{15,}$" VALID_PASSWORD_REGEX = re.compile(f"^({PASS_OPT1}|{PASS_OPT2}|{PASS_OPT3})") LFN_CAPTURE = re.compile( r"^(?PBegin file list)|" r"(?PDIR_ENTER: (?P/[^ ]*/) \"(?P[^\"]*)\")|" r"(?P(?P.*\.(?PGCO|G)) " r"((0x(?P[0-9a-fA-F]+) ?)|(?P\d+ ?)|" r"(\"(?P[^\"]*)\") ?)*)|" r"(?PDIR_EXIT)|" r"(?PEnd file list)$") SD_PRESENT_REGEX = re.compile(r"^(?Pecho:SD card ok)|" r"(?P(echo:SD init fail)|" r"(Error:volume\.init failed)|" r"(Error:openRoot failed))$") SD_EJECTED_REGEX = re.compile(r"^(echo:SD card released)$") ANY_REGEX = re.compile(r".*") CONFIRMATION_REGEX = re.compile( r"^(ok.*)|(Done saving file\.)$") # highest priority # ---CAUTION--- # These are handled by special_commands component # If you use them without, you'll get false positive print starts # when the special menu is used FILE_OPEN_REGEX = re.compile(r"^echo:enqueing \"M23 (?P[^\"]+)\"$") START_PRINT_REGEX = re.compile(r"^echo:enqueing \"M24\"$") PRINT_DONE_REGEX = re.compile(r"^Done printing file$") # ---------------------------------------- REJECTION_REGEX = re.compile( r"^(?P(echo:Unknown command: (\"[^\"]*\"))|" r"(Unknown \S code: .*))|" r"(?Pecho: cold extrusion prevented)$") BUSY_REGEX = re.compile("^echo:busy: processing$") ATTENTION_REGEX = re.compile("^echo:busy: paused for user$") PAUSE_PRINT_REGEX = re.compile(r"^// ?action:pause$") PAUSED_REGEX = re.compile(r"^// ?action:paused$") RESUME_PRINT_REGEX = re.compile("^// ?action:resume$") RESUMED_REGEX = re.compile("^// ?action:resumed$") CANCEL_REGEX = re.compile("^// ?action:cancel$") READY_REGEX = re.compile("^// ?action:ready$") NOT_READY_REGEX = re.compile("^// ?action:not_ready$") REPRINT_REGEX = re.compile("^// ?action:start$") # This girthy regexp tries to capture all error messages requiring printer # reset using M999 or manual button, with connect, only manual reset shall # be accepted ERROR_REGEX = re.compile( r"(Error:(" r"(?PPrinter halted\. kill\(\) called!)|" # There's another one ending in Supervision required r"(?PPrinter stopped due to errors\. Fix.*)))") ERROR_REASON_REGEX = re.compile( # flake8: noqa r"(Error:(" r"(?P(0: )?Heaters switched off\. " r"M((?PIN)|(?PAX))TEMP (?PBED )?triggered!)|" r"(?P( ((?PHOTEND)|" r"(?PHEATBED)))? THERMAL RUNAWAY( \( ?PREHEAT " r"((?PHOTEND)|(?PHEATBED))\))?)))") ATTENTION_REASON_REGEX = re.compile( r"(?PBed leveling failed. Sensor triggered too high)|" r"(?PBed leveling failed\. Sensor didn't trigger\. " r"Debris on nozzle\? Waiting for reset\.)|" r"(?PTM: error triggered!)") TEMPERATURE_REGEX = re.compile( r"^T:(?P-?\d+\.\d+) /(?P-?\d+\.\d+) " r"B:(?P-?\d+\.\d+) /(?P-?\d+\.\d+) " r"T0:(-?\d+\.\d+) /(-?\d+\.\d+) @:(?P-?\d+) B@:(?P-?\d+) " r"P:(?P-?\d+\.\d+)( A:(?P-?\d+\.\d+))?$") POSITION_REGEX = re.compile( r"^X:(?P-?\d+\.\d+) Y:(?P-?\d+\.\d+) Z:(?P-?\d+\.\d+) " r"E:(?P-?\d+\.\d+) Count X: (?P-?\d+\.\d+) " r"Y:(?P-?\d+\.\d+) Z:(?P-?\d+\.\d+) " r"E:(?P-?\d+\.\d+)$") FAN_REGEX = re.compile( r"E0:(?P\d+) RPM PRN1:(?P\d+) RPM " r"E0@:(?P\d+) PRN1@:(?P\d+)") # This one takes some explaining # I cannot assign multiple regular expressions to a single instruction # The `M27 P` has more lines, the first one containing a status report or # a file path. The optional second line contains info about # which byte is being printed and the last one contains the print timer # Expressions below shall be in the order they appear in the output M27_OUTPUT_REGEX = re.compile( r"^(?P/.*\..*)|(?PNot SD printing)|" r"(?PPrint saved)|(?PSD print paused)|" r"(?PSD printing byte (?P\d+)/(?P\d+))|" r"(?P(?P\d+):(?P\d{2}))$") PRINT_INFO_REGEX = re.compile( r"^(?P(SILENT)|(NORMAL)) MODE: " r"Percent done: (?P-?\d+); " r"[pP]rint time remaining in mins: (?P-?\d+); " r"Change in mins: (?P-?\d+)") HEATING_REGEX = re.compile( r"^T:(?P\d+\.\d+) E:\d+ B:(?P\d+\.\d+)$") HEATING_HOTEND_REGEX = re.compile( r"^T:(?P\d+\.\d+) E:([?]|\d+) W:([?]|\d+)$") RESEND_REGEX = re.compile(r"^Resend: ?(?P\d+)$") PRINTER_BOOT_REGEX = re.compile(r"^start$") POWER_PANIC_REGEX = re.compile(r"^INT4$") LCD_UPDATE_REGEX = re.compile(r"^LCD status changed$") M110_REGEX = re.compile(r"^(N\d+)? *M110 ?N(?P-?\d*)$") FAN_ERROR_REGEX = re.compile( r"^(?PExtruder|Hotend|Print) fan speed is lower than expected$") D3_OUTPUT_REGEX = re.compile( r"^(?P
\w{2,}) {2}(?P([0-9a-fA-F]{2} ?)+)$") MBL_REGEX = re.compile(r"^(?PMesh bed leveling not active.)|" r"(Num X,Y: (?P\d+),(?P\d+))|" r"(?P([ ]*-?\d+\.\d+)+)$") MBL_TRIGGER_REGEX = re.compile(r"^(tmc\d+_home_enter\(axes_mask=0x..\))|" r"(echo:enqueing \"G80\")") TM_ERROR_LOG_REGEX = re.compile(r"TM: error \|(?P-?\d+\.?\d*)\|" r"[<>](?P-?\d+\.?\d*)") TM_ERROR_CLEARED = re.compile(r"^TM: error cleared$") URLS_FOR_WIZARD = re.compile(r"/(\d{1,3})?/?") TM_CAL_START_REGEX = re.compile(r"^TM: calibration start$") TM_CAL_END_REGEX = re.compile(r"^(TM: calibr\. failed!)|" r"(Thermal Model settings:)$") MMU_MAJOR_REGEX = re.compile( r"^echo:MMU[23]:[0-9a-fA-F]+)\*[0-9a-f]{1,2}\.$") MMU_MINOR_REGEX = re.compile( r"^echo:MMU[23]:[0-9a-fA-F]+)\*[0-9a-f]{1,2}\.$") MMU_REVISION_REGEX = re.compile( r"^echo:MMU[23]:[0-9a-fA-F]+)\*[0-9a-f]{1,2}\.$") MMU_BUILD_REGEX = re.compile( r"^echo:MMU[23]:[0-9a-fA-F]+)\*[0-9a-f]{1,2}\.$") MMU_SLOT_REGEX = re.compile( r"^echo:MMU2:MMU2tool=(?P\d{1,2})$") # This can report an error or a command in progress, # we don't know before parsing MMU_Q0_RESPONSE_REGEX = re.compile( r"^echo:MMU[23]:<(?P[A-Z][0-9a-fA-F]+) " r"(?P[EFP]([0-9a-fA-F]{0,4}))\*[0-9a-f]{1,2}\.$") MMU_Q0_REGEX = re.compile(r"^echo:MMU[23]:>Q0\*[0-9a-f]{1,2}\.$") MMU_PROGRESS_REGEX = re.compile( r"echo:MMU2:(?P" + r"|".join(map(re.escape, MMU_PROGRESS_MAP.keys())) + r")" ) RESET_ACTIVATED_REGEX = re.compile(r"^Reset mode activated$") RESET_DEACTIVATED_REGEX = re.compile(r"^Reset mode deactivated$") PP_RECOVER_REGEX = re.compile(r"^// ?action:uvlo_recovery_ready$") PP_AUTO_RECOVER_REGEX = re.compile(r"^// ?action:uvlo_auto_recovery_ready$") ================================================ FILE: prusa/link/printer_adapter/telemetry_passer.py ================================================ """ The frequency at which we send telemetry is being determined by quite a lot of factore. This module takes care of monitoring, how often to send telemetry and what actual telemetry to send """ import logging from copy import deepcopy from enum import Enum from threading import Event, RLock, Thread from time import time from typing import Any from prusa.connect.printer import Printer from prusa.connect.printer.const import State from pydantic import BaseModel from pydantic.utils import deep_update from ..config import Settings from ..const import ( JITTER_THRESHOLD, MMU_SLOTS, PRINTING_STATES, TELEMETRY_IDLE_INTERVAL, TELEMETRY_PRINTING_INTERVAL, TELEMETRY_REFRESH_INTERVAL, TELEMETRY_SLEEP_AFTER, TELEMETRY_SLEEPING_INTERVAL, ) from ..util import loop_until, walk_dict from .model import Model from .structures.mc_singleton import MCSingleton from .structures.model_classes import Telemetry log = logging.getLogger(__name__) # beyond this many things waiting to get sent by the SDK, # we'll stop sending telemetry QUEUE_LENGTH_LIMIT = 4 class Modifier(Enum): """The modifiers for telemetry""" FILTER_IDLE = "FILTER_IDLE" # Filtered when idle FILTER_PRINTING = "FILTER_PRINTING" # Filtered when printing FILTER_MMU_OFF = "FILTER_MMU_OFF" # Filtered when MMU is disconnected JITTER_TEMP = "JITTER_TEMP" # Temperature jitter filtr preset ACTIVATE_IDLE = "ACTIVATE_IDLE" # Wakes up fast telemetry when idle ACTIVATE_PRINTING = "ACTIVATE_PRINTING" # Same but when printing # Important - all filter paths are in the dict format # model is different in structure, the paths are mapped using a mapping below MODIFIERS: dict[tuple[str, ...], set[Modifier]] = { ("target_nozzle",): {Modifier.ACTIVATE_IDLE}, ("target_bed",): {Modifier.ACTIVATE_IDLE}, ("axis_x",): {Modifier.ACTIVATE_IDLE, Modifier.FILTER_PRINTING}, ("axis_y",): {Modifier.ACTIVATE_IDLE, Modifier.FILTER_PRINTING}, ("axis_z",): {Modifier.ACTIVATE_IDLE}, ("target_fan_print",): {Modifier.ACTIVATE_IDLE}, ("speed",): {Modifier.ACTIVATE_IDLE, Modifier.ACTIVATE_PRINTING}, ("temp_nozzle",): {Modifier.JITTER_TEMP}, ("temp_bed",): {Modifier.JITTER_TEMP}, ("time_printing",): {Modifier.FILTER_IDLE}, ("time_remaining",): {Modifier.FILTER_IDLE}, ("progress",): {Modifier.FILTER_IDLE}, ("inaccurate_estimates",): {Modifier.FILTER_IDLE}, ("slot",): {Modifier.FILTER_MMU_OFF}, # ("a", "b") - applies to a key b in a subtree a # ("a") - applies to "a", so if it's filtered, its children are too } MAPPING = { # type: ignore "slot": {}, } for i_ in range(1, MMU_SLOTS+1): # Map slots from orm to the dict representation MAPPING["slot"][str(i_)] = ("slot", "slots", str(i_)) # Add jitter temps to every slot temp value MODIFIERS[("slot", str(i_), "temp")] = {Modifier.JITTER_TEMP} class TelemetryPasser(metaclass=MCSingleton): """Tasked with passing the correct telemetry with the correct timing""" def __init__(self, model: Model, printer: Printer): self.model: Model = model self.printer: Printer = printer self.lock = RLock() self.notify_evt: Event = Event() self.running = True self.sleeping = False self.telemetry_interval = TELEMETRY_SLEEPING_INTERVAL self.thread = Thread(target=self._keep_updating, name="telemetry_passer") self.full_refresh_at = 0 self._active_filters: set[Any] = set() self._last_sent: dict[str, Any] = {} self._to_send: dict[str, Any] = {} self._latest_full = Telemetry() self.model.latest_telemetry = Telemetry() self.last_activity_at = time() def start(self): """Starts the passer""" self.thread.start() def stop(self): """Stops the passer""" self.running = False self.notify_evt.set() def wait_stopped(self): """Wait for the passer to stop""" self.thread.join() def _keep_updating(self): """keeps spinning until supposed to stop The loop here facilitates the instant wakeup of the telemetry passer after activity is observed""" while self.running: self.notify_evt.clear() loop_until(loop_evt=self.notify_evt, run_every_sec=lambda: self.telemetry_interval, to_run=self._update) def _update(self): """Updates how fast to send and sends the telemetry""" self.sleeping = time() - self.last_activity_at > TELEMETRY_SLEEP_AFTER if self.sleeping: log.debug("Telemetry passer is sleeping... zzz") self.telemetry_interval = TELEMETRY_SLEEPING_INTERVAL else: state = self.model.state_manager.current_state if state in PRINTING_STATES: self.telemetry_interval = TELEMETRY_PRINTING_INTERVAL else: self.telemetry_interval = TELEMETRY_IDLE_INTERVAL self.pass_telemetry() def pass_telemetry(self): """Passes the telemetry to the SDK and pushes the newer telemetry into the sent telemetry""" if not Settings.instance.use_connect(): log.debug("Connect isn't configured -> no telemetry") return if not self.printer.is_initialised(): log.debug("Printer isn't initialised -> no telemetry") return if Settings.instance.is_wizard_needed(): log.debug("Wizard has not been completed yet -> no telemetry") return if self.printer.queue.qsize() >= QUEUE_LENGTH_LIMIT: log.debug("SDK queue looks stuck -> no telemetry") return with self.lock: # Update what we sent last time self._last_sent = deep_update(self._last_sent, self._to_send) telemetry = self._to_send self._to_send = {} self.printer.telemetry(**telemetry) def _get_filtered_paths(self): state = self.model.state_manager.current_state if state not in PRINTING_STATES: looking_for = Modifier.FILTER_IDLE elif state == State.PRINTING: looking_for = Modifier.FILTER_PRINTING else: return set() filtered = set() for key_path, filters in MODIFIERS.items(): if looking_for in filters: filtered.add(key_path) return filtered def _get_modifiers(self, key_path): modifiers = set() for i in range(len(key_path)): modifiers.update(MODIFIERS.get(key_path[:i+1], set())) return modifiers def set_telemetry(self, new_telemetry: Telemetry): """Filters jitter, state inappropriate or unchanged data Updates the telemetries with new data""" with self.lock: new_telemetry_dict = new_telemetry.dict(exclude_none=True) for key_path, value in walk_dict(new_telemetry_dict): key_path = tuple(key_path) if value is None or value == {}: continue # ignore nones and empty dicts modifiers = self._get_modifiers(key_path) self._update_by_path( self._latest_full, new_telemetry, key_path) if modifiers & self._active_filters: # Internally we need to check against none self._reset_by_path( self.model.latest_telemetry, key_path) continue self._update_by_path( self.model.latest_telemetry, new_telemetry, key_path) to_update = False if self._get_by_path(self._last_sent, key_path) is None: to_update = True elif Modifier.JITTER_TEMP in modifiers: old = self._get_by_path(self._last_sent, key_path) new = value assert new is not None if old is None: to_update = True else: assert isinstance(new, float) assert isinstance(old, float) if abs(old - new) > JITTER_THRESHOLD: to_update = True elif value != self._get_by_path(self._last_sent, key_path): to_update = True # Wake up from sleep, when specific values change if to_update: if self._should_wake_up(modifiers): self.activity_observed() self._update_by_path( self._to_send, new_telemetry_dict, key_path) self._resend_telemetry_on_timer() def _should_wake_up(self, modifiers): """Returns true if the telemetry passer should wake up from sleep based on the current state and the modifiers present""" state = self.model.state_manager.current_state if state in PRINTING_STATES: if Modifier.ACTIVATE_PRINTING not in modifiers: return False if Modifier.FILTER_PRINTING in modifiers: return True return Modifier.ACTIVATE_IDLE in modifiers def reset_value(self, key_path): """Resets the value for filament_change_in and nothing else""" with self.lock: self._reset_by_path(self._latest_full, key_path) self._reset_by_path(self.model.latest_telemetry, key_path) def _set_multi(self, structure, key, value): """Sets a value from a dictionary or a model""" if isinstance(structure, dict): structure[key] = value elif issubclass(type(structure), BaseModel): setattr(structure, key, value) else: raise TypeError("Unsupported type for traversing") def _get_multi(self, structure, key): """Gets a value from a dictionary or a model""" if isinstance(structure, dict): return structure.get(key) if issubclass(type(structure), BaseModel): return getattr(structure, key) raise TypeError("Unsupported type for traversing") def _get_correct_path(self, structure, key_path): """Gets the correct path depending on the structure type""" if isinstance(structure, dict): return key_path if issubclass(type(structure), BaseModel): return self._path_to_model(key_path) raise TypeError("Unsupported type for traversing") def _update_by_path(self, target, source, key_path, set_none=False): """Sets a value in the model, allow setting none, or pushing more data key path is auto mapped, provide the dict equivalent one Some assumptions not to be broken as this is fragile AF Always supply the full path, do not let it end on a sub dict or sub model, full paths only Supply only models or dicts """ if not isinstance(target, type(source)): raise TypeError("Source and target must be of the same type") model_path = self._get_correct_path(target, key_path) for key in model_path[:-1]: if not isinstance(target, type(source)): raise TypeError("Source and target differ in structure") next_source = self._get_multi(source, key) next_target = self._get_multi(target, key) if next_source is None: # Source has less depth than target if set_none: self._set_multi(target, key, None) return if next_target is None: self._set_multi(target, key, deepcopy(next_source)) return # We have set a subtree, we're done source = next_source target = next_target value = self._get_multi(source, model_path[-1]) if value is None and not set_none: return # We don't want to set None, only add more data self._set_multi(target, model_path[-1], value) def _reset_by_path(self, target, key_path): """Resets a value in the model, does so only for the node at the end of the supplied path""" model_path = self._get_correct_path(target, key_path) for key in model_path[:-1]: target = self._get_multi(target, key) if target is None: return self._set_multi(target, model_path[-1], None) def _get_by_path(self, source, key_path): """Gets a value from model or dict""" model_path = self._get_correct_path(source, key_path) for key in model_path: source = self._get_multi(source, key) if source is None: return None return source def _path_to_model(self, key_path) -> tuple[Any, ...]: """As the ORM is now different from the dict structure, this maps the key path to the model""" sub_mapping = MAPPING iterable_path = iter(key_path) for key in iterable_path: result = sub_mapping.get(key) if result is None: return key_path if isinstance(result, tuple): break sub_mapping = result else: # no break or return encountered raise ValueError("Mapping seems to be invalid") return result + tuple(iterable_path) def _resend_telemetry_on_timer(self): """If sufficient time elapsed, mark all telemetry values to be sent""" if time() - self.full_refresh_at > TELEMETRY_REFRESH_INTERVAL: self.full_refresh_at = time() self.resend_latest_telemetry() def state_changed(self): """When the state changes, update what keys do we filter. Call the setters on any keys for which the filtered status changes, to update them""" with self.lock: # Update the active filters state = self.model.state_manager.current_state if state not in PRINTING_STATES: self._active_filters.add(Modifier.FILTER_IDLE) elif Modifier.FILTER_IDLE in self._active_filters: self._active_filters.remove(Modifier.FILTER_IDLE) if state == State.PRINTING: self._active_filters.add(Modifier.FILTER_PRINTING) elif Modifier.FILTER_PRINTING in self._active_filters: self._active_filters.remove(Modifier.FILTER_PRINTING) if not self.printer.mmu_enabled: self._active_filters.add(Modifier.FILTER_MMU_OFF) elif Modifier.FILTER_MMU_OFF in self._active_filters: self._active_filters.remove(Modifier.FILTER_MMU_OFF) # Update the telemetry to reflect new filters self.set_telemetry(self._latest_full) def activity_observed(self): """Call if any activity that constitutes waking up from sleep occurs""" self.last_activity_at = time() if self.sleeping: log.debug("Telemetry passer woke up.") self.notify_evt.set() def wipe_telemetry(self): """Resets the telemetry, so the values don't lie Paired with polling value invalidation, this will get and send fresh telemetry values""" with self.lock: self.model.latest_telemetry = Telemetry() self._last_sent = {} self._to_send = {} def resend_latest_telemetry(self): """Move the latest telemetry, so it gets sent next time. Great for reconnections and other telemetry forgetting situations""" with self.lock: self._to_send = self.model.latest_telemetry.dict(exclude_none=True) self.pass_telemetry() ================================================ FILE: prusa/link/printer_adapter/updatable.py ================================================ """ Contains implementation of the ThreadedUpdatable class There was an updatable without a thread, but it stopped being used Also contains a thread utility function """ from cProfile import Profile from functools import partial from threading import Event from threading import Thread as _Thread from ..util import loop_until class Thread(_Thread): """https://stackoverflow.com/a/1922945""" def profile_run(self): """run method for profiling""" profiler = Profile() profiler.enable() try: return profiler.runcall(_Thread.run, self) finally: profiler.disable() profiler.dump_stats(f'prusalink-{self.name}.profile') @staticmethod def enable_profiling(): """Swap run method.""" Thread.run = Thread.profile_run @staticmethod def disable_profiling(): """Swap run method.""" Thread.run = _Thread.run class ThreadedUpdatable: """Thread for parallel update operation.""" thread_name = "updater_thread" update_interval = 1.0 def __init__(self): self.quit_evt = Event() target = partial( loop_until, loop_evt=self.quit_evt, run_every_sec=lambda: self.update_interval, to_run=self.update) self.thread = Thread(target=target, name=self.thread_name) def start(self): """Start thread.""" self.thread.start() def stop(self): """Stop the updatable""" self.quit_evt.set() def wait_stopped(self): """Wait for the updatable to be stopped""" self.thread.join() def update(self): """Put code for updating here.""" raise NotImplementedError ================================================ FILE: prusa/link/sdk_augmentation/__init__.py ================================================ ================================================ FILE: prusa/link/sdk_augmentation/command_handler.py ================================================ """Contains implementation of the CommandHandler class""" from prusa.connect.printer import Command from ..const import QUIT_INTERVAL from ..printer_adapter.updatable import Thread from ..util import prctl_name class CommandHandler: """Waits for commands from the SDK, calls their handlers""" def __init__(self, sdk_command: Command): self.sdk_command = sdk_command # Can't start a new thread for every command. # So let's recycle one in here self.command_thread = Thread(target=self.handle_commands, name="command_runner", daemon=True) self.running = True self.command_thread.start() def handle_commands(self): """ Waits on an event, set by the SDK whenever an unprocessed command gets received Calls the sdk command class, which is overloaded and in turn calls the commands handler """ prctl_name() while self.running: if self.sdk_command.new_cmd_evt.wait(QUIT_INTERVAL): self.sdk_command() def stop(self): """Stops the command handling module""" self.running = False ================================================ FILE: prusa/link/sdk_augmentation/file.py ================================================ """Contains implementation of the SDFile class which augments the SDK File""" from pathlib import Path from prusa.connect.printer.files import File class SDFile(File): """Adds a few useful methods for adding SD Files parsed from serial""" def add_node(self, is_dir, path: Path, name, sfn, **attrs): """ Adds a file/dir node to a path, can add only into an existing dir node """ parts = Path(path).parts # Ignores the first "/" node: "SDFile" = self.get(parts[1:]) if not str(path).startswith("/."): if node is None: raise FileNotFoundError(f"Can't find the node at {path} to add" f" the child named {name} to.") node.add(is_dir=is_dir, name=name, read_only=True, sfn=sfn, **attrs) def add_directory(self, path: Path, name, sfn, **attrs): """Shorthand for adding directories""" self.add_node(True, path, name, sfn=sfn, **attrs) def add_file(self, path, name, sfn, **attrs): """Shorthand for adding files""" self.add_node(False, path, name, sfn=sfn, **attrs) ================================================ FILE: prusa/link/sdk_augmentation/printer.py ================================================ """Contains implementation of the augmented Printer class from the SDK""" from logging import getLogger from pathlib import Path from time import sleep from typing import Any, Dict from gcode_metadata import FDMMetaData from prusa.connect.printer import Printer as SDKPrinter from prusa.connect.printer import const from prusa.connect.printer.command import Command from prusa.connect.printer.conditions import API, HTTP, CondState from prusa.connect.printer.const import Source from prusa.connect.printer.files import File from .. import __version__ from ..conditions import use_connect_errors from ..const import PRINTER_CONF_TYPES from ..printer_adapter.keepalive import Keepalive from ..printer_adapter.lcd_printer import LCDPrinter from ..printer_adapter.model import Model from ..printer_adapter.structures.mc_singleton import MCSingleton from ..printer_adapter.updatable import Thread from ..util import file_is_on_sd, prctl_name from .command_handler import CommandHandler log = getLogger("connect-printer") class MyPrinter(SDKPrinter, metaclass=MCSingleton): """ Overrides some methods of the SDK Printer to provide better support for PrusaLink """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.lcd_printer = LCDPrinter.get_instance() self.keepalive = Keepalive.get_instance() self.download_thread = Thread(target=self.download_loop, name="download") self.model = Model.get_instance() self.nozzle_diameter = None self.command_handler = CommandHandler(self.command) self.loop_thread = Thread(target=self.loop, name="loop") self.__inotify_running = False self.inotify_thread = Thread(target=self.inotify_loop, name="inotify") self.snapshot_thread = Thread(target=self.snapshot_loop, name="snapshot_sender", daemon=True) def parse_command(self, res): """Parse telemetry response. When response from connect is command (HTTP Status: 200 OK), it will set command object. """ if 500 > res.status_code >= 400: API.state = CondState.NOK elif res.status_code == 503: HTTP.state = CondState.NOK res = super().parse_command(res) return res def get_info(self) -> Dict[str, Any]: """Returns a dictionary containing the printers info.""" info = super().get_info() info["nozzle_diameter"] = self.nozzle_diameter info["files"] = self.fs.to_dict_legacy() info["prusa_link"] = __version__ # TODO: remove later info["prusalink"] = __version__ return info def connection_from_settings(self, settings): """Loads connection details from the Settings class.""" self.api_key = settings.service_local.api_key server = SDKPrinter.connect_url(settings.service_connect.hostname, settings.service_connect.tls, settings.service_connect.port) token = settings.service_connect.token self.set_connection(server, token) use_connect = settings.use_connect() self.keepalive.set_use_connect(use_connect) use_connect_errors(use_connect) def get_file_info(self, caller: Command) -> Dict[str, Any]: """Return file info for a given file sometimes only when it exists""" # pylint: disable=unused-argument if not caller.kwargs: raise ValueError("SEND_FILE_INFO requires kwargs") file_path_string = caller.kwargs['path'] path: Path = Path(file_path_string) log.info("FILE_INFO for: %s", path) parts = path.parts if file_is_on_sd(parts): data = self.from_path(path) else: data = super().get_file_info(caller) log.info("FILE_INFO: %s", data) return data def from_path(self, path: Path): """Parses SD file metadata from its name only""" string_path = str(path) meta = FDMMetaData(string_path) meta.load_from_path(string_path) log.info(meta.data) data = { "source": Source.CONNECT, "event": const.Event.FILE_INFO, "path": string_path, } file: File = self.fs.get(string_path) if file is not None: data.update(file.attrs) data.update(meta.data) return data def start(self): """Start SDK related threads. * loop * inotify """ self.__inotify_running = True self.loop_thread.start() self.inotify_thread.start() self.download_thread.start() self.snapshot_thread.start() def indicate_stop(self): """Passes the stop request to all SDK related threads. * command handler * loop * inotify """ self.__inotify_running = False self.download_mgr.stop_loop() self.stop_loop() self.queue.put(None) # Trick the SDK into quitting fast self.command_handler.stop() self.camera_controller.stop() def wait_stopped(self): """Waits for the SDK threads to join * command handler * loop * inotify """ self.inotify_thread.join() self.loop_thread.join() self.download_thread.join() self.snapshot_thread.join() def loop(self): """SDKPrinter.loop with thread name.""" prctl_name() super().loop() def inotify_loop(self): """Inotify_handler in loop.""" prctl_name() while self.__inotify_running: try: self.inotify_handler() sleep(0.2) except Exception: # pylint: disable=broad-except log.exception('Unhandled exception') def download_loop(self): """Handler for download loop""" prctl_name() self.download_mgr.loop() def snapshot_loop(self): """Gives snapshot loop a consistent name with the rest of the app""" prctl_name() self.camera_controller.snapshot_loop() @property def type_string(self): """Gets the string version of the printer type""" if self.type is not None: return PRINTER_CONF_TYPES.inverse[self.type] return None ================================================ FILE: prusa/link/serial/__init__.py ================================================ ================================================ FILE: prusa/link/serial/helpers.py ================================================ """Contains helper functions, for instruction enqueuing""" import re from threading import Event from typing import Callable, List, Union from ..const import QUIT_INTERVAL from ..serial.instruction import ( Instruction, MandatoryMatchableInstruction, MatchableInstruction, ) from .serial_queue import SerialQueue def wait_for_instruction(instruction, should_wait: Callable[[], bool] = lambda: True, should_wait_evt: Event = Event(), check_every=QUIT_INTERVAL): """ Wait until the instruction is done, or we shouldn't wait anymore :param instruction: The instruction to wait for :param should_wait: a lambda returning true if we should continue waiting :param should_wait_evt: an event, if set, means this should quit :param check_every: how fast to consult the should_wait lambda """ while should_wait() and not should_wait_evt.is_set(): if instruction.wait_for_confirmation(timeout=check_every): return True return False def enqueue_instruction(queue: SerialQueue, message: str, to_front=False, to_checksum=False) -> Instruction: """ Creates an instruction, which it enqueues right away :param queue: the queue to enqueue into :param message: the gcode you wish to send to the printer :param to_front: Whether the instruction has a higher priority :param to_checksum: Whether to number and checksum the instruction (use only for print instructions!) :return the enqueued instruction """ instruction = Instruction(message, to_checksum=to_checksum) queue.enqueue_one(instruction, to_front=to_front) return instruction # pylint: disable=too-many-arguments def enqueue_matchable(queue: SerialQueue, message: str, regexp: re.Pattern, to_front=False, to_checksum=False, has_to_match=True) -> Union[ MandatoryMatchableInstruction, MatchableInstruction]: """ Creates a matchable instruction, which it enqueues right away :param queue: the queue to enqueue into :param message: the gcode you wish to send to the printer :param regexp: the regular expression which the instruction needs to match, otherwise it will refuse confirmation :param to_front: Whether the instruction has a higher priority :param to_checksum: Whether to number and checksum the instruction (use only for print instructions!) :return the enqueued instruction """ instruction: Union[MandatoryMatchableInstruction, MatchableInstruction] if has_to_match: instruction = MandatoryMatchableInstruction(message, capture_matching=regexp, to_checksum=to_checksum) else: instruction = MatchableInstruction(message, capture_matching=regexp, to_checksum=to_checksum) queue.enqueue_one(instruction, to_front=to_front) return instruction def enqueue_list_from_str(queue: SerialQueue, message_list: List[str], regexp: re.Pattern, to_front=False, to_checksum=False) -> List[MatchableInstruction]: """ Creates a list of instructions, which it enqueues right away :param queue: Queue to enqueue into :param message_list: List of gcodes you wish to send to the printer :param regexp: a regexp to match each instruction output to (this is used by the execute gcode command, so it enqueues with ok / unknown gcode regexp. Keep in mind, that instruction which won't match will refuse to be confirmed) :param to_front: Whether the instruction has a higher priority :param to_checksum: Whether to number and checksum the instruction (use only for print instructions!) :return List of enqueued instructions """ instruction_list: List[MatchableInstruction] = [] for message in message_list: instruction = MatchableInstruction(message, capture_matching=regexp, to_checksum=to_checksum) instruction_list.append(instruction) queue.enqueue_list(instruction_list, to_front=to_front) return instruction_list ================================================ FILE: prusa/link/serial/instruction.py ================================================ """ Contains implementation for all the types of instructions enqueueable to the serial queue """ import logging import re from threading import Event from time import time from typing import List, Optional log = logging.getLogger(__name__) class Instruction: """Basic instruction which can be enqueued into SerialQueue""" def __init__(self, message: str, to_checksum: bool = False, data: Optional[bytes] = None, number: Optional[int] = None, ): if message.count("\n") != 0: raise RuntimeError("Instructions cannot contain newlines.") # Some messages need to be sent with numbered lines and with checksums # This shall be exclusive for printing from files self.to_checksum = to_checksum # Can be changed before the instruction is sent. self.message = message # If already sent, this will contain the sent bytes self.data = data # If we know our number, it is saved here (used by message history) self.number = number # Event set when the write has been _confirmed by the printer self.confirmed_event = Event() # Event set when the write has been sent to the printer self.sent_event = Event() # Api for registering instruction regexps self.capturing_regexps: List[re.Pattern] = [] # Measuring the time between sending and confirmation will hopefully # enable me to determine if the motion planner buffer is full self.sent_at: Optional[float] = None self.time_to_confirm: Optional[float] = None def __str__(self): return f"Instruction '{self.message.strip()}'" def __repr__(self): return self.__str__() def confirm(self, force=False) -> bool: """ Return False, if getting confirmed but not wanting to (not used in the base implementation anymore) """ assert force is not None assert self.sent_at is not None self.time_to_confirm = time() - self.sent_at self.confirmed_event.set() return True def sent(self): """ Sets the instruction sent Event and writes the timestamp, when the instruction got sent """ self.sent_event.set() self.sent_at = time() def output_captured(self, sender, match): """ Output _captured event handler, this type does not capture anything though """ assert sender is not None assert match is not None def wait_for_send(self, timeout=None): """Proxy call to wait method of the sent Event""" return self.sent_event.wait(timeout) def wait_for_confirmation(self, timeout=None): """Proxy call to wait method of the confirmed Event""" return self.confirmed_event.wait(timeout) def is_sent(self): """Returns whether this instruction has been sent yet""" return self.sent_event.is_set() def is_confirmed(self): """Returns whether this instruction has been confirmed yet""" return self.confirmed_event.is_set() def reset(self): """Resets the send status of an instruction""" self.sent_at = None self.sent_event.clear() def fill_data(self, message_number: int): """ Puts together binary data to send as for the given instruction. The specific data might contain a message number and a checksum. Also a newline gets appended at the end :param instruction: Instruction to get data for :return: binary data to send """ data = self.message.encode("ASCII") if self.to_checksum: number_part = f"N{message_number} ".encode("ASCII") to_checksum = number_part + data + b" " checksum = self.get_checksum(to_checksum) checksum_data = f"*{checksum}".encode("ASCII") data = to_checksum + checksum_data self.number = message_number data += b"\n" self.data = data @staticmethod def get_checksum(data: bytes): """ Goes over the given bytes and returns a checksum, which is constructed by XORing each byte of data to a zero :param data: data to make a checksum out of :return: the checksum which is a number """ checksum = 0 for byte in data: checksum ^= byte return checksum class MatchableInstruction(Instruction): """ Matches using captures_matching. """ def __init__(self, *args, capture_matching: re.Pattern = re.compile(r".*"), **kwargs): super().__init__(*args, **kwargs) # Output _captured between command submission and confirmation self.capture_matching = capture_matching self._captured: List[re.Match] = [] self.capturing_regexps = [capture_matching] def output_captured(self, sender, match): """Appends _captured output to the instructions _captured list""" assert sender is not None self._captured.append(match) def match(self, index=0): """If match with an index exists, return it, otherwise return None""" if self._captured and len(self._captured) > index: return self._captured[index] return None def get_matches(self): """Returns the list of all _captured matches""" return self._captured class MandatoryMatchableInstruction(MatchableInstruction): """ HAS TO MATCH, otherwise refuses confirmation! This should fix a communication error we're having. """ def confirm(self, force=False) -> bool: # Yes, matchables HAVE TO match now! if not self._captured and not force: log.warning( "Instruction %s did not capture its expected output, " "so it REFUSES to be confirmed!", self.message) return False return super().confirm() ================================================ FILE: prusa/link/serial/is_planner_fed.py ================================================ """ Contains implementation of the IsPlannerFed class, with HeapName and TimeValue classes. Tries to guess, whether the printer planner is full """ import logging import os from collections import deque from enum import Enum from typing import Deque, Optional from ..const import ( DEFAULT_THRESHOLD, HEAP_RATIO, IGNORE_ABOVE, QUEUE_SIZE, USE_DYNAMIC_THRESHOLD, ) from ..printer_adapter.structures.heap import HeapItem, MaxHeap, MinHeap from ..util import ensure_directory, get_clean_path log = logging.getLogger(__name__) class HeapName(Enum): """Heap name enum""" SHORT_TIMES = "SHORT_TIMES" LONG_TIMES = "LONG_TIMES" class TimeValue(HeapItem): """Time value with info in which queue it currently resides""" def __init__(self, value: float) -> None: super().__init__(value) self.heap_name: Optional[HeapName] = None class IsPlannerFed: """ If the planner queue is full, I expect the printer to take longer when confirming print instructions, if the time surpasses a threshold, I assume full buffer. To stay future-proof, let's compute this threshold on the go. Let's measure the times for all instructions, disqualifying the ones that took too long. Now the threshold computation mimics the way one would compute a moving median. I use the two heaps approach. left heap is a max_heap, the right one is a min_heap, when a number comes, I compare it with the threshold and depending on the result I put it into one of the heaps. If that throws the ratio of element counts off, the heap that is larger than supposed to gives its root to the smaller one. The threshold is an average between the two roots. After the queue is full, the heaps shed the oldest values, so it can adapt, if for some reason the print commands start taking different amounts of time during the print. Problems can arise in hi-res cylindrical vases and other shapes with homogeneously long segments. To get rid of the inaccuracies caused by an initially low number of measured values, let's use a threshold from a previous run, or a default one until the values accumulate. """ def __init__(self, threshold_path): self.times_queue: Deque[TimeValue] = deque(maxlen=QUEUE_SIZE) self.threshold_path = get_clean_path(threshold_path) ensure_directory(os.path.dirname(self.threshold_path)) if not USE_DYNAMIC_THRESHOLD: self.default_threshold = DEFAULT_THRESHOLD else: try: with open(self.threshold_path, encoding='utf-8') as threshold_file: self.default_threshold = float(threshold_file.read()) except (FileNotFoundError, ValueError): self.default_threshold = DEFAULT_THRESHOLD self.is_fed = False self.short_times = MaxHeap() self.long_times = MinHeap() @property def item_count(self): """Return how many time values are contributing to the percentile""" return len(self.times_queue) @property def threshold(self): """ Depending on the internal state and settings, it returns the percentile threshold or the default """ if self.item_count < self.times_queue.maxlen or \ not USE_DYNAMIC_THRESHOLD: return self.default_threshold return self.get_dynamic_threshold() def get_dynamic_threshold(self): """Returns the Nth percentile value. N is fixed in constants""" if not self.short_times and not self.long_times: return float("inf") if not self.long_times and self.short_times: return self.short_times[0].value return (self.long_times[0].value + self.short_times[0].value) / 2 def __call__(self): """ :return: boolean - Did it take long enough? """ return self.is_fed def process_value(self, value): """ Adds the given value to tracked values and moves the percentile value accordingly :param value: how long did it take from send to confirmation """ if value > IGNORE_ABOVE: return if self.item_count >= self.times_queue.maxlen: self._remove_last() self._add(value) self.is_fed = value > self.threshold if self.is_fed: log.debug("Buffer is fed, threshold: %s, value: %s", self.threshold, value) def _remove_last(self) -> None: """ For the median to be influenced only by the last N commands And for the RAM and CPU usage to not slowly creep up, the size of the queue and heaps is capped. This removes the item from the queue and from its associated heap """ item: TimeValue = self.times_queue.popleft() if item.heap_name == HeapName.LONG_TIMES: self.long_times.pop(item.heap_index) else: self.short_times.pop(item.heap_index) self.balance() def _add(self, value): """ Adds a new value to the queue and to the one of the heaps Complexity should be O(log n) """ item = TimeValue(value) if not self.short_times: self._short_push(item) elif not self.long_times: if self.short_times[0].value > value: larger_item = self.short_times.pop() self._short_push(item) self._long_push(larger_item) else: self._long_push(item) else: if value < self.get_dynamic_threshold(): self._short_push(item) else: self._long_push(item) self.balance() self.times_queue.append(item) def balance(self): """Balances heaps to maintain the percentile""" num_long = len(self.long_times) num_short = len(self.short_times) total = num_long + num_short ideal_short_count = round(total * HEAP_RATIO) if num_short < ideal_short_count - 1: self._short_push(self.long_times.pop()) elif num_short > ideal_short_count + 1: self._long_push(self.short_times.pop()) if self.short_times[0].value > self.long_times[0].value: raise RuntimeError("Smaller value heap has a higher value than " "the higher value heap, that's not right...") def _short_push(self, item: TimeValue): """ Pushes a value into the heap containing times shorter than percentile """ item.heap_name = HeapName.SHORT_TIMES self.short_times.push(item) def _long_push(self, item: TimeValue): """ Pushes a value into the heap containing times longer than percentile """ item.heap_name = HeapName.LONG_TIMES self.long_times.push(item) def save(self): """ Saves the threshold, so when the prusa-link starts up again, it doesn't rely on the default threshold anymore """ if self.item_count >= self.times_queue.maxlen: with open(self.threshold_path, "w", encoding='utf-8') as threshold_file: threshold_file.write(str(self.get_dynamic_threshold())) os.fsync(threshold_file.fileno()) ================================================ FILE: prusa/link/serial/serial.py ================================================ """Own Serail class """ import errno import fcntl import logging import os import struct import termios from select import select from time import time from types import MappingProxyType TIOCM_DTR_str = struct.pack('I', termios.TIOCM_DTR) TIOCM_RTS_str = struct.pack('I', termios.TIOCM_RTS) class SerialException(RuntimeError): """Own exception type.""" class Serial: """PySerial compatible class.""" baudrates = MappingProxyType({115200: termios.B115200}) def __init__(self, port: str, baudrate: int, timeout: int): """ baudrate - must be valid baudrates from Serial.baudrates timeout - read operation timeout """ if baudrate not in Serial.baudrates: raise SerialException(f"Baudrate `{baudrate}` is not supported") self.timeout = timeout # pylint: disable=invalid-name self.fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) tty = termios.tcgetattr(self.fd) # cflag tty[2] &= ~termios.PARENB tty[2] &= ~termios.CSTOPB tty[2] |= termios.CS8 tty[2] &= ~termios.CRTSCTS tty[2] |= termios.CREAD | termios.CLOCAL tty[2] &= ~termios.HUPCL # disable hangup # lflag tty[3] &= ~termios.ICANON tty[3] &= ~termios.ECHO tty[3] &= ~termios.ECHOE tty[3] &= ~termios.ECHONL tty[3] &= ~termios.ECHOK tty[3] &= ~termios.ISIG tty[3] &= ~termios.IEXTEN # iflag tty[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY) tty[0] &= ~(termios.IGNBRK | termios.ISTRIP | termios.INLCR | termios.IGNCR | termios.ICRNL) tty[0] &= ~(termios.IUCLC | termios.PARMRK) # oflag tty[1] &= ~termios.OPOST tty[1] &= ~termios.ONLCR tty[1] &= ~termios.OCRNL # cc tty[6][termios.VTIME] = 0 # can be timout*10 tty[6][termios.VMIN] = 0 # ispeed tty[4] = termios.B115200 # ospeed tty[5] = termios.B115200 termios.tcsetattr(self.fd, termios.TCSANOW, tty) # TCSAFLUSH set after everything is done termios.tcsetattr(self.fd, termios.TCSAFLUSH, tty) # clear input buffer termios.tcflush(self.fd, termios.TCIFLUSH) try: # Data Terminal Ready fcntl.ioctl(self.fd, termios.TIOCMBIS, TIOCM_DTR_str) # Request To Send fcntl.ioctl(self.fd, termios.TIOCMBIS, TIOCM_RTS_str) except OSError as e: if e.errno == errno.ENOTTY: logging.getLogger(__name__).warning( "The file does not support ioctl() ignoring") else: raise self.__buffer = b'' self.__dtr = False def close(self): """Close the port.""" if self.fd is None: return try: os.close(self.fd) except OSError: pass finally: self.fd = None def __read(self, timeout): """Fill internal buffer by read from file descriptor.""" try: ready = select([self.fd], [], [], timeout) if ready[0] and self.fd: read_bytes = os.read(self.fd, 1024) if not read_bytes: raise SerialException("The serial became disconnected.") self.__buffer += read_bytes except (BlockingIOError, InterruptedError, TypeError) as err: self.close() raise SerialException(f"read failed: {err}") from err def readline(self): """Return next line from local buffer or from serial port.""" times_out_at = time() + self.timeout start_at = 0 while True: current_time = time() pos = self.__buffer.find(b'\n', start_at) if pos >= 0: line = self.__buffer[:pos + 1] self.__buffer = self.__buffer[pos + 1:] return line start_at = max(0, len(self.__buffer) - 1) if current_time >= times_out_at: break self.__read(times_out_at - current_time) return b'' def write(self, data: bytes): """Write data to serial port.""" return os.write(self.fd, data) @property def is_open(self): """Return true if port is open.""" return self.fd is not None @property def dtr(self): """Data Terminal Ready State""" return self.__dtr @dtr.setter def dtr(self, value: bool): self.__dtr = value if value: fcntl.ioctl(self.fd, termios.TIOCMBIS, TIOCM_DTR_str) else: fcntl.ioctl(self.fd, termios.TIOCMBIC, TIOCM_DTR_str) ================================================ FILE: prusa/link/serial/serial_adapter.py ================================================ """Contains implementation of the Serial class""" import glob import logging import os import re from importlib import util from pathlib import Path from threading import Event, RLock from time import sleep, time from typing import List, Optional from blinker import Signal # type: ignore from prusa.connect.printer.conditions import CondState from ..conditions import SERIAL from ..const import ( PRINTER_BOOT_WAIT, PRINTER_TYPES, QUIT_INTERVAL, RESET_PIN, SERIAL_REOPEN_TIMEOUT, ) from ..printer_adapter.model import Model from ..printer_adapter.structures.mc_singleton import MCSingleton from ..printer_adapter.structures.module_data_classes import ( Port, SerialAdapterData, ) from ..printer_adapter.structures.regular_expressions import ( ATTENTION_REGEX, BUSY_REGEX, FW_REGEX, PRINTER_TYPE_REGEX, ) from ..printer_adapter.updatable import Thread from ..util import decode_line, get_usb_printers, prctl_name from .serial import Serial, SerialException from .serial_parser import ThreadedSerialParser log = logging.getLogger(__name__) class PortAdapter: """Use the Port class, but allow to pass a Serial instance with it""" def __init__(self, port: Port) -> None: self.port: Port = port self.serial: Optional[Serial] = None class SerialAdapter(metaclass=MCSingleton): """ Class handling the basic serial management, opening, re-opening, writing and reading. It also can reset the connected device using DTR - works only with USB """ @staticmethod def is_rpi_port(port_path): """Figure out, whether we're running through the Einsy RPi port""" try: port_name = Path(port_path).name if not port_name.startswith("ttyAMA"): return False sys_path = Path(f"/sys/class/tty/{port_name}") link_path = os.readlink(str(sys_path)) device_path = sys_path.parent.joinpath(link_path).resolve() path_regexp = re.compile(r"^/sys/devices/platform/soc/" r"[^.]*\.serial/tty/ttyAMA\d+$") match = path_regexp.match(str(device_path)) if match is None: return False except Exception: # pylint: disable=broad-except log.exception("Exception when checking if we're connected through " "the Einsy pins. Assuming we're not.") return False return True def __init__(self, serial_parser: ThreadedSerialParser, model: Model, configured_port="auto", baudrate: int = 115200, timeout: int = 2, reset_disabling: bool = True) -> None: # pylint: disable=too-many-arguments self.model: Model = model self.model.serial_adapter = SerialAdapterData( using_port=None, reset_disabling=reset_disabling) self.data: SerialAdapterData = model.serial_adapter self.configured_port = configured_port self.baudrate = baudrate self.timeout = timeout self.write_lock = RLock() self.serial: Optional[Serial] = None self.serial_parser = serial_parser self.failed_signal = Signal() self.renewed_signal = Signal() self.running = True self._work_around_power_panic = Event() self._work_around_power_panic.set() self.read_thread = Thread(target=self._read_continually, name="serial_read_thread", daemon=True) self.read_thread.start() @staticmethod def is_open(serial) -> bool: """Returns bool indicating whether there's a serial connection""" return serial is not None and serial.is_open @staticmethod def _get_info(port_adapter: PortAdapter): """Gets info about the supplied port returns whether it figured something out or not""" serial = port_adapter.serial port = port_adapter.port if serial is None: raise SerialException("Tried getting info without a serial port " "(mostly for mypy to stop crying)") name = version = error_text = None serial.write(b"PRUSA Fir\nM862.2 Q\n") timeout_at = time() + 5 while (raw_line := serial.readline()) and time() < timeout_at: line = decode_line(raw_line) log.debug("Printer detection for '%s' returned: %s", port.path, line) if match := PRINTER_TYPE_REGEX.match(line): if (code := int(match.group("code"))) in PRINTER_TYPES: name = "Prusa " + PRINTER_TYPES[code].name else: error_text = "The printer is not supported" elif match := FW_REGEX.match(line): version = match.group("version") elif BUSY_REGEX.match(line): error_text = "The printer is busy" elif ATTENTION_REGEX.match(line): error_text = "The printer wants user attention" if name and version: port.usable = True port.description = f"{name} - FW: {version}" return if error_text: port.description = error_text return port.description = "A printer did not answer in time" @staticmethod def _detect(port_adapter: PortAdapter): """ Detects the usability of given port Split into two for pylint, this one is responsible for opening serial """ prctl_name() port = port_adapter.port serial = None try: if not SerialAdapter.is_open(serial): serial = Serial(port=port.path, baudrate=port.baudrate, timeout=port.timeout) port_adapter.serial = serial if not port.is_rpi_port: port.description = "Waiting for printer to boot" sleep(8) SerialAdapter._get_info(port_adapter) except (SerialException, FileNotFoundError, OSError) as error: port.description = "Failed to open. Is a printer connected " \ f"to this port? Error: {error}" if SerialAdapter.is_open(serial): serial.close() # type: ignore port.checked = True log.debug("Port: '%s' description: '%s'", port.path, port.description) def _reopen(self) -> bool: """Re-open the configured serial port. Do a full re-scan if auto is configured""" self.data.using_port = None self.data.ports = [] port_adapters: List[PortAdapter] = [] threads = [] with self.write_lock: self.close() if self.configured_port == "auto": paths = glob.glob("/dev/ttyAMA*") paths.extend(glob.glob("/dev/ttyACM*")) paths.extend(glob.glob("/dev/ttyUSB*")) else: # Follow symlinks to the real device file device_path = os.path.realpath(self.configured_port) paths = [device_path] # Pair the usb printer paths with their serial numbers usb_printers = { printer.path: printer.serial_number for printer in get_usb_printers() } for path in paths: port = Port(path=path, baudrate=115200, timeout=2, is_rpi_port=self.is_rpi_port(path)) if path in usb_printers: port.sn = usb_printers[path] port_adapter = PortAdapter(port) self.data.ports.append(port) port_adapters.append(port_adapter) thread = Thread(target=self._detect, args=(port_adapter,), name="port_detector", daemon=True) threads.append(thread) thread.start() for thread in threads: thread.join() found = False for port_adapter in port_adapters: if port_adapter.port.usable and not found: found = True port_adapter.port.selected = True self.data.using_port = port_adapter.port self.serial = port_adapter.serial log.info("Using the serial port %s", self.data.using_port.path) elif self.is_open(port_adapter.serial): # The above if guarantees there's not a None # in port.serial. Mypy is being dramatic again port_adapter.serial.close() # type: ignore log.debug("Other port - %s", port) return found def close(self): """Close the serial. If the read thread is running, it should renew the connection. """ with self.write_lock: if self.is_open(self.serial): self.serial.close() def _renew_serial_connection(self, starting: bool = False): """ Informs the rest of the app about failed serial connection, After which it keeps trying to re-open the serial port If it succeeds, generates a signal to inform the rest of the app """ # Wait for power panic timeout if not self._work_around_power_panic.is_set(): self.failed_signal.send(self) SERIAL.state = CondState.NOK while self.running: if self._work_around_power_panic.wait(QUIT_INTERVAL): break if self.is_open(self.serial): raise RuntimeError("Don't reconnect what is not disconnected") while self.running: if starting: starting = False else: self.failed_signal.send(self) SERIAL.state = CondState.NOK if not self._reopen(): SERIAL.state = CondState.NOK log.warning("Error when connecting to serial according to " "user config: %s", self.configured_port) sleep(SERIAL_REOPEN_TIMEOUT) else: break if self.running and not SERIAL: SERIAL.state = CondState.OK self.data.resets_enabled = None self.renewed_signal.send(self) def _read_continually(self): """Ran in a thread, reads stuff over an over""" prctl_name() self._renew_serial_connection(starting=True) while self.running: raw_line = "[No data] - This is a fallback value, " \ "so stuff doesn't break" try: if not self._work_around_power_panic.is_set(): raise SerialException( "Need to re-connect serial after power panic") raw_line = self.serial.readline() line = decode_line(raw_line) except (SerialException, OSError): log.exception("Failed when reading from the printer. " "Trying to re-open") self.close() self._renew_serial_connection() except UnicodeDecodeError: log.error("Failed decoding a message %s", raw_line) else: # with self.write_read_lock: # Why would I not want to write and handle reads # at the same time? IDK, but if something weird starts # happening, i'll re-enable this if line == "": log.debug("Printer has most likely sent something, " "which is not human readable") else: log.debug("Recv: %s", line) self.serial_parser.decide(line) def write(self, message: bytes): """ Writes a message to serial, if it for some reason fails, calls _renew_serial_connection :param message: the message to be sent Raises SerialException when the communication fails """ sent = False with self.write_lock: if not self.is_open(self.serial): log.warning("No serial to send '%s' to", message) return while not sent and self.running: try: # Mypy does not work with functions that check for None self.serial.write(message) # type: ignore except OSError as error: log.error("Serial error when sending '%s' to the printer", message) self.close() raise SerialException( "Serial error when sending") from error sent = True log.debug("Send: %s", message) def disable_dtr_resets(self): """Disables DTR resets - should be used by a command handler""" if not self.data.reset_disabling: return if self.data.resets_enabled is False: return self.write(b"\n;C32u2_RMD\n") def enable_dtr_resets(self): """Enables DTR resets - should be used by a command handler""" if not self.data.reset_disabling: return if self.data.resets_enabled is True: return self.write(b"\n;C32u2_RME\n") def _reset_pi(self): """Resets the connected raspberry pi""" spam_loader = util.find_spec('wiringpi') if spam_loader is None: log.warning("WiringPi missing, cannot reset using pins") return # pylint: disable=import-outside-toplevel # pylint: disable=import-error # ruff: noqa: PLC0415 import wiringpi # type: ignore wiringpi.wiringPiSetupGpio() wiringpi.pinMode(RESET_PIN, wiringpi.OUTPUT) wiringpi.digitalWrite(RESET_PIN, wiringpi.HIGH) wiringpi.digitalWrite(RESET_PIN, wiringpi.LOW) sleep(0.1) wiringpi.digitalWrite(RESET_PIN, wiringpi.LOW) def _blip_dtr(self): """Pulses the DTR to reset the connected device. Works only over USB""" with self.write_lock: self.serial.dtr = False self.serial.dtr = True sleep(PRINTER_BOOT_WAIT) def reset_client(self): """Resets the connected device, over USB or using the reset pin""" if not self.is_open(self.serial): log.warning("No serial connected, will not reset anything.") return if self.data.using_port.is_rpi_port: self._reset_pi() else: self._blip_dtr() def stop(self): """Stops the component""" self.running = False self.close() def wait_stopped(self): """Waits for the serial to be stopped""" self.read_thread.join() def power_panic_observed(self): """Called when a power panic is observed""" self._work_around_power_panic.clear() def power_panic_unblock(self): """Re-sets the power panic flag that holds the serial disconnected""" self._work_around_power_panic.set() ================================================ FILE: prusa/link/serial/serial_parser.py ================================================ """ Contains implementation of the SerialParser and Regex pairing classes The latter is used by the former for tracking which regular expressions have which handlers As of writing this doc, the "ok" has infinite priority, then every instruction handler has the current time as the priority, meaning later added handlers are evaluated first. """ import logging import re from functools import partial from queue import Queue from threading import Lock, Thread from typing import Any, Callable, Dict, Match, Optional, Union from blinker import Signal # type: ignore from sortedcontainers import SortedKeyList # type: ignore from ..printer_adapter.structures.mc_singleton import MCSingleton log = logging.getLogger(__name__) class RegexPairing: """ An object representing a bound regexp to its handler, with priority, for us to be able to sort which regexps to try first """ def __init__(self, regexp, priority=0) -> None: self.regexp: re.Pattern = regexp self.signal: Signal = Signal() self.priority: Union[float, int] = priority def __str__(self) -> str: receiver_count = len(self.signal.receivers) return f"RegexPairing for {self.regexp.pattern} " \ f"with priority {self.priority} " \ f"having {receiver_count} handler" \ f"{'s' if receiver_count > 1 else ''}" def __repr__(self) -> str: return self.__str__() def fire(self, match: Optional[Match] = None) -> None: """ Fire the associated signal, catch and log errors, don't want to kill the serial reading component """ # pylint: disable=broad-except log.debug("Matched %s calling %s", self, self.signal.receivers) try: self.signal.send(self, match=match) except Exception: log.exception("Exception during handling of the printer output. " "Caught to stay alive.") class SerialParser(metaclass=MCSingleton): """ Its job is to try and find an appropriate handler for every line that we receive from the printer """ def __init__(self) -> None: self.lock = Lock() self.pattern_list = SortedKeyList(key=lambda item: -item.priority) self.pairing_dict: Dict[re.Pattern, RegexPairing] = {} def decide(self, line: str) -> None: """ The meat of the class, trying different RegexPairings ordered by their priorities, to find the matching one """ chosen_pairing = None with self.lock: for pairing in self.pattern_list: match = pairing.regexp.match(line) if match: chosen_pairing = pairing break if chosen_pairing is not None: chosen_pairing.fire(match=match) else: log.debug("Match not found for %s", line) def add_handler(self, regexp: re.Pattern, handler: Callable[[Any, re.Match], None], priority: float = 0) -> None: """ Add an entry to output handlers. :param regexp: if this matches, your handler will get called Warning, should be unique, or the exact same as another one, after the first match, the matching is stopped! and all the handlers for the regexp are called :param handler: Callable that will parse the matched output :param priority: Higher priority means the regexp will be attempted sooner in the list. For items with the same priority, the newest gets used first """ with self.lock: if regexp in self.pairing_dict: existing_pairing: RegexPairing = self.pairing_dict[regexp] if existing_pairing not in self.pattern_list: log.debug("%s is not in %s. What?!", existing_pairing, self.pattern_list) if priority > existing_pairing.priority: self.pattern_list.remove(existing_pairing) existing_pairing.priority = priority self.pattern_list.add(existing_pairing) log.debug("Priority updated from %s to %s", existing_pairing.priority, priority) existing_pairing.signal.connect(handler, weak=False) else: new_pairing: RegexPairing = RegexPairing(regexp, priority=priority) new_pairing.signal.connect(handler, weak=False) self.pairing_dict[regexp] = new_pairing self.pattern_list.add(new_pairing) def remove_handler(self, regexp, handler) -> None: """ Removes the regexp and handler from the list of serial output handlers :param regexp: which regexp to remove a handler from :param handler: Which handler to remove """ with self.lock: if regexp in self.pairing_dict: pairing: RegexPairing = self.pairing_dict[regexp] pairing.signal.disconnect(handler) if not pairing.signal.receivers: del self.pairing_dict[regexp] self.pattern_list.remove(pairing) else: raise RuntimeError(f"There is no handler registered for " f"{regexp.pattern}") class ThreadedSerialParser(SerialParser): """Implements a way to de-couple serial reader from the rest of the app while allowing serial queue to remain coupled""" def __init__(self): super().__init__() self.handler_queue = Queue() self.running = False self.thread = Thread(target=self.process, name="serial_decoupler", daemon=True) self.running = True self.thread.start() def decoupled(self, handler): """A function generator decoupling the caller thread by enqueuing instead of calling the provided handler with its call arguments""" def inner(sender, match): self.handler_queue.put(partial(handler, sender, match=match)) return inner def process(self): """Processes the handler as a new thread""" while self.running: handler = self.handler_queue.get(block=True) if handler is not None: handler() def add_decoupled_handler(self, regexp: re.Pattern, handler: Callable[[Any, re.Match], None], priority: float = 0) -> None: """Converts given handler, so it does not block the caller""" self.add_handler(regexp, self.decoupled(handler), priority) def stop(self): """Signals a stop to the decoupler""" self.running = False self.handler_queue.put(lambda: None) def wait_stopped(self): """Waits until the decoupler is fully stopped""" if self.thread: self.thread.join() ================================================ FILE: prusa/link/serial/serial_queue.py ================================================ """ Contains implementation of the SerialQueue and the MonitoredSerialQueue The idea was to separate the monitoring functionality to not clutter the queue and instruction management """ import logging import re from collections import deque from threading import Event, Lock from time import time from typing import Deque, List, Optional from blinker import Signal # type: ignore from prusa.connect.printer.conditions import CondState from ..conditions import RPI_ENABLED, SERIAL from ..const import ( HISTORY_LENGTH, MAX_INT, QUIT_INTERVAL, RX_SIZE, SERIAL_QUEUE_MONITOR_INTERVAL, SERIAL_QUEUE_TIMEOUT, ) from ..interesting_logger import InterestingLogRotator from ..printer_adapter.structures.mc_singleton import MCSingleton from ..printer_adapter.structures.regular_expressions import ( ATTENTION_REGEX, BUSY_REGEX, CONFIRMATION_REGEX, HEATING_HOTEND_REGEX, HEATING_REGEX, M110_REGEX, RESEND_REGEX, ) from ..printer_adapter.updatable import Thread from ..util import loop_until, prctl_name from .instruction import Instruction, MatchableInstruction from .is_planner_fed import IsPlannerFed from .serial import SerialException from .serial_adapter import SerialAdapter from .serial_parser import ThreadedSerialParser log = logging.getLogger(__name__) class SerialQueue(metaclass=MCSingleton): """ Class responsible for sending commands to the printer Messages need to be sent one by one and need to be confirmed afterwards There are many edge cases like resend requests, message number resets RX buffer dumping and so on, which this class works around to provide as deterministic of a serial connection to a Prusa printer as possible """ def __init__(self, serial_adapter: SerialAdapter, serial_parser: ThreadedSerialParser, threshold_path: str, rx_size=RX_SIZE): self.serial_adapter = serial_adapter self.serial_parser = serial_parser # When the serial_queue cannot re-establish communication with the # printer, let's signal this to other modules self.serial_queue_failed = Signal() self.instruction_confirmed_signal = Signal() self.message_number_changed = Signal() # A queue of instructions for the printer self.queue: Deque[Instruction] = deque() # This one shall contain time critical instructions self.priority_queue: Deque[Instruction] = deque() # Instruction that is currently being handled self.current_instruction: Optional[Instruction] = None # Maximum bytes we'll write self.rx_max = rx_size # Make it possible to enqueue multiple consecutive instructions self.write_lock = Lock() # For numbered messages with checksums self.message_number = 0 # When filament runs out or other buffer flushing calamity occurs # We need to re-send some commands that we already had dismissed as # confirmed self.send_history: Deque[Instruction] = deque(maxlen=HISTORY_LENGTH) # A list which will contain all messages needed to recover self.recovery_list: List[Instruction] = [] self.rx_yeet_slot = None # For stopping fast (power panic) self.closed = False # Flag to be set when serial communication fails self.has_failed = False # Workaround around M110 involves syncing the FW buffers using a G4 # Whenever an M110 comes, a G4 needs to be prepended. # To avoid getting stuck in an endless loop, let's flip a flag self.m110_workaround_slot = None self.worked_around_m110 = False # Allows to temporarily block sending to the serial queue self._block_sending = False self.serial_parser.add_handler(CONFIRMATION_REGEX, self._confirmation_handler, priority=float("inf")) self.serial_parser.add_handler(RESEND_REGEX, self._resend_handler) self.is_planner_fed = IsPlannerFed(threshold_path) self.quit_evt = Event() self.send_event = Event() self.sender_thread = Thread(name="sq_sender", target=self._keep_sending, daemon=True) self.sender_thread.start() def _keep_sending(self): """Send the most important instruction when asked nicely""" prctl_name() while True: self.send_event.wait() if self.quit_evt.is_set(): break self.send_event.clear() if self._block_sending: continue with self.write_lock: if not self.can_write(): continue try: self._send() except (SerialException, OSError): log.info("A serial write has failed, expecting serial " "reader to fix the problem. In the meantime " "waiting for a nudge to send again.") def block_sending(self): """Block sending of instructions until we unblock again""" self._block_sending = True def unblock_sending(self): """Unblock sending of instructions""" if self._block_sending: self._block_sending = False self._try_writing() def _try_writing(self): """ Nudge the sender thread to send an instruction """ self.send_event.set() def stop(self): """ Stops the serial queue sender """ self.quit_evt.set() self.send_event.set() def wait_stopped(self): """Waits for the serial queue to stop""" self.sender_thread.join() def peek_next(self): """Look, what the next instruction is going to be""" # pylint: disable=too-many-return-statements if self.m110_workaround_slot is not None: return self.m110_workaround_slot if self.rx_yeet_slot is not None: return self.rx_yeet_slot if self.recovery_list: return self.recovery_list[-1] if self.priority_queue: if self.is_planner_fed() and self.queue: return self.queue[-1] return self.priority_queue[-1] if self.queue: return self.queue[-1] return None def _next_instruction(self): """ Get a fresh instruction into the self.current_instruction handling slot """ if self.current_instruction is not None: raise RuntimeError("Cannot send a new instruction. " "When the last one didn't finish processing.") if self.m110_workaround_slot is not None: self.current_instruction = self.m110_workaround_slot self.m110_workaround_slot = None elif self.rx_yeet_slot is not None: self.current_instruction = self.rx_yeet_slot self.rx_yeet_slot = None elif self.recovery_list: self.current_instruction = self.recovery_list.pop() elif self.priority_queue: if self.is_planner_fed() and self.queue: # Invalidate, so the unimportant queue doesn't go all at once self.is_planner_fed.is_fed = False log.debug("Allowing a non-important instruction through") self.current_instruction = self.queue.pop() else: self.current_instruction = self.priority_queue.pop() elif self.queue: self.current_instruction = self.queue.pop() # --- If statements in methods --- def can_write(self): """Determines whether we're in a state suitable for writing""" return self.current_instruction is None and not self.is_empty() and \ not self.closed def is_empty(self): """Determines whether all queues and slots for writing are empty""" return not self.queue and not self.priority_queue and \ not self.recovery_list and self.rx_yeet_slot is None\ and self.m110_workaround_slot is None # --- Actual methods --- def _hookup_output_capture(self): """ Instructions can capture output, this will register the handlers necessary """ for regexp in self.current_instruction.capturing_regexps: self.serial_parser.add_handler( regexp, self.current_instruction.output_captured, priority=time()) def _teardown_output_capture(self): """ Tears down the capturing handlers, so they're not slowing us down and not preventing garbage collection """ for regexp in self.current_instruction.capturing_regexps: self.serial_parser.remove_handler( regexp, self.current_instruction.output_captured) def _send(self): """ Gets a new instruction and depending on what appears in the handling slot. Tries its best to send it """ next_instruction = self.peek_next() if M110_REGEX.match(next_instruction.message) and \ not self.worked_around_m110: self.m110_workaround_slot = Instruction("M400") self.worked_around_m110 = True self._next_instruction() instruction = self.current_instruction if instruction.data is None: if instruction.to_checksum: self.send_history.append(instruction) self.message_number += 1 if self.message_number == MAX_INT: self._reset_message_number() instruction.fill_data(self.message_number) # If the instruction is M110 read the value it'll set and save it m110_match = M110_REGEX.match(instruction.message) if m110_match: self.worked_around_m110 = False self.send_history.clear() log.debug("The message number is getting reset") number = m110_match.group("cmd_number") if number is not None: try: self.message_number = int(number) except ValueError: self.message_number = 0 size = len(instruction.data) if size > self.rx_max: log.warning( "The data %s we're trying to write is %sB. " "But we can only send %sB at most.", instruction.data.decode('ASCII'), size, self.rx_max) self._hookup_output_capture() self.current_instruction.sent() # Send the message number only after the instruction is sent if m110_match: self.message_number_changed.send(self.message_number) self.serial_adapter.write(self.current_instruction.data) def set_message_number(self, number): """Sets the message number to the given value Only for power panic recovery""" with self.write_lock: self.message_number = number def replenish_history(self, messages: List[str]): """Expects that the message number is set to the current instruction ought to be sent next""" from_number = self.message_number - (len(messages) - 1) self.send_history.clear() for i, message in enumerate(messages): instruction = Instruction(message, to_checksum=True) instruction.fill_data(from_number + i) self.send_history.append(instruction) def _enqueue(self, instruction: Instruction, to_front=False): """Internal method for enqueuing when already locked""" if to_front: self.priority_queue.appendleft(instruction) else: self.queue.appendleft(instruction) def enqueue_one(self, instruction: Instruction, to_front=False): """ Enqueue one instruction Don't interrupt, if anyone else is enqueueing instructions :param instruction: the thing to be enqueued :param to_front: whether to enqueue to front of the queue """ with self.write_lock: log.debug("%s enqueued %s", instruction, 'to the front' if to_front else '') self._enqueue(instruction, to_front) self._try_writing() def enqueue_list(self, instruction_list: List[MatchableInstruction], to_front=False): """ Enqueue list of instructions Don't interrupt, if anyone else is enqueueing instructions :param instruction_list: the list to enqueue :param to_front: whether to enqueue to front of the queue """ with self.write_lock: log.debug("Instructions %s enqueued %s", instruction_list, 'to the front' if to_front else '') for instruction in instruction_list: self._enqueue(instruction, to_front) self._try_writing() # --- Static capture handlers --- def _confirmation_handler(self, sender, match: re.Match): """Used to do M105 parsing, but that is not supported anymore.""" assert sender is not None assert match is not None self._confirmed() def _resend_handler(self, sender, match: re.Match): """ The printer can ask for re-sends of past numbered instructions. This method just parses the received match, does a bunch of checks and calls the actual handler resend() """ assert sender is not None number = int(match.group("cmd_number")) log.info("Resend of %s requested. Current is %s", number, self.message_number) if self.message_number >= number: if (self.current_instruction is None or not self.current_instruction.to_checksum): log.warning("Re-send requested for a non-numbered message") # If that happened, the non-numbered message got yeeted from # the buffer, so let's solve that first self._rx_got_yeeted() self._resend((self.message_number - number) + 1) else: log.warning("We haven't sent anything with that number yet. " "The communication shouldn't fail after this.") # --- def _resend(self, count): """If possible, enqueue already sent instruction starting from the one requested back into the recovery list/queue, to be re-sent""" if not 0 < count < len(self.send_history): log.error("Impossible re-send request! Aborting...") self._worst_case_scenario() else: with self.write_lock: # get the instructions newest first, they are going to reverse # in the list history = list(reversed(self.send_history)) self.recovery_list.clear() for instruction_from_history in history[:count]: instruction = Instruction( instruction_from_history.message, to_checksum=True, data=instruction_from_history.data, number=instruction_from_history.number) self.recovery_list.append(instruction) def _confirmed(self, force=False): """ Printer confirmed an instruction. Tears down the instruction and prepares the module for processing of a new one """ if self.current_instruction is None or \ not self.current_instruction.is_sent(): log.error("Unexpected message confirmation. Ignoring") elif self.current_instruction.confirm(force=force): if not force: # If a message was successfully confirmed, the rpi port # had to be ok imo RPI_ENABLED.state = CondState.OK self.instruction_confirmed_signal.send(self) with self.write_lock: instruction = self.current_instruction # If the instruction did not refuse to be confirmed # Yes, that needs to happen log.debug("%s confirmed", instruction) self._teardown_output_capture() if instruction.to_checksum: # Only check those times for check-summed instructions self.is_planner_fed.process_value( instruction.time_to_confirm) self.current_instruction = None else: InterestingLogRotator.trigger("instruction refusing confirmation.") log.debug( "%s refused confirmation. Hopefully it has a reason " "for that", self.current_instruction) self._try_writing() def _rx_got_yeeted(self): """ Something caused the RX buffer to get thrown out, let's re-send everything supposed to be in it. """ log.debug("Think that RX Buffer got yeeted, sending instruction again") # Let's bypass the check and write if we can. if self.current_instruction is not None: instruction = self.current_instruction # These two types have to be recovered in their own ways with self.write_lock: self.rx_yeet_slot = instruction self._teardown_output_capture() instruction.reset() self.current_instruction = None self._send() def reset_message_number(self): """ Does not wait for the result, everything that gets enqueued after this will be executed after this. If this is no longer true, stuff will break """ with self.write_lock: self._reset_message_number() def _reset_message_number(self): """Sends a massage number reset gcode to the printer""" instruction = Instruction("M110 N0") self._enqueue(instruction, to_front=True) def flush_print_queue(self): """ Only printing instructions are checksummed, so let's get rid of those. We don't need to confirm them, they shouldn't be waited on. The only component able to wait on them is file printer and that should be stopping when this is called. """ with self.write_lock: InterestingLogRotator.trigger("flushing of the serial queue.") new_queue = deque() for instruction in self.priority_queue: if not instruction.to_checksum: new_queue.append(instruction) self.priority_queue = new_queue self.recovery_list.clear() self._throw_out_current_instruction() def _flush_queues(self): """ Tries to get rid of every queue by fake force confirming all instructions, to keep the serial queue consistent for example after a reboot. """ if self.current_instruction is not None: # To flush the one instruction, that has not yet been confirmed # but has been sent, use the usual way self._throw_out_current_instruction() self._next_instruction() while self.current_instruction is not None: # obviously don't send the other ones, # so they can be handled faster self.current_instruction.sent() self.current_instruction.confirm(force=True) self.current_instruction = None self._next_instruction() def _throw_out_current_instruction(self): """Throws out the currently executed instruction""" if self.current_instruction is not None: self.current_instruction.confirm(force=True) self._teardown_output_capture() self.current_instruction = None def _worst_case_scenario(self): """ Everything has failed, let's abandon whatever we were doing and save the printer/user """ self.has_failed = True log.error("Communication failed. Aborting...") RPI_ENABLED.state = CondState.NOK self.serial_queue_failed.send(self) def printer_reconnected(self, was_printing, was_power_panic): """The printer reset, starts a thread to recover the serial queue from such a state""" Thread(target=self._printer_reconnected, args=(was_printing, was_power_panic), name="serial_queue_reset_thread").start() def _printer_reconnected(self, was_printing, was_power_panic): """ Printer resets for two reasons, it has been stopped by the user, or the serial communication failed. Either way, the old instructions inside the serial queue are now useless. This method flushes the queues and depending on what caused the error, moves the printer head up, or demands user attention. """ prctl_name() with self.write_lock: self._flush_queues() self._block_sending = False final_instruction = None if self.has_failed: beep_instruction = Instruction("M300 S880 P200") self._enqueue(beep_instruction, to_front=True) stop_instruction = Instruction("M603") self._enqueue(stop_instruction, to_front=True) message_instruction = Instruction("M1 FW COMM ERR. Aborted") self._enqueue(message_instruction, to_front=True) final_instruction = message_instruction self.has_failed = False elif was_printing and not was_power_panic: stop_instruction = Instruction("M603") self._enqueue(stop_instruction, to_front=True) final_instruction = stop_instruction if final_instruction is not None: self._try_writing() while not self.closed: if final_instruction.wait_for_confirmation( timeout=QUIT_INTERVAL): break class MonitoredSerialQueue(SerialQueue): """Separates the queue monitoring into a different class.""" def __init__(self, serial_adapter: SerialAdapter, serial_parser: ThreadedSerialParser, threshold_path: str, rx_size=128): super().__init__(serial_adapter, serial_parser, threshold_path, rx_size) self.stuck_counter = 0 self.serial_parser.add_handler( BUSY_REGEX, lambda sender, match: self._renew_timeout()) self.serial_parser.add_handler( ATTENTION_REGEX, lambda sender, match: self._renew_timeout()) self.serial_parser.add_handler( HEATING_REGEX, lambda sender, match: self._renew_timeout()) self.serial_parser.add_handler( HEATING_HOTEND_REGEX, lambda sender, match: self._renew_timeout()) # Remember when the last write or confirmation happened # If we want to time out, the communication has to be dead for some # time # Useful only with unbuffered messages self.last_event_on = time() self.monitoring_thread = Thread(target=self.keep_monitoring, name="sq_stall_recovery", daemon=True) self.monitoring_thread.start() def get_current_delay(self): """ If we are waiting on an instruction to be confirmed, returns the time we've been waiting """ if self.is_empty() and self.current_instruction is None: return 0 return time() - self.last_event_on def keep_monitoring(self): """Runs the loop of monitoring the queue""" prctl_name() loop_until(self.quit_evt, lambda: SERIAL_QUEUE_MONITOR_INTERVAL, self.check_status) def check_status(self): """ Called periodically. If the confirmation wait times out, calls the appropriate handler """ if self.get_current_delay() > SERIAL_QUEUE_TIMEOUT and SERIAL: # The printer did not respond in time, lets assume it forgot # what it was supposed to do log.info("Timed out waiting for confirmation of %s after %ssec.", self.current_instruction, SERIAL_QUEUE_TIMEOUT) log.debug("Assuming the printer yeeted our RX buffer") self.stuck_counter += 1 if self.stuck_counter > 2: log.warning("Closing the serial, because it's stuck") self.serial_adapter.close() InterestingLogRotator.trigger("a stuck instruction") self._rx_got_yeeted() self._renew_timeout(unstuck=False) def stop(self): """ Stops the monitoring thread If not required to go fast, saves the planner fed threshold """ super().stop() self.is_planner_fed.save() def wait_stopped(self): """Waits for the serial queue to stop""" super().stop() self.monitoring_thread.join() def _confirmed(self, force=False): """Adds a timeout renewal onto an instruction confirmation""" self._renew_timeout() super()._confirmed(force=force) def _renew_timeout(self, unstuck=True): """Renews the instruction confirmation """ self.last_event_on = time() if unstuck: self.stuck_counter = 0 ================================================ FILE: prusa/link/service_discovery.py ================================================ """ Implements the things for service discovery As of now only DNS-SD is supported """ import logging import socket from time import sleep from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen import zeroconf from zeroconf import NonUniqueNameException, ServiceInfo, Zeroconf from .const import SELF_PING_RETRY_INTERVAL, SELF_PING_TIMEOUT, instance_id from .interesting_logger import InterestingLogRotator from .printer_adapter.updatable import Thread from .util import prctl_name log = logging.getLogger(__name__) class ServiceDiscovery: """ A class implementing methods for easy registration of PrusaLink as a network service to be discoverable by prusa-slicer and alike """ def __init__(self, port): """Loads configuration and inits Zeroconf""" # Leave out service discovery logs from the interesting log # was sending too many messages InterestingLogRotator.get_instance().skip_logger(zeroconf._logger.log) self.zeroconf = Zeroconf() self.port = port self.hostname = socket.gethostname() self.number = 0 self.thread = Thread(target=self._register, daemon=True, name="zeroconf") self.thread.start() @staticmethod def _get_port_part(port): """Return the port part of an url""" return "" if int(port) == 80 else f":{port}" def is_on_port(self, port): """Check, if the same instance is presented on the specified port""" port_part = self._get_port_part(port) url = f"http://127.0.0.1{port_part}" request = Request(url, method="HEAD") try: with urlopen(request, timeout=SELF_PING_TIMEOUT) as response: return response.headers["Instance-ID"] == str(instance_id) except (HTTPError, URLError, socket.timeout): return False def _register(self): """ Registers services provided by us to be discoverable one _octoprint for "legacy" prusa-slicer support one _http, because we have a web server and one _prusa-link because why not """ prctl_name() # Wait for our own instance to be reachable on the configured port # if not, just try again while not self.is_on_port(self.port): log.warning( "Can't reach our own instance at the configured " "port: %s. If just initialising, this is normal", self.port) sleep(SELF_PING_RETRY_INTERVAL) # Try to connect using the default http port register_port = self.port if self.is_on_port(80): # if successful, register the 80 we are being forwarded to register_port = 80 log.debug("Reached our own instance at the port 80, " "running as root or being forwarded, awesome!") self._register_service("PrusaLink", "prusalink", register_port) self._register_service("PrusaLink", "http", register_port) # legacy slicer support self._register_service("PrusaLink", "octoprint", register_port) def unregister(self): """Unregisters all services""" self.zeroconf.unregister_all_services() def _register_service(self, name, service_type, port): """ Registers one service given its name and type param name: name of the service, can contain fairly fancy characters param service_type: The DNS-SD service type. A list can be found here http://www.dns-sd.org/ServiceTypes.html https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xml """ number = self.number while True: port_part = self._get_port_part(port) name_to_use = f"{name} at {self.hostname}{port_part}" if number > 0: name_to_use += f" ({number})" try: info = ServiceInfo(type_=f"_{service_type}._tcp.local.", name=f"{name_to_use}._{service_type}" f"._tcp.local.", port=port, server=f"{self.hostname}.local", properties={"path": "/"}) self.zeroconf.register_service(info) except NonUniqueNameException: number += 1 else: break self.number = number if number > 0: log.warning("Registered service named identically to others #%s", number) log.debug( "Registered service name: %s, type: %s, port: %s, " "server: %s", info.name, info.type, info.port, info.server) ================================================ FILE: prusa/link/static/css/bootstrap.connect.css ================================================ a { color: #fa6831 !important; } .flex-even { flex: 1; } section { margin-top: 10px; } #mainmenu { border-bottom: 1px solid #e0e0e0; padding-top: 0 !important; padding-bottom: 0 !important; } .nav-link { font-family: Helvetica, sans-serif; font-size: 15px; color: #2a2a2a !important; } .nav-link.disabled { color: #a0a0a0 !important; } .nav-link:hover { color: #fa6831 !important; } .nav-tabs { border-bottom: 1px solid #707070; } .nav-tabs .nav-link { padding: 0.8em 1.3em 0.7em 1.2em; text-transform: uppercase; color: #585858 !important; } .nav-tabs .nav-link:hover { color: #fa6831 !important; } .nav-tabs .nav-link.active:hover { color: #585858 !important; } .nav-tabs .nav-link:first-child { margin-left: 1em; } .nav-tabs .nav-link:last-child { margin-right: 1em; } .nav-tabs .nav-link { border-top-left-radius: 0; border-top-right-radius: 0 } .nav-tabs .nav-link:focus, .nav-tabs .nav-link:hover { border-color: #e9ecef #e9ecef #707070; } .nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active { color: #495057; background-color: #fff; border-color: #707070 #707070 #fff } .breadcrumb { padding: 0 1rem; margin-bottom: 0.5rem; background-color: transparent; border-radius: 0; font-size: 0.9em; color: black; } .breadcrumb-item .active { color: black; font-family: Helvetica, sans-serif; font-size: 0.9em; } .breadcrumb-item a { font-size: 0.9em; color: #797979 !important; font-family: Helvetica, sans-serif; } .btn { font-family: Helvetica, sans-serif; border: 1px solid transparent; padding: 0.3em 0.75em; white-space: pre-line; word-break: break-word; } .btn img { position: relative; top: -2px; padding: 0; } .btn svg.append { margin-right: 0; margin-left: 0.25em; } .btn-group .btn { border-right-width: 0; } .btn-group .btn:last-child { border-right-width: 1px; } .btn-outline-primary, .btn-outline-secondary, .btn-outline-success, .btn-outline-warning, .btn-outline-danger, .btn-outline-info, .btn-outline-light, .btn-outline-dark { border-color: white; color: white; } .btn-outline-primary:hover, .btn-outline-secondary:hover, .btn-outline-success:hover, .btn-outline-warning:hover, .btn-outline-danger:hover, .btn-outline-info:hover, .btn-outline-light:hover, .btn-outline-dark:hover { color: black !important; } .btn-back { color: #585858 !important; } ================================================ FILE: prusa/link/static/css/bootstrap.prusa-link.css ================================================ body, html { font-size: 18px; color: #7a7a7a; background-color: hsl(0, 0%, 4%); height: 100%; font-family: Helvetica, sans-serif; } a { color: #fff !important; } a.active { text-decoration: underline !important; } h1, h2, h3, h4 { color: #f5f5f5; margin-top: 2em; margin-bottom: 2em; } h1 { font-size: 28px; } h2 { font-size: 22px; } h3 { font-size: 18px; } pre { color: #7a7a7a; } p>b { color: #f5f5f5; } .jumbotron { background-color: hsl(0, 0%, 10%); } .nav-link { color: #f5f5f5 !important; font-size: 18px; border-bottom: .1rem solid transparent; } .navbar-logo { background: white url("/img/prusa-link-logo.svg") no-repeat 50%; background-size: cover; width: 288px; height: 69px; bottom: 3px; display: flex; } .white { color: #f5f5f5; } .orange { color: #f9c129 } .progress { margin-bottom: 3em; } .progress, .progress-bar { overflow: visible; } .progress-bar a { position: relative; top: 2em; text-decoration: underline; text-decoration-color: #6c757d; } .progress-bar a:hover { text-decoration: none; } .align-center { text-align: center; } .text-muted { font-size: 0.75em; } .img-center { text-align: center; } .navigation { margin-top: 2em; } code { color: white; } .api-key { font-size: 22px; cursor: copy; } .api-key img { margin-left: 0.5em; } .condensed { margin-bottom: 1em; } #ports_section {display: none;} #ports_section_show{ color: #ffffff; cursor:pointer; } #ports_section_show:hover{ color: #f2f2f2; } @media(max-width: 768px) { input.full-width, button.full-width, a.full-width { width: 100%; margin-bottom: 0.5em; } } ================================================ FILE: prusa/link/static/index.html ================================================ PrusaLink

Printer status

NA

Nozzle Temperature

NA NA

Heatbed

NA NA

Printing Speed

NA

Z - Height

NA

Nozzle Diameter

NA

================================================ FILE: prusa/link/static/main.9b8dc0068f6e6508dfd4.js ================================================ (()=>{var e={5862:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"f844a0a85dde310826fce450c3e149d3.svg"},340:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"d0ce7d704590cb0b868ae92249ee86e8.svg"},3246:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"e6e0734bc2dad609a3e92f1873412f11.svg"},6730:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"557f3616d5a1b407b59795b8328bb51f.svg"},8065:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"2e842fcb7b83af8e1e0d26afd80f8fd9.svg"},8920:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"24c2502c54c43441d0bb4bbc17e10a83.svg"},2456:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"f6a7f34a841f532dd1e3e4d610d164f6.svg"},9819:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"53df9c11e268c2390b147239f9ee8796.svg"},7038:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"9332c323b291ba7226ddcdabb0c8e7c4.svg"},1656:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"79e90794ba3b36a64a12414decda0932.svg"},9594:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"a443dadfc7e114c74dae6cd64a74db9d.svg"},931:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"926f52aea63cfd597b30096bfe4077c6.svg"},7336:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"44b654f4ca724e154a9bc60335e98847.svg"},9387:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"8657650f85be23655543d28f58941616.svg"},5300:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"ea93bcffb8771234f8c641f3b7e9f848.svg"},3482:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"ccd215a92d0427a21339a8c38e2a40f3.svg"},5515:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"997c391425907810b4a6e42663d11fd4.svg"},4578:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"24e2e49c87df140cf16aa18b4e261f12.svg"},2e3:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"58151992d28dfb34ee417e0ebf6a66b6.svg"},2290:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"c8a0ca767f73685bbe06863f81e9d3cb.svg"},3174:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"39e93870e6b8fd4ef2d9ed668f8c1545.svg"},8796:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"1218694688ee15ee97bcded9e1a75cc8.svg"},1373:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"dfb811cc28f8e3d7c14bcedbe7ef35b8.svg"},4622:(e,t,a)=>{"use strict";a.r(t),a.d(t,{default:()=>i});const i=a.p+"b891ace1622f34bac5a2b4edb7adc733.svg"},5464:(e,t,a)=>{var i=a(7091),s=a(8065),n=a(4622),r=a(6730),o='

  • Camera Name

    -

  • Snapshot Time

    -

    ';e.exports=o},3478:(e,t,a)=>{var i=a(7091),s=a(340),n=a(5862),r=a(1656),o=i(s),l=i(n),d=i(r),c='

    printer coordinates

    X axis

    0 mm

    Y axis

    0 mm

    Z axis

    0 mm

    stepper motors

    heated bed X and Y move

    move step [mm]

    nozzle Z move

    extruder

    extrude/retract step [mm]

    nozzle temperature

    0°C

    speed

    100%

    heated bed temperature

    0°C

    flow

    100%

    ';e.exports=c},2373:(e,t,a)=>{var i=a(7091),s=a(2e3),n=a(2290),r=a(3246),o=a(4578),l=a(7038),d=a(2456),c=a(9819),u=a(9387),p=a(5300),m=a(3482),v=a(4622),g=a(6730),h=a(3174),b=a(8796),f=i(s),y=i(n),k=i(r),w=i(o),x=i(l),z=i(d),S=i(c),E=i(u),P=i(p),L=i(m),I=i(v),N=i(g),_='

    Upload file

    Add file from

     

    Click to choose a *.sl1 file or drag it here

    Source URL

    Type URL of G-CODE file

    File name

    Type or edit file name

    Progress

    NA

    Size

    NA

    Download Started

    NA

    Remaining Time

    NA

    Autostart

    NA

    Temperatures

    Cameras

    • -

    • -

    ';e.exports=_},7189:(e,t,a)=>{var i=a(7091),s=a(2e3),n=a(2290),r=a(3246),o=a(4578),l=a(7038),d=a(2456),c=a(9819),u=a(9387),p=a(5300),m=a(3482),v=a(4622),g=a(6730),h=a(931),b=a(5515),f=a(3174),y=a(8796),k=a(9594),w=a(8065),x=a(8920),z=i(s),S=i(n),E=i(r),P=i(o),L=i(l),I=i(d),N=i(c),_=i(u),T=i(p),j=i(m),C=i(v),A=i(g),D=i(h),B=i(b),O=i(f),R=i(y),q=i(k),M=i(w),H=i(x),U='

    Local

    0%

    0 GB of 0 GB free

    Add file from

     

    Click to choose a *.sl1 file or drag it here

    Source URL

    Type URL of G-CODE file

    File name

    Type or edit file name

    Progress

    NA

    Size

    NA

    Download Started

    NA

    Remaining Time

    NA

    Autostart

    NA

    ';e.exports=U},5198:(e,t,a)=>{var i=a(7091),s=a(4622),n=a(6730),r='

    ';e.exports=r},2936:(e,t,a)=>{var i=a(7091),s=a(7038),n=a(7336),r=a(1373),o=a(4622),l=a(6730),d=i(s),c=i(n),u=i(r),p='

    version

    api

    hostname

    firmware

    server

    text

    sdk

    frontend

    3.12.0

    system version

    updates

    connection

    PRUSA CONNECT

    connection status

    Successfully connected

    3D printer connection status

    Successfully connected

    printer

    printer name

    printer location

    network error chime

    user

    username

    new password

    repeat password

    current password

    serial number

    serial number

    api key

    api key

    logs

    select file

    • No log file is selected!
    ';e.exports=p},7091:e=>{"use strict";e.exports=function(e,t){return t||(t={}),"string"!=typeof(e=e&&e.__esModule?e.default:e)?e:(t.hash&&(e+=t.hash),t.maybeNeedQuotes&&/[\t\n\f\r "'=<>`]/.test(e)?'"'.concat(e,'"'):e)}},6648:(e,t,a)=>{"use strict";a.d(t,{LK:()=>d,O2:()=>n,Z5:()=>g,gJ:()=>m,iT:()=>c,wU:()=>o});var i=a(8236),s=a(1351);const n=window.location.pathname.endsWith("/")?window.location.pathname.slice(0,-1):window.location.pathname,r=()=>new Promise(((e,t)=>{(0,i.o)((e=>t=>{const a=document.getElementById("modal-apiKey"),i=document.importNode(a.content,!0);return i.getElementById("apiKey").addEventListener("keydown",(a=>{"Enter"==a.key&&(t(),e(a.target.value))})),i.getElementById("login").addEventListener("click",(a=>{a.preventDefault();let i=document.getElementById("apiKey").value;t(),e(i)})),i})(e),{timeout:0,closeOutside:!1})})).then((e=>sessionStorage.setItem("apiKey",e))),o=(e="application/json")=>"ApiKey"==sessionStorage.getItem("authType")?{"X-Api-Key":sessionStorage.getItem("apiKey"),Accept:e}:{Accept:e},l=()=>new Promise(((e,t)=>(sessionStorage.setItem("auth","pending"),fetch(`${n}/api/v1/info`,{headers:o()}).then((t=>{if(401==t.status){const a=t.headers.get("WWW-Authenticate").split(" ")[0];return sessionStorage.setItem("authType",a),sessionStorage.removeItem("apiKey"),"ApiKey"==a?r().then((()=>l().then((t=>e(t))))):l().then((t=>e(t)))}{const e=t.json();return 200!=t.status&&e.then((e=>(0,s.S)({data:e}))),e}})).then((t=>{sessionStorage.setItem("auth","true"),e(t)}))))),d=(e,t={})=>u(e,t,"application/json","json"),c=(e,t={})=>u(e,t,"text/plain","text");async function u(e,t={},a,i){if("true"!=sessionStorage.getItem("auth"))throw{code:401};{t.headers={...t.headers,...o(a)};const s=await fetch(`${n}${e}`,t),r=s.status,l={code:r,eTag:s.headers.get("etag")};switch(r){case 401:throw sessionStorage.setItem("auth","false"),l;case 204:case 304:return l;default:const e=await s.text();if(!s.ok){if(e.length>0)try{l.data=JSON.parse(e)}catch{}throw l.data=l.data||{title:`Error ${r}`,message:s.statusText},l}return l.data="json"===i?0===e.length?{}:JSON.parse(e):e,l}}}const p=(e,t,a)=>new Promise(((i,s)=>{"true"==sessionStorage.getItem("auth")?(t.headers={...o(),...t.headers},fetch(a?`${n}${e}?ct=${a}`:e,t).then((e=>{401==e.status&&(sessionStorage.setItem("auth","false"),s(e)),e.ok?e.blob().then((t=>i({url:URL.createObjectURL(t),headers:e.headers}))):s(e)})).catch((e=>s(e)))):s()})),m=(e,t,a={})=>p(`${n}${e}`,{...a,headers:{...a.headers,Accept:"image/*"}},t),v=e=>{const t=document.getElementById("modal-welcome"),a=document.importNode(t.content,!0);return a.querySelector(".close-button").addEventListener("click",e),a},g=()=>null==localStorage.getItem("showWelcome")?new Promise(((e,t)=>{(0,i.o)(v,{closeCallback:()=>{localStorage.setItem("showWelcome",!0),e()}})})).then((()=>l())):l()},1972:(e,t,a)=>{"use strict";a.d(t,{Z:()=>s});var i=a(6648);const s=function(e,t){let a=document.createElement("a");a.href=`${i.O2}${e}`,a.download=t||"",a.click(),a.remove()}},646:(e,t,a)=>{"use strict";function i(e,t=!0){e&&(t&&!e.hasAttribute("hidden")&&e.setAttribute("hidden",!0),!t&&e.hasAttribute("hidden")&&e.removeAttribute("hidden"))}function s(e,t=!0){i(e,!t)}function n(e,t=!0){e&&(t&&!e.hasAttribute("disabled")&&e.setAttribute("disabled",!0),!t&&e.hasAttribute("disabled")&&e.removeAttribute("disabled"))}function r(e,t=!0){return n(e,!t)}function o(){s(document.querySelector("#job .loading-overlay"))}function l(){i(document.querySelector("#job .loading-overlay"))}function d(e,...t){const a=i=>{if(t)for(const e of t)if(e&&e.contains(i.target))return;e&&e(),window.removeEventListener("pointerup",a)};window.addEventListener("pointerup",a)}a.d(t,{H:()=>n,QH:()=>i,QP:()=>o,Ti:()=>d,Zk:()=>l,gL:()=>r,yx:()=>s})},5537:(e,t,a)=>{"use strict";a.d(t,{Z:()=>i});const i=function(e){return"string"==typeof e?document.getElementById(e):e||document.body}},9741:e=>{e.exports=function(e,t){let a=t.split("."),i=e;for(const e of a)if(i=i[e],!i)break;return i}},7780:(e,t,a)=>{"use strict";a.d(t,{G3:()=>v,Iu:()=>h,Vb:()=>g,m0:()=>m,ot:()=>f});var i=a(5537),s=a(9741),n=a.n(s);const r=a(4977),o=r.langs,l=r.texts,d=o.indexOf("en");let c,u;function p(){return navigator.language||navigator.userLanguage||""}function m(e){const t=o.indexOf(e);return-1!==t&&(u=t,c=e,localStorage.setItem("lang",c),!0)}function v(){return c}function g(){return[...o]}function h(e,t){let a=n()(l,`${e}.${u}`);if(!a)return a=n()(l,`${e}.${d}`),a||(a=e),b(a,t),a;let i=null;if(t&&(i=Object.assign({},t),delete i.query,delete i.ref),i&&Object.keys(i).length>0){let e,i=a,s=/{{(.*?)}}/g;for(;e=s.exec(a);){let s=a.substr(e.index+2,e[0].length-4);if("query"!==s&&"ref"!==s)if(s in t){let a=t[s];i=i.replace(e[0],a)}else 0}a=i}return b(a,t),a}function b(e,t){if(t)if(t.ref){t.ref.innerHTML=e}else if(t.query){let a=document.querySelector(t.query);a?a.innerHTML=e:console.warn(`cannot find element with "${t.query}" query`)}}function f(e){(0,i.Z)(e).querySelectorAll('[data-label]:not([data-label=""])').forEach((e=>{const t=h(e.getAttribute("data-label")),a=e.getAttribute("data-label-target");a?e.setAttribute(a,t):e.innerHTML=t}))}m(localStorage.getItem("lang"))||m(p().toLowerCase())||m(p().toLowerCase().split("-")[0])||m("en")},2451:(e,t,a)=>{"use strict";a.d(t,{Z:()=>L});var i=a(6648),s=a(646),n=a(7780),r=a(3707),o=a(8236),l=a(5412),d=a(1351);let c,u=!1,p=[];const m={TEN_SEC:"TEN_SEC",THIRTY_SEC:"THIRTY_SEC",SIXTY_SEC:"SIXTY_SEC",EACH_LAYER:"EACH_LAYER",FIFTH_LAYER:"FIFTH_LAYER",MANUAL:"MANUAL"},v=e=>{switch(e){case m.TEN_SEC:return(0,n.Iu)("cameras.trigger-scheme.ten-sec");case m.THIRTY_SEC:return(0,n.Iu)("cameras.trigger-scheme.thirty-sec");case m.SIXTY_SEC:return(0,n.Iu)("cameras.trigger-scheme.sixty-sec");case m.EACH_LAYER:return(0,n.Iu)("cameras.trigger-scheme.each-layer");case m.FIFTH_LAYER:return(0,n.Iu)("cameras.trigger-scheme.fifth-layer");case m.MANUAL:return(0,n.Iu)("cameras.trigger-scheme.manual");default:return`${e}`}},g=()=>(0,r.Vp)((0,n.Iu)("ntf.success"),(0,n.Iu)("ntf.camera-suc")),h=(e,t=E)=>{void 0===c&&(c=e.camera.id),u=e.link.connect.ok,(0,i.LK)("/api/v1/cameras").then((e=>{const a=(e?.data?.camera_list||[]).map((e=>{let t=p.find((t=>t.id===e.camera_id))||{};return e.camera_id===c&&(e.connected||(c=null)),{id:e.camera_id,config:e.config,connected:e.connected,detected:e.detected,stored:e.stored,registered:e.registered,nextSnapshotAt:t?.nextSnapshotAt,lastSnapshotAt:t?.lastSnapshotAt,lastSnapshotUrl:t?.lastSnapshotUrl}})),i=p.filter((e=>!a.find((t=>e.id===t.id))));t&&t(a,i),p=a})).catch(d.S),p.filter((e=>e.connected)).forEach((e=>y(e.id)))},b=e=>`camera_${e}`,f=e=>document.getElementById(b(e)),y=e=>{const t=p.find((t=>t.id===e));if(!t)return;const a=new Date;t.lastSnapshotAt&&t.nextSnapshotAt&&a{const n=p.find((t=>t.id===e)),r=b(e),o=document.querySelector(`#${r} .camera__no-snapshot`),l=document.querySelector(`#${r} .camera__snapshot`);if(o&&(0,s.yx)(o,!1),l&&((0,s.yx)(l,!0),l.src=t),n){const e=`${i.get("cache-control")}`.match(/max-age=(\d+)/);let s;e&&(s=parseInt(e[1],10)),s||(s=11);const r=i.get("expires"),o=i.get("last-modified"),l=e=>{const t=new Date;return new Date(t.getTime()+1e3*e)};n.nextSnapshotAt=r?new Date(r):l(s),n.nextSnapshotAt{e||(e=c);const t=e?p.find((t=>t.id===e)):null,a=document.getElementById("camera-snapshot-picture"),i=document.getElementById("camera-snapshot-time"),s=document.getElementById("camera-snapshot-name"),[n,r,o,l]=t?.lastSnapshotAt?[t.id,t.config.name,t.lastSnapshotAt.toLocaleString(),t.lastSnapshotUrl]:[null,"-","-",""];c=n,a&&(a.src=l),i&&(i.innerText=o),s&&(s.innerText=r)},w=(e,t,a=!1)=>{e.querySelector(".camera__name").innerText=t.config.name,e.querySelector(".camera__path").innerText=t.config.path||"-",e.querySelector(".camera__driver").innerText=t.config.driver||"-",e.querySelector(".camera__cloud").innerText=t.registered?(0,n.Iu)("camera.cloud.linked"):(0,n.Iu)("camera.cloud.not-linked");const i=e.querySelector(".camera__register"),r=e.querySelector(".camera__unregister"),o=e.querySelector(".camera__add"),l=e.querySelector(".camera__remove"),d=e.querySelector(".camera__settings");(0,s.yx)(i,u&&t.connected&&!t.registered),(0,s.yx)(r,u&&t.connected&&t.registered),(0,s.yx)(o,!1),(0,s.yx)(l,!t.connected),(0,s.yx)(d,t.connected),a&&(o.title=(0,n.Iu)("camera.btn.connect"),d.title=(0,n.Iu)("camera.btn.settings"),i.title=(0,n.Iu)("camera.btn.link"),r.title=(0,n.Iu)("camera.btn.unlink"),o.addEventListener("click",(e=>{e.stopPropagation(),z(t.id)}),!1),l.addEventListener("click",(e=>{e.stopPropagation(),S(t.id)}),!1),d.addEventListener("click",(e=>{e.stopPropagation(),P(t.id)}),!1),i.addEventListener("click",(e=>{e.stopPropagation(),x(t.id,"POST")}),!1),r.addEventListener("click",(e=>{e.stopPropagation(),x(t.id,"DELETE")}),!1))},x=(e,t)=>{(0,i.LK)(`/api/v1/cameras/${e}/connection`,{method:t}).then((()=>g())).catch(d.S)},z=e=>{const t=p.find((t=>t.id===e));t&&(0,i.LK)(`/api/v1/cameras/${t.id}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({config:t.config})}).then((()=>g())).catch(d.S)},S=e=>{const t=p.find((t=>t.id===e));t&&(0,i.LK)(`/api/v1/cameras/${t.id}`,{method:"DELETE"}).then((()=>g())).catch(d.S)},E=(e,t)=>{const a=document.getElementById("cameras-list");t.forEach((e=>{const t=f(e.id);t&&a.removeChild(t)})),e.sort(((e,t)=>t.connected-e.connected)).forEach((e=>{const t=f(e.id);if(t)w(t,e);else{const t=(e=>{const t=document.getElementById("camera-list-item")?.content;if(!t)return null;const a=document.importNode(t,!0),i=a.querySelector("li"),r=e.id;return i.addEventListener("click",(e=>{const t=p.find((e=>e.id===r));t&&t.connected&&k(t.id),e.preventDefault()}),!1),i.id=b(e.id),a.querySelector(".camera__path__label").innerText=(0,n.Iu)("camera.path"),a.querySelector(".camera__driver__label").innerText=(0,n.Iu)("camera.driver"),a.querySelector(".camera__cloud__label").innerText=(0,n.Iu)("camera.cloud.label"),(0,s.yx)(a.querySelector(".camera__snapshot"),!1),w(a,e,!0),a})(e);t&&a.appendChild(t)}}))},P=e=>new Promise(((t,a)=>{(0,o.o)(((e,t)=>t=>{const a=`/api/v1/cameras/${e}`,o=document.getElementById("modal-camera-settings"),c=document.importNode(o.content,!0),u=c.getElementById("camera-settings__name"),p=c.getElementById("camera-settings__focus"),g=l.L.init(c.getElementById("camera-settings__resolution"),"camera-settings__resolution"),h=l.L.init(c.getElementById("camera-settings__trigger-scheme"),"camera-settings__trigger-scheme"),b=c.getElementById("yes");return(0,i.LK)(a).then((e=>{const o=e.data,l=o.available_resolutions.sort(((e,t)=>e.width===t.width?t.height-e.height:t.width-e.width)).map(((e,t)=>`${e.width}x${e.height}`)),c=Object.keys(m),f=c.map((e=>v(e))),y=o.capabilities.includes("FOCUS");u.value=o.name,g.setOptions(l),g.value=`${o.resolution.width}x${o.resolution.height}`,h.setOptions(f),h.value=v(o.trigger_scheme),(0,s.yx)(p.parentNode,y),y&&(p.value=Math.round(100*o.focus)),b.addEventListener("click",(()=>{const[e,s]=g.value.split("x").map((e=>parseInt(e))),o=c[f.indexOf(h.value)],l=y?{focus:p.value/100}:{};(0,i.LK)(`${a}/config`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:u.value,resolution:{width:e,height:s},trigger_scheme:o,...l})}).then((()=>(0,r.Vp)((0,n.Iu)("ntf.success"),(0,n.Iu)("ntf.camera-config-success")))).catch(d.S).finally(t)}))})).catch(d.S),c.getElementById("no").addEventListener("click",(()=>t())),c})(e),{timeout:0,closeOutside:!1})})).then((()=>{lastUpdated=null,h()})),L={load:e=>{p=[],(0,n.Iu)("cameras.link",{query:"#title-status-label"}),h(e)},update:h,getCameraNode:f,getCameraNodeId:b,updateCurrentCamera:k}},732:(e,t,a)=>{"use strict";a.d(t,{Z:()=>v});var i=a(7780),s=a(646),n=a(5489),r=a(6648);var o=a(1351),l=a(4800);let d=1,c=1;const u=e=>{(0,n.ZP)("control",e),function(e){const t=e.state,a=document.querySelectorAll("#control button"),i=["extrude","retract"],n=["flowrate","bed","nozzle","speed"],r=[...n,...i,"move-step","extrude-retract-step","heated-bed-xy-move"];if([l.PT.PRINTING||l.PT.BUSY||l.PT.PAUSED].includes(t)){const e=t===l.PT.PAUSED?r:n;a.forEach((t=>{const a=t.id||t.parentNode.id||t.parentNode.parentNode.id;(0,s.H)(t,!e.includes(a))}))}const o=e.telemetry.temperature.nozzle.current||0,d=e.printer.minExtrusionTemp||0,c=o&&d&&o>=d;i.forEach((e=>(0,s.H)(document.getElementById(e),!c)))}(e)};function p(e,t){if("move"===e){(e=>(0,r.LK)("/api/printer/printhead",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"jog",...e})}))(function(e){const t=e.includes("-"),a=e.replace(RegExp("[+-]"),"");return a?{[a]:t?-d:d}:(console.error(`"${e}" is not valid direction`),{})}(t)).catch((e=>(0,o.S)(e)))}else if("home"===e){(e=>(0,r.LK)("/api/printer/printhead",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"home",axes:e})}))(t.split(",")).catch((e=>(0,o.S)(e)))}}function m(e,t,a){function i(e){return Number.parseFloat(e.getAttribute("data-step"))}if(e){const n=e.querySelectorAll("button[data-step]"),r=e=>{n.forEach((t=>{i(t)===e?((0,s.H)(t),t.setAttribute("selected",!0)):((0,s.gL)(t),t.hasAttribute("selected")&&t.removeAttribute("selected"))}))};n.forEach((e=>{const t=i(e);isNaN(t)||(e.onclick=()=>{r(t),a(t)})})),r(t)}}const v={load:e=>{(0,i.Iu)("control.title",{query:"#title-status-label"}),function(e){function t(e,t){switch(e){case"bed":return i=t,(0,r.LK)("/api/printer/bed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"target",target:i})});case"flowrate":return a=t,(0,r.LK)("/api/printer/tool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"flowrate",factor:a})});case"nozzle":return(e=>(0,r.LK)("/api/printer/tool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"target",targets:{tool0:e}})}))(t);case"speed":return(e=>(0,r.LK)("/api/printer/printhead",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"speed",factor:e})}))(t);default:throw Error("Unknown property!")}var a,i}e&&e.querySelectorAll(".input-wrapper").forEach((e=>{const a=e.querySelector("input"),i=e.querySelector("button");if(a){const e=()=>{const e=a.getAttribute("data-action"),i=Number.parseFloat(a.value);isNaN(i)||t(e,i).then((e=>{a.value=""})).catch((e=>(0,o.S)(e)))};a.onkeyup=t=>{"Enter"===t.key&&(e(),a.blur())},i&&(i.onclick=e)}}))}(document.querySelector("#control")),m(document.querySelector("#control #move-step"),d,(e=>d=e)),m(document.querySelector("#control #extrude-retract-step"),c,(e=>c=e)),function(){const e=document.querySelector("#control #disable-steppers");e&&(e.onclick=()=>{(0,r.LK)("/api/printer/printhead",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"disable_steppers"})}).catch((e=>(0,o.S)(e)))})}(),function(){const e=document.querySelector("#control #extrude");e&&(e.onclick=()=>{var e;(e=c,(0,r.LK)("/api/printer/tool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"extrude",amount:e})})).catch((e=>(0,o.S)(e)))})}(),function(){const e=document.querySelector("#control #retract");e&&(e.onclick=()=>{var e;(e=c,(0,r.LK)("/api/printer/tool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"extrude",amount:-e})})).catch((e=>(0,o.S)(e)))})}(),function(){const e=document.querySelector("#control #heated-bed-xy-move");e&&e.querySelectorAll("button[data-action]").forEach((e=>{e.onclick=()=>{p(e.getAttribute("data-action"),e.getAttribute("data-value"))}}))}(),function(){const e=document.querySelector("#control #nozzle-z-move");e&&e.querySelectorAll("button[data-action]").forEach((e=>{e.onclick=()=>{p(e.getAttribute("data-action"),e.getAttribute("data-value"))}}))}(),u(e)},update:u}},5502:(e,t,a)=>{"use strict";a.d(t,{Z:()=>y});var i=a(7780);const s=(0,i.Iu)("prop.at"),n=(0,i.Iu)("unit.h"),r=(0,i.Iu)("prop.less-than"),o=(0,i.Iu)("unit.min"),l=(0,i.Iu)("unit.ml"),d=(0,i.Iu)("unit.rpm"),c=(0,i.Iu)("prop.today-at"),u=(0,i.Iu)("prop.tmw-at"),p=(0,i.Iu)("unit.b"),m=(0,i.Iu)("unit.kb"),v=(0,i.Iu)("unit.mb"),g=(0,i.Iu)("unit.gb"),h=(0,i.Iu)("prop.true"),b=(0,i.Iu)("prop.false");function f(e,t=!0,a=1){return e>0?t?e.toFixed(a):e:0}const y=(e,t)=>{if(void 0===t||null===t&&"progress"!==e)return(0,i.Iu)("prop.na");switch(e){case"int":return parseInt(t);case"number":return f(t);case"layer":return f(t,!1)+" mm";case"totalLayer":return function(e){const t=e?.progress?.currentLayer,a=e?.job?.file?.layers;return null==t||null==a?(0,i.Iu)("prop.na"):`${t}/${a}`}(t);case"material":case"material":return t||(0,i.Iu)("prop.na");case"temp":return f(t)+" °C";case"temp_int":return f(t,0)+"°C";case"fan":return f(t)+` ${d}`;case"resin":return f(t)+` ${l}`;case"cover":return t?(0,i.Iu)("prop.cover-closed"):(0,i.Iu)("prop.cover-opened");case"print":case"progressPct":return f(t||0,!0,0)+"%";case"pos":return f(t)+" mm";case"date":return function(e){const t=new Date(1e3*e);var a=localStorage.getItem("lang");return(t.toLocaleDateString(a,{year:"numeric",month:"numeric",day:"numeric"})+" "+t.toLocaleTimeString(a,{hour:"numeric",minute:"numeric"})).substring(0,25)}(t);case"time":return function(e){if(e<60)return r;const t=Math.floor(e/60%60),a=Math.floor(e/3600);return(a>0?`${a} ${n}`:"")+(t>0?` ${t} ${o}`:"")}(t);case"timeEst":return function(e){let t="00:00";if(e){let a=new Date,i=new Date(a.getTime()+1e3*e),n=new Date(a);n.setDate(n.getDate()+1);let r="";if(i.getDate()==a.getDate()&&i.getMonth()==a.getMonth())r=`${c} `;else if(i.getDate()==n.getDate()&&i.getMonth()==n.getMonth())r=`${u} `;else{let e={month:"numeric",day:"numeric"};r=`${i.toLocaleString(window.navigator.language,e)} ${s} `}t=r+("0"+i.getHours()).substr(-2)+":"+("0"+i.getMinutes()).substr(-2)}return t}(t);case"progress":return f(100*(t||0),!0,0)+"%";case"percent":return`${f(t||0,!0,0)}%`;case"size":return function(e){let t=e;const a=[p,m,v,g];for(let e=0;e{"use strict";a.d(t,{L:()=>s});var i=a(5537);class s{set value(e){this._value=e,this.updateLabel()}get value(){return this._value}constructor(e,t,a){const i=this;this._label=t,this._ul=a,this._value=t.innerHTML,this.is_open=!1,this._options=[],this._onKeyDown=e=>{if(i.is_open){switch(e.key.toLowerCase()){case"escape":return void i.close();case"arrowdown":let e=!1;for(const t of this._ul.childNodes){if(e){this._highlight(t);break}t.classList?.contains("select")&&(e=!0)}break;case"arrowup":let t=null;for(const e of this._ul.childNodes){if(e.classList?.contains("select")){t&&this._highlight(t);break}t=e}break;case"enter":for(const e of this._ul.childNodes)if(e.classList.contains("select")){const t=e.innerText;this.value=t,this.onselect&&this.onselect(t),this.close();break}}e.preventDefault()}},this._onClick=t=>{const a=t.target.parentNode;if(!i.is_open)return!0;a!==e&&i.close()},window.addEventListener("keydown",this._onKeyDown,!1),window.addEventListener("click",this._onClick,!1),this.onselect=void 0,e.onclick=e=>{e.preventDefault(),this.is_open?this.close():this.open()}}_highlight(e){this._ul.childNodes.forEach((t=>{t===e?t.classList.add("select"):t.classList.remove("select")}))}destructor(){window.removeEventListener("keypress",this._onKeyPress),window.removeEventListener("click",this._onClick)}static init(e,t){let a=(0,i.Z)(e);const n=document.getElementById("dropdown-template"),r="dropdown"===a.getAttribute("data-type")?a:a.querySelector('select[data-type="dropdown"]');if(!r)return;r.after(document.importNode(n.content,!0));const o=r.nextElementSibling;o.id=t,r.remove();const l=o.querySelector(".dropdown-btn"),d=l.querySelector(".dropdown-label"),c=o.querySelector(".dropdown-content ul");return l&&d&&c?new s(l,d,c):void 0}setOptions(e){this._options=e}updateLabel(){this._label.innerHTML=this._value}select(e){this._label.innerHTML=e}open(){this._ul.classList.contains("open")||(this._options.forEach((e=>{const t=document.createElement("li");e===this._value&&(t.className="select selected"),t.innerText=e,t.onclick=()=>{this.select(e),this.value=e,this.onselect&&this.onselect(e),this.close()},t.onmouseover=()=>this._highlight(t),this._ul.appendChild(t)})),this._ul.classList.add("open")),this.is_open=!0}close(){for(this._ul.classList.remove(["open"]);this._ul.firstChild;)this._ul.removeChild(this._ul.firstChild);this.is_open=!1}}},1351:(e,t,a)=>{"use strict";a.d(t,{S:()=>n});var i=a(3707);let s={};function n(e,t){let a=e?.data?.title||t?.fallbackMessage?.title||"Error",n=e?.data?.message||t?.fallbackMessage?.message||"Action can not be performed",r=t?.isWarning??!1;if(e?.data){const t=e.data;t.code&&(a+=` - ${t.code}`,"7"==`${t.code}`[3]&&(r=!0)),t.url&&(n+=`
    more info`)}const o=e?.data?.code||`${a}\n${n}`;if(s[o])return;s[o]=!0;const l=()=>s[o]=!1;r?(0,i.Kp)(a,n,l):(0,i.vU)(a,n,l)}},8236:(e,t,a)=>{"use strict";a.d(t,{o:()=>n});var i=a(7780);const s={count:0,current:0},n=(e,t={})=>{const a=Object.assign({timeout:5500,closeOutside:!0},t),n=s.count;s.count=s.count+1;const r=document.querySelector(".modal-box");t.className&&r.classList.add(t.className);const o=r.parentElement;for(;r.firstChild;)r.removeChild(r.firstChild);const l=()=>{n==s.current&&o.classList.contains("show-modal")&&(o.classList.remove("show-modal"),a.closeCallback&&a.closeCallback())},d=e=>{e.target===o&&l()};a.closeOutside&&window.addEventListener("click",d);const c=e(l);s.current=n,r.appendChild(c),(0,i.ot)(r),o.classList.add("show-modal"),a.timeout>0&&setTimeout(l,a.timeout)}},5951:(e,t,a)=>{"use strict";function i(e,t,a="right"){if(!e)return;const i=e.querySelector(".fill");if(i){const e=`${["top","right","bottom","left"].map((e=>(e=>e===a?100-(t||0)+"%":0)(e))).join(" ")}`;i.style.inset=e}}a.d(t,{g:()=>i})},2957:(e,t,a)=>{"use strict";a.d(t,{Z:()=>x});var i=a(7780),s=a(5489),n=a(6648),r=a(646),o=a(1351);var l=a(3707),d=a(8236);let c=null,u=null,p=null,m=!1;const v=a(5493).Z,g=()=>(0,l.Vp)((0,i.Iu)("ntf.success"),(0,i.Iu)("ntf.settings-suc"));function h(e){const t=document.getElementById("api_key");t&&(t.innerText=e)}function b(e,t){(0,s.ZP)("con-settings",e.link),function(e,t){const a=document.getElementById("conn-prusa-connect-status"),i=document.getElementById("conn-prusa-connect-url"),s=document.getElementById("edit-connect-del"),n="FINISHED"===e.connect.registration,{hostname:o,tls:l,port:d}=e.connect.settings,{ok:c,message:u}=e.connect,m=c&&n,v=document.getElementById("conn-prusa-connect-status-"+(m?"ok":"not-ok")),g=`${l?"https":"http"}://${o}${d?`:${d}`:""}`,h=`(${g})`;(t||p!==g)&&(i.value=g,p=g);(0,r.QH)(i.parentNode.parentNode,n),s&&(0,r.yx)(s,n||!c);f(a,v,m,u,h)}(e.link,t),function(e,t){const a=document.getElementById("conn-printer-status"),{port:i,baudrate:s}=t.printer.settings,{ok:n,message:r}=t.printer,o=document.getElementById("conn-printer-status-"+(n?"ok":"not-ok"));f(a,o,n,r,`(${i||"/dev/ttyACM0"} @ ${s||0}bps)`)}(e.state,e.link)}function f(e,t,a,s,n){e&&e.setAttribute("ok",Boolean(a)),t&&(t.innerHTML=(a?(0,i.Iu)("conn.suc"):s)+"
    "+n)}function y(e){switch(e.toLowerCase()){case"python":return(0,i.Iu)("sys-version.python");case"description":return(0,i.Iu)("sys-version.description");case"id":return(0,i.Iu)("sys-version.id");case"os":return(0,i.Iu)("sys-version.os");default:return e}}const k=(e,t,a)=>{const s=document.getElementById("modal-sysupgrade"),l=document.importNode(s.content,!0),d=l.getElementById("modal-sysupgrade__target"),c=l.getElementById("modal-sysupgrade__current"),p=l.getElementById("modal-sysupgrade__version"),m=l.getElementById("modal-sysupgrade__status"),v=l.getElementById("modal-sysupgrade__spinner"),g=l.getElementById("yes"),h=l.getElementById("no");return d.innerText=t,c.innerText=u,p.innerText=a,g.addEventListener("click",(t=>{t.preventDefault(),(0,r.H)(g,!0),(0,r.H)(h,!0),(0,r.yx)(g,!1),(0,r.yx)(h,!1),(0,r.yx)(v,!0),m.innerText=(0,i.Iu)("msg.sysupgrade.pending"),(0,n.LK)("/api/v1/update/prusalink",{method:"POST"}).then((()=>(m.innerText=(0,i.Iu)("msg.sysupgrade.wait-for-printer"),w(a)))).catch((e=>(0,o.S)(e))).finally((()=>{e(),window.location.href="/"}))})),h.addEventListener("click",(()=>e())),l},w=e=>new Promise(((e,t)=>{const a=()=>(0,n.LK)("/api/version").then((t=>{e()})).catch((e=>setTimeout(a,2500)));setTimeout(a,5e3)})),x={load:e=>{(0,i.Iu)("settings.title",{query:"#title-status-label"}),(0,n.LK)("/api/version?system=1").then((e=>{const t={version:e.data};u=t.version.server,function(e){if(m||void 0===e.version.system||void 0===e.version.system.DESCRIPTION||"Raspbian GNU/Linux 11 (bullseye)"!==e.version.system.DESCRIPTION)return;const t=(0,i.Iu)("msg.outdated-os.message"),a=(0,i.Iu)("msg.outdated-os.title");(0,l.Kp)(a,t,(()=>{}),!1),m=!0}(t),(0,s.ZP)("settings",t),function(e){const t=document.querySelector("#sys-version .table");if(t)for(const[a,i]of Object.entries(e.version.system)){const e=document.createElement("div");e.className="row";const s=document.createElement("div");s.className="col txt-sm",s.innerHTML=`

    ${y(a)}

    `,e.appendChild(s);const n=document.createElement("div");n.className="col txt-md",n.innerHTML=`

    ${i}

    `,e.appendChild(n),t.appendChild(e)}}(t)})).catch((e=>(0,o.S)(e))),function(){const e=document.getElementById("updates-check"),t=document.getElementById("updates-check__spinner"),a=a=>{a&&document.querySelectorAll(".update-pkg").forEach((e=>{e.parentNode.removeChild(e)})),(0,r.yx)(t,a),(0,r.gL)(e,!a)};e&&t&&(e.onclick=()=>{a(!0),(0,n.LK)("/api/v1/update/prusalink").then((t=>{const a=t.data?.new_version,s=[{name:"PrusaLink",new_version:a}],n=e.parentNode.parentNode.parentNode;s.forEach((e=>{const t=document.createElement("div"),a=document.createElement("div"),s=document.createElement("p"),r=document.createElement("div"),o=document.createElement("p"),l=document.createElement("div"),c=document.createElement("span"),p=document.createElement("span"),m=document.createElement("span"),v="PrusaLink";if(t.className="row update-pkg",a.className="col",r.className="col",l.className="col",s.className="txt-bold txt-grey txt-sm",o.className="txt-md",c.className="txt-grey txt-sm",m.className="txt-grey txt-sm",s.innerText=v,p.innerHTML=" → ",e.new_version){c.innerText=u,m.innerText=e.new_version;const t=document.createElement("button"),a=document.createElement("p");a.innerText=(0,i.Iu)("btn.upgrade"),t.className="action",t.appendChild(a),l.appendChild(t),t.onclick=()=>(0,d.o)((t=>k(t,v,e.new_version)),{timeout:0,closeOutside:!1})}else m.innerText="The package is up to date";o.appendChild(c),o.appendChild(p),o.appendChild(m),a.appendChild(s),r.appendChild(o),t.appendChild(a),t.appendChild(r),t.appendChild(l),n.appendChild(t)}))})).catch((e=>(0,o.S)(e))).finally((()=>a(!1)))})}(),function(e){b(e,!0),document.getElementById("edit-connect-del").addEventListener("click",(t=>{(0,n.LK)("/api/connection",{method:"DELETE"}).then(g).catch((e=>(0,o.S)(e))).finally((()=>e.updateConnection()))}));const t=document.getElementById("edit-connect-set"),a=document.getElementById("edit-connect-set__spinner"),i=e=>{e&&document.querySelectorAll(".update-pkg").forEach((e=>{e.parentNode.removeChild(e)})),(0,r.yx)(a,e),(0,r.gL)(t,!e)};t.addEventListener("click",(t=>{i(!0);const a=document.getElementById("conn-prusa-connect-url")?.value;if(!a)return;const s=new URL(a);(0,n.LK)("/api/connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({connect:{hostname:s.hostname,port:s.port?parseInt(s.port):0,tls:s.protocol.startsWith("https")?1:0}})}).then((t=>{const a=t?.data?.url;a&&window.open(a,"_blank"),setTimeout((()=>{e.updateConnection().finally((()=>i(!1)))}),5e3)})).catch((e=>{i(!1),(0,o.S)(e)}))}))}(e),(0,n.LK)("/api/settings").then((e=>{const t=e.data;(function(e){const t=document.querySelector("#settings #printer-name"),a=document.querySelector("#settings #printer-location"),i=document.querySelector("#settings #printer-network_error_chime"),s=document.querySelector("#settings #edit-printer"),l=()=>{(0,r.gL)(s,t.value.length>0&&a.value.length>0)};t.oninput=l,a.oninput=l,i.oninput=l,t.value=e.printer?.name||"",a.value=e.printer?.location||"",i.checked=!!e.printer?.network_error_chime,"api-key"in e&&h(e["api-key"]),s.onclick=()=>{(({name:e,location:t,network_error_chime:a})=>(0,n.LK)("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({printer:{name:e,location:t},network_error_chime:a})}))({name:t.value,location:a.value,network_error_chime:i.checked}).then((()=>g())).catch((e=>(0,o.S)(e)))},l()})(t),function(e){const t=document.querySelector("#settings #username"),a=document.querySelector("#settings #password"),i=document.querySelector("#settings #new-password"),s=document.querySelector("#settings #re-password"),l=document.querySelector("#settings #edit-user"),d=()=>{(0,r.gL)(l,a.value.length>0&&(t.value.length>0||i.value.length>0&&s.value.length>0))};t.oninput=d,a.oninput=d,i.oninput=d,s.oninput=d,t.value=e.username||"",d(),l.onclick=()=>{((e,{username:t,newPassword:a,rePassword:i})=>(0,n.LK)("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({user:{password:e,username:t,new_password:a,new_repassword:i}})}))(a.value,{username:t.value||void 0,newPassword:i.value||void 0,rePassword:s.value||void 0}).then((()=>g())).catch((e=>(0,o.S)(e)))}}(t)})).catch((e=>(0,o.S)(e))),function(){const e=document.querySelector("#settings #serial"),t=document.querySelector("#settings #edit-serial"),a=()=>{if(e&&t){c&&(e.value=`${c}`);const i=()=>{(0,r.gL)(t,!c&&e.value.length>0),(0,r.yx)(t,!c)};i(),e.oninput=i,(0,r.gL)(e,!c);const s=()=>{var t;(t=e.value,(0,n.LK)("/api/settings/sn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({serial:t})})).then((e=>{c=e.data.serial,g()})).catch((e=>(0,o.S)(e))).finally((()=>a()))};e.onkeyup=t=>{"Enter"===t.key&&(s(),e.blur())},t.onclick=s}};(0,n.LK)("/api/settings/sn",{headers:{"Content-Type":"application/json"}}).then((e=>{c=e.data.serial})).catch((e=>(0,o.S)(e))).finally((()=>a()))}(),function(){const e=document.getElementById("api_key-reset");e&&e.addEventListener("click",(()=>{(0,n.LK)("/api/settings/apikey",{method:"POST"}).then((e=>{h(e.data["api-key"]),g()})).catch((e=>(0,o.S)(e)))}),!1)}(),v?.load(),e.updateConnection()},update:e=>{v?.update(),b(e,!1)}}},5493:(e,t,a)=>{"use strict";a.d(t,{Z:()=>g});var i=a(7780),s=a(6648),n=a(1972),r=a(5412),o=a(1351),l=a(5502);let d=null,c=null;const u=67108864;function p(e){const t=document.querySelector("ul.logs");t&&(t.innerHTML=m(e))}function m(e){return`
  • ${e}
  • `}const v=()=>{(0,s.LK)("/api/logs").then((e=>{const t=e.data.files;if(d){const e=t.find((e=>e.name===d));e&&(!c||e.date>c)&&(c=e.date,null===e.size?p((0,i.Iu)("logs.file-size-unknown")):e.size>u?p((0,i.Iu)("logs.file-too-large",{size:(0,l.Z)("size",u)})):(a=d,(0,s.iT)(`/api/logs/${a}`).then((e=>{const t=document.querySelector("ul.logs");t&&(e.data?t.innerHTML=e.data.split("\n").map((e=>m(e))).join(""):t.innerHTML=m((0,i.Iu)("logs.empty-file")))}))))}var a})).catch((e=>(0,o.S)(e)))},g={load:()=>{(0,s.LK)("/api/logs").then((e=>{const t=e.data.files,a=r.L.init("settings","log-list"),s=t.map((e=>e.name)),o=(0,i.Iu)("logs.select-file-placeholder");a.setOptions(s),a.select(o),a.onselect=e=>{!function(e){d=e,c=null,document.getElementById("download-log").onclick=()=>{(0,n.Z)(`/api/logs/${e}`,e)},v()}(e)}})).catch((e=>(0,o.S)(e)))},update:v}},3707:(e,t,a)=>{"use strict";a.d(t,{Kp:()=>r,Vp:()=>o,vU:()=>l});const i=document.getElementById("prusa-toast"),s={info:10500,success:10500,warning:10500,error:10500};function n({title:e,message:t,type:a,onClose:n,autoClose:r=!0}){const o=((e,t,a)=>{const i=document.getElementById("toast"),s=document.importNode(i.content,!0),n=s.querySelector("article");return n.className=a,s.querySelector("p").innerHTML=e,s.querySelector(".toast-body").innerHTML=t,n})(e,t,a),l=()=>{i.contains(o)&&i.removeChild(o),n?.()};o.querySelector("span").addEventListener("click",(e=>{e.preventDefault(),l()}));const d=s[a];d&&r&&setTimeout(l,d),i.appendChild(o)}function r(e,t,a,i=!0){n({type:"warning",title:e,message:t,onClose:a,autoClose:i})}function o(e,t,a,i=!0){n({type:"success",title:e,message:t,onClose:a,autoClose:i})}function l(e,t,a,i=!0){n({type:"error",title:e,message:t,onClose:a,autoClose:i})}},5489:(e,t,a)=>{"use strict";a.d(t,{NA:()=>n,ZP:()=>o,zR:()=>r});var i=a(646),s=a(5502);const n=(e,t)=>{try{const a=e.split(".");let i=t;for(;a.length;)i=i[a.shift()];return i}catch(e){return}},r=(e,t)=>{document.querySelectorAll(`[data-type="${e}"]`).forEach((e=>{const a=e.dataset.where,r=e.dataset.zeroes,o=a?n(a,t):t;e.innerHTML=(0,s.Z)(e.dataset.format,o),(0,i.QH)(e,"hide"===r&&!o)}))},o=r},2038:(e,t,a)=>{"use strict";a.d(t,{$:()=>o,i:()=>l});var i=a(8236),s=a(646),n=a(4800),r=a(7780);const o=(e,t,a)=>{const i=document.getElementById("modal-question"),n=document.importNode(i.content,!0),o=n.getElementById("modal-question-label"),l=document.createElement("p"),d=document.createElement("p");l.className="txt-sm my-md txt-bold",l.innerText=t,o.innerText=(0,r.Iu)("msg.file-exists.title"),d.innerText=(0,r.Iu)("msg.file-exists.overwrite-it"),o.parentNode.append(l),o.parentNode.append(d);const c=n.getElementById("yes"),u=n.getElementById("no");return u.addEventListener("click",e),c.addEventListener("click",(t=>{t.preventDefault(),(0,s.H)(c,!0),(0,s.H)(u,!0),a(),e()})),n},l=e=>{e.addEventListener("change",(t=>{e.checked&&e.getAttribute("data-link-state")!==n.PT.READY&&(0,i.o)((t=>((e,t)=>{const a=document.getElementById("modal-confirm"),i=document.importNode(a.content,!0),n=i.getElementById("yes"),r=i.getElementById("no");return n.addEventListener("click",(a=>{a.preventDefault(),(0,s.H)(n,!0),(0,s.H)(r,!0),t.checked=!0,e()})),r.addEventListener("click",(()=>{t.checked=!1,e()})),i})(t,e)),{timeout:0,closeOutside:!1})}))}},7049:(e,t,a)=>{"use strict";a.d(t,{Z:()=>k});var i=a(6648),s=a(1351),n=a(646),r=a(3707),o=a(7780),l=a(5951),d=a(2038),c=a(5489),u=a(4800),p=a(3532);let m=!1,v=null;function g(e){const t=p.Z.getContext(),a=u.cG.includes(e),i=document.querySelector("#upld-remote-start-pt");if(i&&(i.setAttribute("data-link-state",e),a||(i.checked=!1),(0,n.H)(i,!a)),t.transfer?.id){y("uploading"),(0,c.ZP)("download",t.transfer);const e=document.querySelector("#upld-remote .progress-bar");(0,l.g)(e,t.transfer.progress||0)}else y("choose")}function h(e){const t=e.data;if(![200,201,204].includes(e.code))return(0,s.S)(e),void f();if(t){if("FROM_WEB"===t.type){y("uploading"),(0,c.ZP)("download",{...t,time_start:t.time_transferring?Math.round((new Date).getTime()/1e3)-t.time_transferring:null});const e=document.querySelector("#upld-remote .progress-bar");(0,l.g)(e,t.progress||0)}}else{if(m)return function(){const e=(0,o.Iu)("ntf.success"),t=(0,o.Iu)("ntf.upld-suc",{file_name:""});(0,r.Vp)(e,t)}(),void f();y("choose")}}const b=(e,t,a,s)=>{if("local"===t)return e=e.split("?")[0].split("&")[0].split("#")[0],(0,i.LK)(`/api/download/${t}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:e,destination:a,...s})}).then((e=>function(e){y("uploading");const t=document.querySelector("#upld-remote .progress-bar");(0,l.g)(t,0);const a=(0,o.Iu)("ntf.success"),i=(0,o.Iu)("ntf.upld-start");(0,r.Vp)(a,i)}())).catch((e=>h(e)));(0,r.vU)("Can't upload to "+t,"You can upload only to local storage via remote upload!")};function f(){y("choose"),function(e){const t=document.querySelector("#upld-remote .progress-bar");t&&(0,l.g)(t,e||0)}(0),(0,c.ZP)("download",{}),v=null}function y(e){m="uploading"==e;const t=document.getElementById("upld-remote");t&&t.setAttribute("data-state",e)}const k={init:function(e,t){const a=document.getElementById("upld-remote");if(a){const i=a.querySelector("#remote-url"),s=a.querySelector("#remote-file-name"),r=a.querySelector("#upld-file"),o=a.querySelector("#upld-remote-start-pt");o&&(0,d.i)(o),r.onclick=()=>b(i.value,e,t,{to_print:o.checked,rename:s.value});const l=()=>{(0,n.H)(r,""===i.value)};l(),i.oninput=l}g()},update:g,get isUploading(){return m}}},3532:(e,t,a)=>{"use strict";a.d(t,{Z:()=>Je});var i=a(7780);const s=500;let n=100,r=new Map;const o=(e,t)=>{r=e,n=t};function l(){const e=document.getElementById("graph");e&&(0==e.childElementCount&&function(){const e=document.getElementById("graph-template"),t=document.importNode(e.content,!0);document.getElementById("graph").appendChild(t),(0,i.ot)("graph")}(),r.forEach(((e,t)=>{!function(e,t){const a=(new Date).getTime();let i=[];if(e.length>1){let t=e[0],r=s-2.66*(a-t[0]-1e4)/1e3;for(let e=1;e1&&e[1][0]{var n=new XMLHttpRequest;const r=()=>{s(void 0)};!function(e,t){if(t){const a=e=>{if(!e.lengthComputable)return;let a=Math.round(e.loaded/e.total*100);t({loaded:e.loaded,total:e.total,percentage:a})};e.upload.addEventListener("progress",a,!1)}}(n,a.onProgress),n.addEventListener("load",(e=>{const t=function(e){function t(e){try{return JSON.parse(e)}catch{return}}return{status:e.status,statusText:e.statusText,ok:e.status>=200&&e.status<=299,data:t(e.response)}}(e.target);t.ok?i(t):s(t)}),!1),n.addEventListener("error",r,!1),n.addEventListener("abort",r,!1),n.open("PUT",`${m.O2}${e}`),a.print&&n.setRequestHeader("Print-After-Upload","?1"),n.setRequestHeader("Overwrite","?1"),n.setRequestHeader("Content-Type",e.endsWith(".bgcode")?"application/gcode+binary":"text/x.gcode");for(const[e,t]of Object.entries((0,m.wU)()))n.setRequestHeader(e,t);n.send(t)}))};var g=a(2038),h=a(4800),b=a(8236);let f=!1,y=0;function k(e,t,a,i,s){const n=document.querySelector(e);n&&(n.setAttribute("accept",i.join(", ")),n.onchange=()=>{if(n.files.length>0&&!f){let e=n.files[0];S(e,t,a,s?.checked||!1)}})}function w(){[document.querySelector('#upld-direct input[type="file"]'),document.querySelector('#drop-zone input[type="file"]')].filter((e=>!!e)).forEach((e=>e.value="")),z(0),x("choose")}function x(e){f="uploading"===e;const t=document.getElementById("upld-direct");t&&t.setAttribute("data-state",e)}function z(e){y=e;const t=document.getElementById("upld-progress");t&&(t.innerHTML=`${e} %`)}const S=(e,t,a,s)=>{const n=["/api/v1/files",t,a,e.name].filter((e=>!!e)).join("/"),r=e.display_name||e.name,o=()=>e.arrayBuffer().then((e=>{x("uploading"),z(0),v(n,e,{onProgress:e=>{return t=e.percentage,x("uploading"),void z(t);var t},print:s}).then((()=>function(e){const t=(0,i.Iu)("ntf.success"),a=(0,i.Iu)("ntf.upld-suc",{file_name:e});(0,c.Vp)(t,a)}(r))).catch((e=>function(e,t){if(t)(0,u.S)(t);else{const t=(0,i.Iu)("ntf.error"),a=(0,i.Iu)("ntf.upld-unsuc",{file_name:e});(0,c.vU)(t,a)}}(r,e))).finally((()=>w()))}));(0,m.LK)(n,{method:"HEAD"}).then((()=>{try{(0,b.o)((e=>(0,g.$)((()=>{e(),w()}),r,o)),{timeout:0,closeOutside:!1})}catch(e){console.error("Modal error",e)}})).catch((()=>o()))};const E={init:function(e,t,a){(0,i.Iu)("upld.direct.choose",{query:"#upld-direct p",file:a.join(", ")}),function(e,t,a){const i=document.getElementById("upld-direct-start-pt");i&&(0,g.i)(i);const s=document.getElementById("drop-zone");s&&(document.ondragenter=e=>(0,p.yx)(s,!0),s.ondragleave=e=>(0,p.yx)(s,!1),document.ondrop=e=>(0,p.yx)(s,!1),k('#drop-zone input[type="file"]',e,t,a,i));k('#upld-direct input[type="file"]',e,t,a,i)}(e,t,a),f&&(x("uploading"),z(y))},update:function(e){const t=h.cG.includes(e),a=document.querySelector("#upld-direct-start-pt");a&&(a.setAttribute("data-link-state",e),t||(a.checked=!1),(0,p.H)(a,!t))},get isUploading(){return f},initInputByQuery:k};const P=class{get selected(){return this._selected}get isLocked(){return this._isLocked}lock(){this._isLocked=!0,this._root&&this._root.querySelectorAll("[data-tab-btn]").forEach((e=>{e.getAttribute("data-tab-btn")!==this.selected&&e.setAttribute("locked",!0)}))}unlock(){this._isLocked=!1,this._root&&this._root.querySelectorAll("[data-tab-btn]").forEach((e=>{e.hasAttribute("locked")&&e.removeAttribute("locked")}))}constructor(){this._root=null,this._selected=null,this._isLocked=!1}init(e){this._root=e,this._root&&(e.querySelectorAll("[data-tab-btn]").forEach((e=>{e.onclick=()=>{if(!this._isLocked){const t=e.getAttribute("data-tab-btn");t===this._selected||(this.closeTab(),this.openTab(t))}}})),this.openTab(this._selected,!0))}openTab(e){if(this._root&&e){const t=this._root.querySelector(`[data-tab="${e}"]`);t&&t.setAttribute("opened",!0);const a=this._root.querySelector(`[data-tab-btn="${e}"]`);a&&a.setAttribute("selected",!0),this._selected=e}}closeTab(){if(this._root&&this._selected){const e=this._root.querySelector(`[data-tab="${this._selected}"]`);e&&e.setAttribute("opened",!1);const t=this._root.querySelector(`[data-tab-btn="${this._selected}"]`);t&&t.setAttribute("selected",!1),this._selected=null}}},L=a(7049).Z,I=new P;function N(){E.isUploading?(I.openTab("direct"),I.lock()):L?.isUploading?(I.openTab("remote"),I.lock()):(I.unlock(),I.selected||I.openTab("direct"))}const _={init:function(e,t="",a){E.init(e,t,a),L?.init(e,t),I.init(document.getElementById("upld")),N()},update:function(e){E?.update(e),L?.update(e),N()},hide:function(e){(0,p.QH)(document.getElementById("upld"),e)}};var T=a(2451),j=a(5489),C=a(8751);const A=e=>(0,m.LK)(e,{method:"POST",headers:{"Content-Type":"application/json"}}),D=e=>(0,m.LK)(`/api/v1/job/${e}/resume`,{method:"PUT"}).catch((e=>(0,u.S)(e))),B=(e,t)=>{(0,b.o)((a=>((e,t,a)=>{const s=document.getElementById("modal-question"),n=document.importNode(s.content,!0);n.getElementById("modal-question-label").innerText=(0,i.Iu)("msg.cancel");const r=n.getElementById("yes"),o=n.getElementById("no");return r.addEventListener("click",(i=>{i.preventDefault(),t.onConfirm(),(0,p.H)(r,!0),(0,p.H)(o,!0),(0,m.LK)(`/api/v1/job/${a}`,{method:"DELETE",headers:{"Content-Type":"application/json"}}).catch((e=>{t.onError(),(0,u.S)(e)})),e()})),o.addEventListener("click",e),n})(a,t,e)),{timeout:0,closeOutside:!1})},O=(e,t)=>{e?(0,b.o)((e=>((e,t)=>{const a=document.getElementById("modal-confirm"),i=document.importNode(a.content,!0),s=i.getElementById("yes"),n=i.getElementById("no");return s.addEventListener("click",(a=>{a.preventDefault(),(0,p.H)(s,!0),(0,p.H)(n,!0),A(t).then((()=>(0,C.c4)("#dashboard"))).catch((e=>(0,u.S)(e))).finally((()=>e()))})),n.addEventListener("click",e),i})(e,t)),{timeout:0,closeOutside:!1}):A(t).then((()=>(0,C.c4)("#dashboard"))).catch((e=>(0,u.S)(e)))},R=()=>(0,m.LK)("/api/job",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:"cancel"})}).catch((e=>(0,u.S)(e)));var q=a(1972);const M=(e,t,a)=>{(0,q.Z)(e,t),a()},H=(e,t,a,s,n=!1)=>{const r=document.getElementById("modal-question"),o=document.importNode(r.content,!0);o.getElementById("modal-question-label").innerText=n?(0,i.Iu)("msg.del-folder",{folder_name:a}):(0,i.Iu)("msg.del-proj",{file_name:a});const l=o.getElementById("yes"),d=o.getElementById("no");return d.addEventListener("click",e),l.addEventListener("click",(a=>{a.preventDefault(),(0,p.H)(l,!0),(0,p.H)(d,!0),(0,m.LK)(t,{method:"DELETE",headers:{force:"?1"}}).then((()=>s&&s())).catch((e=>(0,u.S)(e))).finally((t=>e()))})),o},U=(e,t,a)=>{(0,b.o)((i=>H(i,e,t,a)),{timeout:0,closeOutside:!1})},K=(e,t)=>{(0,b.o)((a=>((e,t,a)=>{const i=document.getElementById("modal-file-name"),s=document.importNode(i.content,!0),n=s.getElementById("modal-file-name__input"),r=s.getElementById("yes"),o=s.getElementById("no");return o.addEventListener("click",e),r.addEventListener("click",(i=>{i.preventDefault();const s=n.value;if(s){const i=t(s);(0,p.H)(r,!0),(0,p.H)(o,!0),(0,m.LK)(i,{method:"PUT",headers:{"create-folder":"?1"}}).then((()=>a&&a())).catch((e=>(0,u.S)(e))).finally((t=>e()))}})),s})(a,e,t)),{timeout:0,closeOutside:!1})};function V(e){return`inset(${100-e}% 0% 0% 0%)`}var Z=a(5951);const F={},$=()=>{F.title=null,F.questionChildren=null,F.yes=null,F.no=null,(0,C.g9)(F.next)},G={load:()=>{F.title||(0,C.c4)("#dashboard"),document.getElementById("title-status-label").innerHTML=F.title;const e=document.getElementById("question"),t=F.questionChildren;Array.isArray(t)?t.forEach((t=>e.appendChild(t))):e.innerHTML=t;for(let e of["yes","no"]){const t=document.getElementById(e),a=F[e];t.querySelector("p").innerHTML=F[e+"Text"],t.addEventListener("click",(e=>{e.stopPropagation(),a($)}))}}};(0,i.Iu)("exp-times.exp-time"),(0,i.Iu)("exp-times.inc"),(0,i.Iu)("exp-times.layer-1st"),(0,i.Iu)("exp-times.profile");const W=e=>{const t=e.querySelector("img");(0,p.gL)(e,!1),t&&(t.setAttribute("data-src",t.src),t.src=document.querySelector(".loading-overlay img").src)},Y=e=>{const t=e.querySelector("img");if((0,p.gL)(e,!0),t){const e=t.getAttribute("data-src");e&&(t.src=e,t.setAttribute("data-src",""))}};var J=a(5502);const Q=function(e,t="smooth"){if(!e)return;const a=e.getBoundingClientRect(),i=document.body.getBoundingClientRect(),s=a.top-(i.top+function(){const e=document.querySelector(".header");return e&&"sticky"===getComputedStyle(e).position?e.getBoundingClientRect().height:0}());(a.bottom>window.innerHeight||a.top<0)&&window.scroll({top:s,behavior:t})};var X=a(5537);const ee=e=>{const t=(0,X.Z)(e).querySelector(".kebab"),a=t.querySelector(".kebab-menu"),i=t.querySelector("ul");a.addEventListener("click",(e=>{e.stopPropagation(),function(e){return e&&e.classList.contains("open")}(i)?te(i):function(e,t){e.classList.add("open"),(0,p.Ti)((()=>te(e)),e,t)}(i,a)})),i.querySelectorAll("li").forEach((e=>{e.addEventListener("click",(()=>te(i)))}))};function te(e){e.classList.remove("open")}const ae=e=>{const t=document.querySelectorAll(".storage-select-content li");t&&t.forEach((t=>t.setAttribute("selected",t.getAttribute("data-storage")===e)))},ie=e=>{const t=document.querySelector("#node-storage .storage-select-btn-inner");t&&(t.innerHTML=e)},se=(e,t)=>{e&&t&&(t.classList.toggle("open"),(0,p.Ti)((()=>{t?.classList.remove("open")}),e,t))},ne=e=>{const t=document.querySelector(".node-storage-space");if(!t)return;const a=e.available&&!!e.totalSpace;if((0,p.yx)(t,a),a){const a=e.freeSpace,s=e.totalSpace,n=s-a,r=Math.round(100*(s?n/s:0)),o=(0,i.Iu)("prop.storage-used-space",{used:(0,J.Z)("size",n),free:(0,J.Z)("size",a),total:(0,J.Z)("size",s)});(0,Z.g)(t,r),document.getElementById("storage-pct").innerHTML=(0,J.Z)("percent",r),document.getElementById("storage-space").innerHTML=o}},re=()=>{const e=document.querySelector("#node-storage .storage-select-btn");e&&(e.onclick=t=>{t.stopPropagation(),se(e,dropdownContent)})},oe=(e,t,a,i=!1)=>{const s=document.querySelector(".storage-select-content");document.querySelectorAll(".storage-select-content li").forEach((i=>{const n=i.getAttribute("data-storage");let r=!1;if(n in e){const o=e[n],l=i.querySelector("p");r=e[n].available,o.name&&(l.innerText=o.name),i.setAttribute("selected",n===t),i.onclick=e=>{e.stopPropagation(),ie(i.innerHTML),ae(n),s.classList.remove("open"),ne(o),a(n)}}(0,p.yx)(i,r)})),i&&ne(e[t])},le="FOLDER",de="PRINT_FILE",ce="FIRMWARE",ue=["name","date","size"];let pe=null;const me={origin:null,current_path:[],storages:{},files:[],eTag:null,sort:{field:"date",order:"desc"}};function ve(){const e=me.origin;return me.storages[e]}function ge(){return me.current_path.map((e=>e.path)).join("/")}function he(e){const t=ve(),a=ge();return be(t.path,a,e)}function be(e,t,a){const i=["/api/v1/files",e,t,a].filter((e=>!!e)).join("/");return a?i:`${i}/`}const fe=(e={})=>{(0,m.LK)("/api/v1/storage",{}).then((t=>{const a=t.data?.storage_list;let i=!!e.redraw,s=!!e.redraw;if(a&&a.forEach((e=>{const t=e.type,a={name:e.name,path:e.path.replaceAll("/",""),available:e.available,readOnly:e.read_only,freeSpace:e.free_space,totalSpace:e.total_space};if(t in me.storages){const e=me.storages[t];e.available!==a.available&&(i=!0),e.freeSpace!==a.freeSpace&&me.origin===t&&(s=!0)}else i=!0,s=!0;me.storages[t]=a})),!me.origin){let e=Object.keys(me.storages).find((e=>me.storages[e].available));e||(e=Object.keys(me.storages).find((()=>!0))),e&&Le(e)}i&&oe(me.storages,me.origin,Le,s)}))},ye=(e={})=>{if(!ve())return;const t=he();let a=me.eTag;e.force&&(me.eTag=null,a=null,function(e){const t=ve(),a=ge();_.init(t.path,a,e?.fileExtensions),_.hide(!1!==t?.readOnly)}(Je.getContext()));const i=e=>{me.files=e,ke(),we()};(0,m.LK)(t,{headers:{"If-None-Match":a}}).then((s=>{if(304===s.code)return;if(t!==he())return;const n=s.eTag;if(!n||n!==a){me.eTag=n;const t=s.data.children||[];if(!n&&!e.force){const e=(e,t)=>e.display_name.localeCompare(t.display_name);if(JSON.stringify([...me.files].sort(e))===JSON.stringify(t.sort(e)))return}i(t)}})).catch((()=>{me.eTag=null,i([]),Je.getContext().selectFile(null)}))},ke=()=>{const e=document.getElementById("files");if(e){for(;e?.firstChild;)e.removeChild(e.firstChild);e.appendChild(function(){const e=ve(),t=[e.name,...me.current_path.map((e=>e.name))],a=xe("node-current",t.pop()||"Root");a.getElementById("path").innerHTML=`${t.join("/")}/`;const s=a.getElementById("create");s&&(s.onclick=e=>{e.stopPropagation(),K(he)});(0,p.H)(s,!1!==e.readOnly),a.querySelector("#sort-by-name p").innerText=(0,i.Iu)("sort.by-name"),a.querySelector("#sort-by-date p").innerText=(0,i.Iu)("sort.by-date"),a.querySelector("#sort-by-size p").innerText=(0,i.Iu)("sort.by-size");return a.querySelector(`#sort-by-${me.sort.field}`).classList.add(me.sort.order),ue.forEach((e=>{a.getElementById(`sort-by-${e}`).addEventListener("click",(t=>{const a=document.getElementById(`sort-by-${e}`);document.getElementById(`sort-by-${me.sort.field}`).classList.remove(me.sort.order);const i=me.sort.field===e&&"asc"===me.sort.order?"desc":"asc";a.classList.add(i),me.sort.field=e,me.sort.order=i,ke(),we()}),!1)})),a}()),me.current_path.length&&e.appendChild(function(){const e=xe("node-up","",(()=>{me.current_path.pop(),ke(),ye({force:!0})}));return(0,i.ot)(e),e}())}},we=()=>{const e=document.getElementById("files");let t;if(e)for(let a of(e=>(e.sort(((e,t)=>{if(e.type===le&&t.type!==le)return-1;if(e.type!==le&&t.type===le)return 1;const a="desc"===me.sort.order?-1:1;switch(me.sort.field){case"date":return a*((e.m_timestamp||0)-(t.m_timestamp||0));case"size":return a*((e.size||0)-(t.size||0));default:return a*e.display_name.localeCompare(t.display_name)}})),e))(me.files)){switch(a.type.toUpperCase()){case le:t=ze(a,{files:void 0,folders:void 0});break;case de:t=Ee(a);break;case ce:t=Pe(a,"firmware");break;default:t=Pe(a,"file")}e.appendChild(t)}};function xe(e,t,a){const i=document.getElementById(e).content,s=document.importNode(i,!0);return a&&s.querySelector(".node").addEventListener("click",(e=>{a(e),e.preventDefault()})),s.querySelector("#name")?.appendChild(document.createTextNode(t)),s}function ze(e,t){const a=e.display_name||e.name,i=e.name,s=he(i),n=xe("node-folder",a,(()=>{me.current_path.push({path:i.replace("/",""),name:a}),ke(),ye({force:!0})})),r=[t?.files?`${t.files} files`:null,t?.folders?`${t.folders} folders`:null].filter((e=>null!=e)).join(" | ");n.getElementById("details").innerHTML=r;const o=n.getElementById("delete");(0,p.gL)(o,!(e.read_only||e.ro)),o&&(o.onclick=e=>{var t,i,n;e.stopPropagation(),t=s,i=a,n=()=>{},(0,b.o)((e=>H(e,t,i,n,!0)),{timeout:0,closeOutside:!1})});const l=n.getElementById("rename");return l&&(l.onclick=e=>{e.stopPropagation(),console.log("renameFolder")}),n}const Se=e=>{!function(e){const t=Je.getContext(),a=t.files.selected,i=ge(),s=he(e.name);if(a===i)return;t.selectFile({...e,path:i,resource:s});const n=document.getElementById("job");n&&Q(n)}(e)};function Ee(e){const t=xe("node-file",e.display_name||e.name,(t=>Se(e))),a=t.querySelector(".node-details");a.querySelectorAll(".details").forEach((t=>{(0,i.ot)(t);const s=(0,j.NA)(t.dataset.where,e);if(s){const e=(0,J.Z)(t.dataset.format,s);t.querySelector("p[data-value]").innerHTML=e}else a.removeChild(t)}));const s=e?.refs?.thumbnail;if(s){const a=t.querySelector("img.node-img");a.setAttribute("data-src",e.date?`${s}?ct=${e.date}`:s),pe.observe(a)}return ee(t),function(e,t){const a=he(e.name),i=e.display_name||e.name,s=t.getElementById("details");s&&(s.onclick=t=>{Se(e)});const n=t.getElementById("start");n&&(n.onclick=e=>{e.stopPropagation(),console.log("startPrint")});const r=t.getElementById("rename");r&&(r.onclick=e=>{e.stopPropagation(),console.log("renameFile")});const o=t.getElementById("delete");o&&((0,p.gL)(o,!(e.read_only||e.ro)),o.onclick=e=>{U(a,i),e.stopPropagation()});const l=t.getElementById("download");l&&((0,p.gL)(l,!!e.refs?.download),l.onclick=t=>{W(l),M(e.refs?.download,i,(()=>Y(l))),t.stopPropagation()})}(e,t),(0,i.ot)(t),t}function Pe(e,t){const a=xe("node-file",e.display_name||e.name,(e=>{})),s=a.querySelector(".node-details");s.querySelectorAll(".details").forEach((t=>{(0,i.ot)(t);const a=(0,j.NA)(t.dataset.where,e);if(a){const e=(0,J.Z)(t.dataset.format,a);t.querySelector("p[data-value]").innerHTML=e}else s.removeChild(t)}));const n=a.querySelector("img.node-img");if(t){const e=n.getAttribute(`data-${t}`);e&&(n.src=e),pe.observe(n)}else(0,p.yx)(n,!1);return function(e){["details","start","rename","delete","download"].forEach((t=>{const a=e.getElementById(t);a&&(0,p.yx)(a,!1)}))}(a),a}function Le(e){if(e in me.storages){const t=me.storages[e];me.origin=e,me.current_path=[],ke(),t.available&&ye({force:!0}),_.hide(!1!==t.readOnly)}}const Ie={load:function(e){if(me.eTag=null,(0,i.Iu)("proj.link",{query:"#title-status-label"}),!pe){pe=new IntersectionObserver(((e,t)=>{e.forEach((e=>{if(e.isIntersecting){const t=e.target.getAttribute("data-src");(0,m.gJ)(t).then((({url:t})=>{e.target.src=t})).catch((()=>{}))}}))}),{rootMargin:"0px 0px 50px 0px",threshold:0})}e||(e=Je.getContext());const t=e.files.selected;if(t){(function(e,t){if(!e||!t)return null;let a=me.files.find((t=>t.origin===e));const i=t.split("/").filter((e=>e)).slice(1);for(const e of i){if(!a)break;a=a.children.find((t=>t.name===e))}return"machinecode"===a?.type?a:null})(t.origin,t.path)||e.selectFile(null)}Te(e,!0),re(),fe({redraw:!0}),ye({force:!0})},update:e=>{const t=e.state;fe(),ye(),Te(e,!0),_.update(t)},getApiPath:be};let Ne=null,_e=null;function Te(e,t=!1){const a=je(e,t);Ne&&Ne.state!==e.state&&(Ne=null),a&&Ce(e,t)}function je(e,t){const a=document.getElementById("job");if(!a)return!1;const i=t?!!e.files.selected:!!e.job?.id;return(0,p.yx)(a,i),i}function Ce(e,t){const a=t?e.files.selected:e.job,s=a?.file?.resource;e.state;var n;if(a.file?(0,p.Zk)():(0,p.QP)(),function(e,t){const a=document.querySelector(".progress-bar"),i=document.querySelector(".progress-pct"),s=document.querySelector(".preview-img-wrapper"),n=void 0!==t;(function(e,t,a){if(!e)return;const i=t?.url&&t.ready?t.url:document.querySelector(".thumbnail-fallback")?.src;if(e.getAttribute("data-src")!==i){for(;e.firstChild;)e.removeChild(e.firstChild);e.setAttribute("data-src",i);const t=document.createElement("div");t.className="progress-img";const s=document.createElement("img");s.src=i,s.className="background",t.appendChild(s);const n=document.createElement("img");n.src=i,n.className="foreground",n.style.clipPath=V(a??100),t.appendChild(n),e.appendChild(t)}else{const t=e.querySelector(".foreground");t&&(t.style.clipPath=V(a))}})(s,e,t),(0,Z.g)(a,t,"top"),(0,p.yx)(a,n),(0,p.yx)(i,n)}(a.thumbnail,a.progress),n=t,(0,p.QH)(document.querySelector("#job #pnt-time"),n),(0,p.QH)(document.querySelector("#job #rem-time"),n),(0,j.ZP)("job",a),s&&a.file){const s=t?null:a.id;!function(e,t,a){const i=t.file;(function(e,t){const a=document.querySelector("#job #stop"),i=document.querySelector("#job-close"),s=h.PT.fromApi(e),n=[h.PT.IDLE,h.PT.READY,h.PT.FINISHED].includes(s),r=!Ne&&h.Dt.includes(e),o=Je.getContext();if((0,p.gL)(a,r),a&&t){const i=t&&![h.PT.REFILL].includes(e);(0,p.yx)(a,i),a.onclick=()=>{B(t,{onConfirm:()=>{Ne={code:"stop",state:e},(0,p.gL)(a,!1)},onError:()=>Ne=null})}}i&&((0,p.yx)(i,n||!t),i.onclick=t?R:()=>o.selectFile(null))})(e,a),function(e,t,a){const i=document.querySelector("#job #start"),s=h.cG.includes(e);i&&((0,p.yx)(i,s),(0,p.gL)(i,s),i.onclick=()=>O(e!==h.PT.READY,t.resource))}(e,i),function(e,t,a){const i=document.querySelector("#job #delete");if(i){const s=t.display_name||t.name;(0,p.gL)(i,!t.readOnly&&t.resource),(0,p.yx)(i,!a||e===h.cG.includes(e)),i.onclick=()=>{U(t.resource,s,(()=>{if(!a){Je.getContext().selectFile(null)}}))}}}(e,i,a),function(e,t,a){const i=document.querySelector("#job #download");if(i){const e=!a&&t.refs?.download&&(!_e||_e===t.refs.download),s=t.display_name||t.name;(0,p.yx)(i,e),e&&(i.onclick=()=>{_e=t.refs.download,W(i),M(t.refs.download,s,(()=>{_e=null,Y(i)}))})}}(0,i,a),!a||(function(e,t,a){const i=document.querySelector(a),s=e===h.PT.PRINTING;(0,p.yx)(i,s),(0,p.gL)(i,!Ne&&s),i&&(i.onclick=()=>{(0,p.gL)(i,!1),Ne={code:"pause",state:e},(e=>(0,m.LK)(`/api/v1/job/${e}/pause`,{method:"PUT"}).catch((e=>(0,u.S)(e))))(t).catch((()=>Ne=null))})}(e,a,"#job #pause"),function(e,t){const a=document.querySelector("#job #resume"),i=[h.PT.PAUSED].includes(e);(0,p.yx)(a,i),(0,p.gL)(a,!Ne&&i),a&&(a.onclick=()=>{(0,p.gL)(a,!1),Ne={code:"resume",state:e},D(t).catch((()=>Ne=null))})}(e,a))}(e.state,a,s),(0,j.ZP)("file",a.file),function(e,t){const a=(0,i.Iu)("prop.na");document.getElementById("job").querySelectorAll(".job-details .job-prop").forEach((e=>{const t=e.querySelector(".job-prop-grid").children;let i=!0;for(const e of t){var s=e.querySelector("[data-type]")?.innerHTML.trim()===a;(0,p.QH)(e,s),s||(i=!1)}(0,p.QH)(e,i)}))}()}}p.QP,p.Zk;const Ae=e=>{if(!e.printer)return;const t=e.state;Te(e),_.update(t),T.Z.update(e,null)},De={load:e=>{(0,i.Iu)("home.link",{query:"#title-status-label"}),l(),Ae(e),T.Z.update(e,null),T.Z.updateCurrentCamera(),(0,m.LK)("/api/v1/storage").then((t=>{const a=t.data.storage_list.find((e=>e.available&&!e.read_only));if(a){const t=a.path.replace("/","");_.init(t,"",e.fileExtensions)}}))},update:Ae},Be=e=>[...e].filter((e=>!!e)).map((e=>e.trim())).join(" - "),Oe=e=>{e.getAttribute("tooltip")||(e.addEventListener("click",(t=>{e.classList.toggle("tooltip-handle--active");const a=e.querySelector("span");(0,p.Ti)((()=>e.classList.remove("tooltip-handle--active")),a,e),t.preventDefault()}),!1),e.setAttribute("tooltip",!0))};let Re=0,qe=null;const Me=()=>{const e=e=>{const t=document.getElementById("offline-screen"),a=document.importNode(t.content,!0);return["not-responsing","please-wait"].forEach((e=>{const t=a.getElementById(`offline-screen.${e}`);t&&(t.innerHTML=(e=>{switch(e){case"not-responsing":return(0,i.Iu)("msg.offline.not-responsing");case"please-wait":return(0,i.Iu)("msg.offline.please-wait");default:return""}})(e))})),qe={node:a,close:()=>{e(),qe=null}},a};(0,b.o)((t=>e(t)),{timeout:0,closeOutside:!1,className:"offline-screen"})},He=({link:e,isConnected:t})=>{const a=["connect"];for(const t in e){const{ok:s,message:n}=e[t],r=document.getElementById(`conn-status-${t}-msg`),o="ok"===n.toLowerCase();r&&(r.innerHTML="connect"===t?o?(0,i.Iu)("conn.connect.linked"):(0,i.Iu)("conn.connect.not-linked"):(0,i.Iu)("conn.error_status"));const l=document.getElementById(`conn-status-${t}`),d=l.querySelector(".icon-success"),c=l.querySelector(".icon-warning"),u=l.querySelector(".info-message-tooltip"),m=u.querySelector("span"),v=void 0===s||s&&!a.includes(t);u&&m&&(o||(m.innerText=n,Oe(u)),(0,p.QH)(u,o)),(0,p.QH)(l,v),v||((0,p.QH)(d,!s),(0,p.QH)(c,s))}const s=e=>{Re<3?Re+=1:(e?Me():qe.close(),Re=0)};t?qe&&s(!1):!qe&&s(!0)};const Ue=e=>{return{resource:e.resource??(t=e.path,a=e.name,`/api/v1/files${t}${t.endsWith("/")?"":"/"}${a}`),name:e.name,displayName:e.display_name??e.name,path:e.path,displayPath:e.display_path??e.path,size:e.size,refs:{download:e.refs?.download,icon:e.refs?.icon,thumbnail:e.refs?.thumbnail},lastModified:e.m_timestamp||0,meta:{filamentType:e.meta?.filament_type,layerHeight:e.meta?.layer_height,estimatedPrintTime:e.meta?.estimated_print_time,exposureTime:e.meta?.exposure_time,exposureTimeCalibration:e.meta?.exposure_time_calibration,exposureTimeFirst:e.meta?.exposure_time_first,exposureUserProfile:e.meta?.exposure_user_profile},readOnly:e.read_only||e.ro};var t,a},Ke=new class{constructor(){this.state=void 0,this.printer=void 0,this.job=void 0,this.transfer=void 0,this.version=void 0,this.storage=[],this.currentStorage=void 0,this.telemetry={temperature:{nozzle:{current:0,target:0},bed:{current:0,target:0}}},this.flow=0,this.speed=0,this.fan={hotend:0,print:0},this.link={connect:{ok:!0,message:"OK",settings:{hostname:"connect.prusa3d.com",tls:!0,port:0}},printer:{ok:!0,message:"OK",settings:{port:"",baudrate:115200}}},this.files={location:"/",selected:{file:void 0,thumbnail:void 0}},this.fileExtensions=[".gcode"],this.updateConnection()}updateConnection(){return(0,m.LK)("/api/connection",{method:"GET"}).then((e=>{this.link.connect.settings={hostname:e.data.connect?.hostname,port:e.data.connect?.port,tls:e.data.connect?.tls},this.link.connect.registration=e.data.connect?.registration,this.link.printer.settings={port:e.data.current?.port,baudrate:e.data.current?.baudrate}}))}update({status:e,printer:t}){e?.ok&&e.payload&&this.updateStatus(e.payload.data),t&&this.updatePrinter(t)}updateStatus(e){this.updateTelemetry(e.printer),this.updateJob(e.job),this.updateStorage(e.storage),this.updateTransfer(e.transfer),this.updateCamera(e.camera)}updatePrinter(e){this.printer={name:e.name,location:e.location,farmMode:e.farm_mode,nozzleDiameter:e.nozzle_diameter,minExtrusionTemp:e.min_extrusion_temp,serial:e.serial,hostname:e.hostname,port:e.port},this.fileExtensions=e.project_extensions??[".gcode"]}updateTelemetry(e){this.state=h.PT.fromApi(e.state.toUpperCase()),this.telemetry={temperature:{nozzle:{current:e.temp_nozzle,target:e.target_nozzle},bed:{current:e.temp_bed,target:e.target_bed},ambient:{current:e.temp_ambient},cpu:{current:e.temp_cpu},uvLED:{current:e.temp_uv_led}},axis:{x:e.axis_x,y:e.axis_y,z:e.axis_z},flow:e.flow,speed:e.speed,fan:{hotend:e.fan_hotend,print:e.fan_print,blower:e.fan_blower,rear:e.fan_rear,uvLED:e.fan_uv_led},coverClosed:e.cover_closed,isCalibrated:e.is_calibrated},this.link.connect.message=e.status_connect?.message??"",this.link.connect.ok=e.status_connect?.ok,this.link.printer.message=e.status_printer?.message??"ok",this.link.printer.ok=e.status_printer?.ok??!0}updateJob(e){const t=this.job?.id||null,a=e?.id||null;if(t!==a||this.job?.dirty){if(!a)return void(this.job=void 0);this.updateJobDetails()}if(e&&a){const t=this.job?.timeRemaining,s=e.time_remaining,n=t!=s?(i=s,Math.round(Date.now()/1e3)+i):this.job?.estimatedEnd;0,this.job={dirty:!1,file:void 0,...this.job,timePrinting:e.time_printing,id:a,progress:e.progress,timeRemaining:e.time_remaining,estimatedEnd:n}}var i}updateJobDetails(){return(0,m.LK)("/api/v1/job").then((e=>{const t=e.data;if(t.id===this.job.id&&(this.job={...this.job,dirty:!1,file:Ue(t.file),thumbnail:{source:!t.file.refs?.thumbnail,ready:!t.file.refs?.thumbnail,url:void 0}},!this.job.thumbnail.ready)){const e=this.job.id;(0,m.gJ)(this.job.file.refs.thumbnail).then((({url:t})=>{this.job.id===e&&(this.job.thumbnail.url=t)})).catch((e=>console.error("Failed to fetch thumbnail",e))).finally((()=>this.job.thumbnail.ready=!0))}})).catch((e=>{this.job.dirty=!0,(0,u.S)(e)}))}updateStorage(e){Object.keys(e).forEach((e=>{const t={path:e.path,name:e.name,readOnly:e.readOnly,freeSpace:e.freeSpace},a=this.storage.findIndex((e=>e.path===t.path));-1!==a?this.storage[a]=t:this.storage.push(t)}))}updateTransfer(e){const t=this.transfer?.id||null,a=e?.id||null;if(t!==a){if(!a)return void(this.transfer=void 0);(0,m.LK)("/api/v1/transfer").then((e=>{const t=e.data;this.transfer={...this.transfer,file:{displayName:t.display_name??t.name,path:t.path,size:t.size,toPrint:t.to_print}}})).catch((e=>(0,u.S)(e)))}if(a){const t=Math.round(Date.now()/1e3),i=this.transfer?.file?.size||0,s=e.time_transferring,n=void 0!==s?t-s:void 0,r=e.data_transferred,o=i-r,l=s>0&&o>=0?o/(r/s):void 0;this.transfer={...this.transfer,timeTransferring:s,timeStarted:n,timeRemaining:l,id:a,progress:e.progress,dataTransferred:r}}}updateCamera(e){this.camera={id:e?.id}}selectFile(e){if(!e)return void(this.files.selected=null);const t=e.refs?.thumbnail;if(this.files.selected={file:Ue(e),thumbnail:{source:t,ready:!t,url:void 0},timeRemaining:e.meta?.estimated_print_time},!e.meta){const e=this.files.selected.file.resource;(0,m.LK)(e).then((t=>{const a=Ue({...t.data,resource:e});this.files.selected.file.resource===e&&(this.files.selected.file.meta=a.meta,this.files.selected.timeRemaining=a.meta?.estimatedPrintTime)}))}t&&(0,m.gJ)(t).then((({url:e})=>{t===this.files.selected.thumbnail.source&&(this.files.selected.thumbnail.url=e)})).catch((()=>{})).finally((()=>{t===this.files.selected.thumbnail.source&&(this.files.selected.thumbnail.ready=!0)}))}},Ve=e=>{const t=e.load;return e.load=()=>{(()=>{const e=document.getElementById("title-printer");e&&(e.innerHTML=Ze())})(),t(Ke)},e},Ze=()=>(e=>Be([e.printer?.location||e.printer?.hostname,e.printer?.name]))(Ke);let Fe=De;const $e={routes:[{path:"dashboard",html:a(2373),module:Ve(De),getTitle:()=>(0,i.Iu)("home.link")},{path:"question",html:a(5198),module:Ve(G)},null,{path:"files",html:a(7189),module:Ve(Ie),getTitle:()=>(0,i.Iu)("proj.storage")},{path:"settings",html:a(2936),module:Ve(a(2957).Z),getTitle:()=>(0,i.Iu)("settings.title")},{path:"control",html:a(3478),module:Ve(a(732).Z),getTitle:()=>(0,i.Iu)("control.link")},{path:"cameras",html:a(5464),module:Ve(a(2451).Z),getTitle:()=>(0,i.Iu)("cameras.link")}].filter((e=>null!=e)),init:e=>{Ke.update(e),Ge()},update:e=>{Ke.update(e);const t=(0,C.nC)(),a=(e=>{const t=e.state;let a=(0,h.ny)(t);switch(t){case"IDLE":return"";case"PRINTING":return`${a} ${Math.round(e?.job?.progress||0)}%`;default:return a}})(Ke);var i;document.title=(i=[a,$e.routes.find((e=>e.path===t)).getTitle()],Be([...i,Ze(),"PrusaLink"])),(0,j.zR)("telemetry",Ke),(e=>{const t=e,a=document.getElementById("printer-status");a&&(a.innerHTML=(0,h.ny)(t))})(Ke.state),We(Ke.telemetry),Ye()},setConnected:e=>{He({link:Ke.link,isConnected:e})},setModule:e=>{Fe=e},getContext:()=>Ke},Ge=()=>{const e=new Map([["temp-line-blue",[]],["temp-line-orange",[]]]);o(e,300),l()},We=e=>{const t=(new Date).getTime();d("temp-line-blue",[t,e.temperature.bed.current]),d("temp-line-orange",[t,e.temperature.nozzle.current]),l()},Ye=()=>{Fe&&Fe.update&&Fe.update(Ke)},Je=$e},8751:(e,t,a)=>{"use strict";a.d(t,{c4:()=>l,g9:()=>d,nC:()=>r});var i=a(7780),s=a(3532);const n=e=>{const[t,a]=e.split("#");return a||"dashboard"},r=()=>n(window.location.hash);function o(e,t){const a=n(e),r=s.Z.routes.find((e=>e.path===a));if(!r)return!1;t&&function(e){window.location.hash!=e&&history.pushState(null,"",e)}("#"+a);const o=document.getElementById("root");var l;return function(e,t){t.innerHTML="",(new DOMParser).parseFromString(e,"text/html").body.childNodes.forEach((e=>t.appendChild(e)))}(r.html,o),(0,i.ot)(o),function(e){const t=document.querySelector(`a[href="#${e}"]`);t&&(document.getElementById("navbar").childNodes.forEach((e=>{"li"===e.nodeName.toLowerCase()&&e.classList.remove(["active"])})),t.parentNode.className="active")}(a),(l=r.getTitle)&&(document.title=l()),window.scrollTo({top:0}),r.module.load(),s.Z.setModule(r.module),!0}const l=e=>o(e,!0),d=e=>o(e,!1)},4800:(e,t,a)=>{"use strict";a.d(t,{Dt:()=>r,PT:()=>s,cG:()=>n,ny:()=>o});var i=a(7780);const s={UNKNOWN:"UNKNOWN",IDLE:"IDLE",READY:"READY",BUSY:"BUSY",POUR_IN_RESIN:"POUR IN RESIN",REFILL:"FEED ME",PRINTING:"PRINTING",PAUSED:"PAUSED",FINISHED:"FINISHED",STOPPED:"STOPPED",SELECTED:"SELECTED",ERROR:"ERROR",ATTENTION:"ATTENTION",fromApi:e=>{switch(e.toUpperCase()){case"IDLE":return s.IDLE;case"READY":return s.READY;case"BUSY":return s.BUSY;case"PRINTING":return s.PRINTING;case"PAUSED":return s.PAUSED;case"FINISHED":return s.FINISHED;case"STOPPED":return s.STOPPED;case"ERROR":return s.ERROR;case"ATTENTION":return s.ATTENTION;case"POUR IN RESIN":return s.POUR_IN_RESIN;case"FEED ME":return s.REFILL;case"SELECTED":return s.SELECTED;case"UNKNOWN":return s.UNKNOWN;default:return console.error(`Unsupported state: ${e}`),s.UNKNOWN}}},n=[s.IDLE,s.READY,s.FINISHED,s.SELECTED],r=[s.PRINTING,s.PAUSED,s.POUR_IN_RESIN,s.SELECTED],o=e=>{switch(e){case s.IDLE:return(0,i.Iu)("prop.st-idle");case s.READY:return(0,i.Iu)("prop.st-ready");case s.BUSY:return(0,i.Iu)("prop.st-busy");case s.PRINTING:return(0,i.Iu)("prop.st-printing");case s.PAUSED:return(0,i.Iu)("prop.st-paused");case s.FINISHED:return(0,i.Iu)("prop.st-finished");case s.STOPPED:return(0,i.Iu)("prop.st-stopped");case s.ERROR:return(0,i.Iu)("prop.st-error");case s.ATTENTION:return(0,i.Iu)("prop.st-attention");case s.POUR_IN_RESIN:return(0,i.Iu)("prop.st-pour-resin");case s.SELECTED:return(0,i.Iu)("prop.st-ready");case s.REFILL:return(0,i.Iu)("prop.st-feedme");default:return console.error(`Unsupported state: ${e}`),(0,i.Iu)("prop.st-unknown")}}},4977:e=>{"use strict";e.exports=JSON.parse('{"langs":["cs","de","en","es","fr","it","kr","lt","nl","pl","sk"],"texts":{"home":{"link":["Přehled","Dashboard","Dashboard","Panel principal","Tableau de bord","Dashboard","대쉬보드","Darbastalis","Dashboard","Panel kontrolny","Prehľad"],"title":["Stav tiskárny","Druckerstatus","Printer Status","Estado de la impresora","État de l\'imprimante","Stato della stampante","프린터 상태","Spausdintojo būsena","Printerstatus","Stan drukarki","Stav tlačiarne"]},"proj":{"link":["Úložiště","Projekte","Storage","Proyectos","Projets","Progetti","저장장치","Saugykla","Opslag","Projekty","Úložisko"],"storage":["Úložiště","Speicher","Storage","Almacenamiento","Stockage","Archivio","저장장치","Saugykla","Opslag","Projekty","Úložisko"],"add-from":{"title":["Nahrát soubor z","Datei hinzufügen von","Add file from","Añadir archivo desde","Ajouter un fichier à partir de","Aggiungi file da","에서 파일 추가하기","Pridėti failą iš","Bestanden toevoegen van","Dodaj plik z","Nahrať súbor z"],"local":["Lokálního úložiště","Lokaler Speicher","Local storage","Almacenamiento local","Stockage local","Archivio locale","로컬 저장장치","Vietinis saugykla","Lokale opslag","Magazyn lokalny","Lokálneho úložiska"],"remote":["Vzdálené url","Remote-URL","Remote url","Eliminar url","URL distante","Url remoto","원격 URL","Nuotolinis URL","Externe URL","Zdalny URL","Vzdialenej URL"]},"details":["Detaily souboru","Datei-Details","File details","Detalles del archivo","Détails du fichier","Dettagli file","파일 상세정보","Failo informacija","Bestandseigenschappen","Szczegóły pliku","Detaily súboru"],"del":["Smazat","Datei löschen","Delete","Borrar archivo","Supprimer le Fichier","Elimina File","삭제","Ištrinti","Verwijderen","Usuń plik","Zmazať"],"download":["Stáhnout","Herunterladen","Download","Descargar","Télécharger","Download","다운로드","Atsisiųsti","Downloaden","Pobierz","Stiahnuť"],"up-folder":["nadřazená složka","übergeordneter Ordner","parent folder","carpeta contenedora","dossier parent","cartella superiore","상위폴더","tėvinis aplankas","Bovenliggende map","folder nadrzędny","nadradený priečinok"]},"control":{"link":["Ovládání","Kontrolle","Control","Control","Contrôle","Controllo","제어","Valdymas","Bediening","Sterowanie","Ovládanie"],"coordinates":["Souřadnice tiskárny","Drucker-Koordinaten","Printer Coordinates","Coordenadas de la Impresora","Coordonnées de l\'imprimante","Coordinate Stampante","프린터 좌표","Spausdintojo koordinatės","Printercoördinaten","Współrzędne drukarki","Súradnice tlačiarne"],"axis":{"x":["Osa X","X-Achse","X axis","Eje X","axe X","Asse X","X축","X ašis","X-as","Oś X","Os X"],"y":["Osa Y","Y-Achse","Y axis","Eje Y","axe Y","Asse Y","Y축","Y ašis","Y-as","Oś Y","Os Y"],"z":["Osa Z","Z-Achse","Z axis","Eje Z","axe Z","Asse Z","Z축","Z ašis","Z-as","Oś Z","Os Z"]},"stepper-motors":["Krokové motory","Schrittmotoren","Stepper Motors","Motores Paso-a-paso","moteurs","Motori Passo-Passo","스텝 모터","Sustojimo varikliai","Stappenmotors","Silniki krokowe","Krokové motory"],"heated-bed-move":["Pohyb podložky v ose X a Y","Heizbett X und Y bewegen","Heated Bed X and Y Move","Base Calefactable Mover X e Y","Mouvement en x/y","Spostamento X e Y Piano Riscaldato","히트베드 X 및 Y 이동","Šildomojo patalo X ir Y poslinkis","Beweeg verwarmd bed in X en Y","Ruch stołu grzewczego X i Y","Pohyb podložky v osi X a Y"],"move-step":["Krok pohybu [mm]","Schrittweite [mm]","Move Step [mm]","Mover Paso [mm]","déplacement [mm]","Sposta Passo [mm]","[mm]mm 스텝 이동","Poslinkis [mm]","Beweegafstand [mm]","Krok ruchu [mm]","Krok pohybu [mm]"],"nozzle-z-move":["Pohyb extrudéru v ose Z","Düse Z Bewegung","Nozzle Z Move","Boquilla Mover Z","déplacement en z","Spostamento Z Ugello","노즐 Z 이동","Antgalio Z poslinkis","Beweeg omhoog/omlaag","Ruch dyszy w osi Z","Pohyb extruderu v osi Z"],"extruder":["Extrudér","Extruder","Extruder","Extrusor","extrudeur","Estrusore","익스트루더","Ištraukiklis","Extruder","Ekstruder","Extruder"],"extrude-retract-step":["Krok vytlačení/vtažení [mm]","Extrudieren/Zurückziehen Schritt [mm]","Extrude/Retract Step [mm]","Distancia Extruir/Retraer [mm]","longueur d\'extrusion ou de rétractation [mm]","Passo Estrusione/Retrazione [mm]","[mm]mm 스텝 압출/철회","Ištrauka/atgalinimas [mm]","Extrude/retract-afstand [mm]","Krok ekstruzji/retrakcji [mm]","Krok vysunutia/zasunutia [mm]"],"nozzle-temp":["Teplota trysky","Düsentemperatur","Nozzle Temperature","Temperatura Boquilla","température de la buse","Temperatura Ugello","노즐 온도","Antgalio temperatūra","Temperatuur nozzle","Temperatura dyszy","Teplota trysky"],"speed":["Rychlost","Geschwindigkeit","Speed","Velocidad","vitesse","Velocità","속도","Greitis","Snelheid","Prędkość","Rýchlosť"],"heated-bed-temp":["Teplota vyhřívané podložky","Temperatur des Heizbettes","Heated Bed Temperature","Temperatura de la Base Calefactable","Température du lit","Temperatura piano riscaldato","히트베드 온도","Šildomojo patalo temperatūra","Temperatuur verwarmd bed","Temperatura stołu","Teplota vyhrievanej podložky"],"flow":["Průtok","Flow","Flow","Flujo","flux","Flusso","흐름","Srautas","Extrusiefactor","Przepływ","Prietok"],"title":["Ovládání tiskárny","Druckersteuerung","Printer Control","Control de la Impresora","Contrôle de l\'imprimante","Controllo Stampante","프린터 제어","Spausdintojo valdymas","Printerbediening","Sterowanie drukarką","Ovládanie tlačiarne"]},"cameras":{"link":["Kamery","Kameras","Cameras","Cámaras","Caméras","Fotocamere","카메라","Kameros","Camera\'s","Kamery",""],"trigger-scheme":{"ten-sec":["Každých 10 sekund","Alle 10 Sekunden","Every 10 seconds","Cada 10 segundos","Toutes les 10 secondes","Ogni 10 secondi","매 10초","Kas 10 sekundžių","Elke 10 seconden","Co 10 sekund",""],"thirty-sec":["Každých 30 sekund","Alle 30 Sekunden","Every 30 seconds","Cada 30 segundos","Toutes les 30 secondes","Ogni 30 secondi","매 30초","Kas 30 sekundžių","Elke 30 seconden","Co 30 sekund",""],"sixty-sec":["Každých 60 sekund","Alle 60 Sekunden","Every 60 seconds","Cada 60 segundos","Toutes les 60 secondes","Ogni 60 secondi","매 60초","Kas 60 sekundžių","Elke minuut","Co 60 sekund",""],"each-layer":["Při změně vrstvy","Beim Schichtwechsel","On layer change","Al cambiar de capa","Au changement de couche","Al cambio layer","매 레이어","Pakeitus sluoksnį","Bij laagwissel","Przy zmianie warstwy",""],"fifth-layer":["Každou pátou vrstvu","Alle 5 Schichten","Every fifth layer","Cada cinco capas","Toutes les cinq couches","Ogni cinque layer","매 5레이어","Kas penktas sluoksnis","Elke 5 lagen","Co piąta warstwa",""],"manual":["Ručně","Manuell","Manual","Manual","Manuel","Manuale","수동","Rankinis","Handmatig","Ręcznie",""]}},"settings":{"title":["Nastavení","Einstellungen","Settings","Ajustes","Réglages","Impostazioni","세팅","Nustatymai","Instellingen","Ustawienia","Nastavenie"]},"conn":{"prusa-connect-status":["Stav připojení PrusaConnect","PrusaConnect Status","PrusaConnect Status","Estado de PrusaConnect","Informations de connexion à PrusaConnect","Stato PrusaConnect","프루사커넥트 상태","PrusaConnect būsena","Status van PrusaConnect","Stan PrusaConnect","Stav pripojenia PrusaConnect"],"printer-status":["Stav připojení 3D tiskárny","3D-Drucker Verbindungsstatus","3D Printer Connection Status","Estado Conexión Impresora 3D","Informations de connexion à l\'imprimante 3D","Stato Connessione Stampante 3D","3D 프린터 연결 상태","3D spausdintojo prisijungimo būsena","Verbindingsstatus van 3D-printer","Status połączenia z drukarką 3D","Stav pripojenia 3D tlačiarne"],"title":["připojení","Verbindung","Connection",null,"connexion",null,"연결","Prisijungimas","Verbinding",null,"pripojenie"],"prusa-connect-url":["Adresa PRUSA CONNECT","PRUSA CONNECT Adresse","PRUSA CONNECT Address",null,null,null,"프루사커넥트 주소","PRUSA CONNECT adresas","Adres van PRUSA CONNECT",null,"Adresa PrusaConnect"],"suc":["Úspěšně připojeno","Erfolgreich verbunden","Successfully connected","Correctamente conectado","Connecté avec succès","Connessione riuscita","연결 성공","Prisijungta sėkmingai","Succesvol verbonden","Połączono pomyślnie","Úspešne pripojené"],"connect":{"linked":["","Verbunden","Linked","Enlazado","Associée","Collegato","연결됨","Susieta","Verbonden","Połączono",""],"not-linked":[null,"Nicht verbunden","Not Linked","No enlazado","Non Associée","Non collegato","연결안됨","Nesusieta","Niet verbonden","Nie połączono",""]},"error_status":["Chyba","Fehler","Error","Error","Erreur","Errore","에러","","","Błąd",""]},"prop":{"temp-nozzle":["Teplota trysky","Düsentemperatur","Nozzle Temperature","Temperatura del nozzle","Température de la buse","Temperatura ugello","노즐 온도","Nozzle Temperature","Nozzletemperatuur","Temperatura dyszy","Teplota trysky"],"temp-bed":["Vyhřívaná podložka","Heizbett","Heatbed","Base calefactable","Plateau chauffant","Piano riscaldato","히트베드","Dėklo temperatūra","Verwarmd bed","Stół","Vyhrievaná podložka"],"speed":["Rychlost tisku","Druckgeschwindigkeit","Printing Speed","Velocidad de impresión","Vitesse d\'impression","Velocità di stampa","출력속도","Spausdinimo greitis","Printsnelheid","Prędkość drukowania","Rýchlosť tlače"],"z-height":["Výška Z","Z-Höhe","Z-Height","Altura-Z","Hauteur en z","Altezza-Z","Z높이","Z-Height","Z-Hoogte","Wysokość Z","Výška Z"],"nozzle-diameter":["Velikost trysky","Düsendurchmesser","Nozzle Diameter","Diámetro de Boquilla","Diamètre de la Buse","Diametro ugello","노즐지름","Galvutės skersmuo","Nozzlediameter","Średnica dyszy","Veľkosť trysky"],"rem-time":["Zbývající čas","Restzeit","Remaining time","Tiempo restante","Temps restant","Tempo residuo","남은시간","Likęs laikas","Resterende tijd","Pozostały czas","Zostávajúci čas"],"time-est":["Odhad doby tisku","Druckzeit Schätzen","Print Time Estimate","Tiempo Estimado de Impresión","Estimation du temps d\'impression","Stima del Tempo di Stampa","예상 출력 시간","Print Time Estimate","Geschatte printtijd","Szacowany Czas Druku","Odhad času tlače"],"est-end":["Odhadovaný konec","Erwartetes Ende","Estimated end","Fin estimado","Fin d\'impression estimée","Fine prevista","예상완료","Numatomas pabaiga","geschatte eindtijd","Szacowane zakończenie","Odhadovaný koniec"],"pnt-time":["Doba tisku","Druckzeit","Printing time","Tiempo de impresión","Temps d\'impression","Tempo di stampa","출력시간","Spausdinimo laikas","Printtijd","Czas druku","Čas tlače"],"last-mod":["Naposledy upraveno","Zuletzt Geändert","Last Modified","Ultima Modificación","Dernière Modification","Ultima Modifica","최종 수정됨","Paskutinis modifikavimas","Laatst gewijzigd","Ostatnio Zmodyfikowany","Posledná úprava"],"material":["Materiál","Material","Material","Material","Matériau","Materiale","재료","Medžiaga","Materiaal","Materiał","Materiál"],"layer-ht":["Výška vrstvy","Schichthöhe","Layer Height","Altura de Capa","Hauteur de Couche","Altezza Layer","레이어 높이","Sluoksnio storis","Laagdikte","Wysokość Warstwy","Výška vrstvy"],"size":["Velikost souboru","Größe der Datei","File size","Tamaño de archivo","Taille du fichier","Dimensione file","파일크기","Failo dydis","Bestandsgrootte","Rozmiar pliku","Veľkosť súboru"],"progress":["Postup","Fortschritt","Progress","Progreso","Progression","Progresso","진행","Eiga","Voortgang","Postęp","Progres"],"st-idle":["Nečinná","Leerlauf","Idle","En espera","Repos","Inattivo","대기","Pramoginė veikla","Inactief","Oczekuje","Nečinná"],"st-ready":["Připravena","Bereit","Ready","Lista","Prête","Pronto","준비","Pasirengęs","Gereed voor gebruik","Gotowe","Pripravené"],"st-busy":["Zaneprázdeněná","Beschäftigt","Busy","Ocupada","Occupée","Occupato","작업중","Užsiėmęs","Bezig","Zajęty","Zaneprázdnená"],"st-printing":["Probíhá tisk","Druckt","Printing","Imprimiendo","Impression","Stampa","출력중","Spausdinama","Printen","Drukowanie","Prebieha tlač"],"st-paused":["Pozastavena","Pausiert","Paused","Pausado","En pause","In pausa","일시정지","Pristabdyta","Gepauzeeerd","Pauza","Pozastavená"],"st-finished":["Dokončeno","Beendet","Finished","Terminado","Terminé","Completato","완료됨","Užbaigta","Klaar","Zakonczono","Dokončené"],"st-stopped":["Zastaveno","Gestoppt","Stopped","Parada","Arrêtée","Arrestato","정지됨","Sustabdytas","Gestopt","Zatrzymano","Zastavené"],"st-error":["Chyba","Fehler","Error","Error","Erreur","Errore","에러","Klaida","Fout","Błąd","Chyba"],"st-attention":["Vyžaduje pozornost","Achtung","Attention","Atención","Attention","Attenzione","주의","Dėmesio","Attentie","Uwaga","Pozor"],"st-pour-resin":["Nalití resinu","Gieße Harz ein","Pour in resin","Vierte resina","Faire affluer la résine","Versa la resina","레진 붓기","Įpilkite dervą","Giet hars in vat","Wlej żywicę","Nalejte resin"],"st-feedme":["Doplnění resinu","Harz nachfüllen","Refill resin","Añadir resina","remplir le réservoir de résine","Ricarica la resina","레진 리필","Papildykite dervą","Hervul resin","Uzupełnij żywicę","Doplnenie resinu"],"st-unknown":["Neznámý","Unbekannt","Unknown","Desconocido","Inconnu","Sconosciuto","알수없음","Nežinoma","Onbekend","Nieznane","Neznáme"],"at":["v","um","at","a las","à","alle","에서","iš","op","o","v"],"less-than":["Méně než minuta","Weniger als eine Minute","Less than a minute","Menos de un minuto","Moins d\'une minute","Meno di un minuto","1분 미만","Mažiau nei minutė","Minder dan een minuut","Mniej niż minuta","Menej ako minúta"],"today-at":["Dnes v","Heute um","Today at","Hoy a las","Aujourd\'hui à","Oggi alle","오늘","Šiandien","Vandaag om","Dzisiaj o","Dnes o"],"tmw-at":["Zítra v","Morgen um","Tomorrow at","Mañana a las","Demain à","Domani alle","내일","Rytoj","Morgen om","Jutro o","Zajtra o"],"true":["Ano","Ja","Yes","Si","Oui","Si","예","Taip","Ja","Tak","Áno"],"false":["Ne","Nein","No","No","Non","No","아니오","Ne","Nee","Nie","Nie"],"na":["N/A","NV","NA","NA","NA","ND","없음","NA","N.v.t.","N/D","N/A"],"cover-closed":["Uzavřené","Geschlossen","Closed","Cerrada","Fermé","Chiuso","닫힘","Uždarytas","Gesloten","Zamknięta","Zavreté"],"cover-opened":["Otevřené","Geöffnet","Opened","Abierta","Ouvert","Aperto","열림","Atidarytas","Open","Otwarta","Otvorené"],"storage-used-space":["Použito {{used}} z {{total}} ({{free}} volných)","{{used}} benutzt von {{total}} ({{free}} frei)","Used {{used}} of {{total}} ({{free}} free)","Usados {{used}} de {{total}} ({{free}} libres)","{{used}} utilisés sur {{total}} ({{free}} libre)","Usato {{used}} di {{total}} ({{free}} liberi)","{{total}} 중 {{used}} 사용중({{free}} 남음)","","","Zajęte {{used}} z {{total}} ({{free}} wolne)",""]},"msg":{"modal-p1":["Vítejte na webovém rozhraní vaší","Willkommen zur Weboberfläche Ihres","Welcome to the web interface of your","Bienvenido a la interfaz web de tu","Bienvenue dans l\'interface web de votre","Benvenuto nell\'interfaccia web del tuo","당신의 웹 인터페이스에 오신 것을 환영합니다.","Sveiki atvykę į savo","Welkom bij de webinterface van uw","Witaj w interfejsie Twojej","Vitajte vo webovom rozhraní vašej"],"modal-p2":["Upozornění: některé hodnoty jsou zobrazeny pouze v průběhu tisku.","Bitte beachten Sie, dass die Werte nur angezeigt werden, wenn der Drucker gerade druckt.","Please note that values are shown only when the printer is printing.","Tenga en cuenta que los valores se muestran solo cuando la impresora está imprimiendo.","Veuillez noter que les valeurs sont affichées uniquement lorsque l\'imprimante imprime.","Si noti che i valori vengono visualizzati solo durante la stampa.","프린터가 출력중일 경우에만 값이 표시됩니다.","Prašome atkreipti dėmesį, kad reikšmės rodomos tik tada, kai spausdintuvas spausdina.","De waarden worden alleen getoond tijdens het printen.","Wartości będą wyświetlane tylko podczas drukowania.","Upozornenie: niektoré hodnoty sú zobrazené len počas tlače."],"api-key-1":["Vítejte ve webovém rozhraní PrusaLink Web!","Willkommen auf der Web-Oberfläche von PrusaLink Web.","Welcome to the PrusaLink Web web interface.","Bienvenido a la interfaz web de PrusaLink Web.","Bienvenue sur l\'interface web locale de PrusaLink Web.","Benvenuti nell\'interfaccia web di PrusaLink Web.","프루사 커넥트 웹 웹인터페이스에 오신 것을 환영합니다.","Sveiki atvykę į PrusaLink Web interneto sąsają.","Welkom bij de PrusaLink Web webinterface.","Witaj w interfejsie sieciowym PrusaLink Web.","Vitajte vo webovom rozhraní PrusaLink Web!"],"api-key-2":["Vložte API klíč.","Bitte geben Sie den API-Schlüssel ein.","Please insert the API key.","Por favor introduce la clave API.","Veuillez insérer la clé API.","Si prega di inserire la chiave API.","API키를 입력하십시오.","Prašome įvesti API raktą.","Voer de API-sleutel in","Wpisz klucz API.","Vložte API kľúč."],"api-key-3":["Otevřete menu Nastavení -> Síť -> PrusaLink.","Sie finden ihn in Einstellungen -> Netzwerk -> PrusaLink.","You can find it in Settings -> Network -> PrusaLink.","Puedes encontrarla en Ajustes -> Red -> PrusaLink.","Vous pouvez la trouver dans Réglages -> Réseau -> Informations de connexion.","È possibile trovarla in Impostazioni -> Rete -> PrusaLink","설정 -> 네트워크 -> 프루사링크에서 찾을 수 있습니다.","Jį galite rasti Nustatymai -> Tinklas -> PrusaLink.","Deze kan gevonden worden bij Settings -> -> Network -> PrusaLink.","Możesz znaleźć go w sekcji Ustawienia -> Sieć -> PrusaLink.","Nájdete ho v Nastavenia -> Sieť -> PrusaLink."],"offline":{"not-responsing":["Tiskárna nereaguje.","Der Drucker antwortet nicht.","Printer is not responding.","La impresora no responde.","L\'imprimante ne répond pas.","La stampante non risponde.","프린터가 응답하지 않습니다.","Spausdintuvas neatsako.","Printer reageert niet.","Drukarka nie odpowiada.","Tlačiareň nereaguje."],"please-wait":["Prosím, čekejte...","Bitte warten...","Please wait...","Espera, por favor...","Veuillez patienter...","Attendere...","기다려주십시오...","Prašome palaukti...","Even geduld...","Proszę czekać...","Prosím, čakajte..."]},"drop-zone":{"label":["Přetažením souboru zahájíte nahrávání","Datei hier ablegen, um den Upload zu starten","Drop file here to start the uploading","Arrastra archivo aquí para subirlo","Déposez le fichier ici pour commencer le téléchargement","Trascina il file qui per iniziare il caricamento","파일을 여기로 끌어놓으면 업로드가 시작됩니다.","Tempti failą čia, kad predėti įkrovimą","Sleep het bestand hierheen om het te uploaden","Upuść plik tutaj, aby rozpocząć przesyłanie.",""]},"create-folder":["Nová složka","Neuer Ordner","New Folder","Nueva Carpeta","Nouveau Dossier","Nuova Cartella","새 폴더","","","Nowy folder",""],"create-folder-name":["Název složky","Ordnername","Folder Name","Nombre de la Carpeta","Nom de Dossier","Nome cartella","폴더 이름","","","Nazwa folderu",""],"sysupgrade":{"title":["Aktualizovat systémový balíček?","Systempaket aktualisieren?","Upgrade System Package?","¿Actualizar el Paquete del Sistema?","Mettre à niveau le package système ?","Aggiornare il pacchetto di sistema?","시스템 패키지를 업그레이드하겠습니까?","","","Zaktualizować paczkę systemową?",""],"remark":["Aktualizace může trvat několik desítek vteřin.","Das Upgrade kann einige Sekunden dauern.","The upgrade may take several tens of seconds.","La actualización puede tardar varias decenas de segundos.","La mise à jour peut prendre plusieurs dizaines de secondes.","L\'aggiornamento può richiedere qualche minuto.","업그레이드에는 수십초가 걸릴 수 있습니다.","","","Aktualizacja może trwać kilkadziesiąt sekund.",""],"pending":["Probíhá aktualizace... Nevypínejte tiskárnu.","Upgraden... Schalten Sie den Drucker nicht aus.","Upgrading... Do not switch the printer off.","Actualizando... No apagues la impresora.","Mise à niveau... N\'éteignez pas l\'imprimante.","Aggiornamento... Non spegnere la stampante.","업그레이드중... 프린터를 끄지 마십시오.","","","Aktualizacja... Nie wyłączaj drukarki.",""],"wait-for-printer":["Čeká se na tiskárnu...","Warten auf den Drucker...","Waiting for the printer...","Esperando a la impresora...","Attente de l\'imprimante...","In attesa della stampante...","프린터를 기다리는 중...","","","Oczekiwanie na drukarkę...",""]},"outdated-os":{"message":["PrusaLink běží na zastaralém obrazu systému. Nahrajte ho prosím znovu podle tohoto návodu. Bohužel bude nutné PrusaLink znovu nastavit","Ihr PrusaLink läuft mit einem veralteten Raspberry Pi Betriebssystem. Folgen Sie dieser Anleitung, um die neueste Version zu flashen. Dies wird PrusaLink zurücksetzen","Your PrusaLink is running on an outdated Raspberry Pi OS. Follow this guide to flash the latest version. This will reset PrusaLink","Tu PrusaLink está funcionando con un Raspberry Pi OS obsoleto. Sigue esta guía para flashear la última versión. Esto reiniciará PrusaLink","Votre PrusaLink fonctionne sur un système un Raspberry Pi OS obsolète. Suivez ce guide pour flasher la dernière version. Cela réinitialisera PrusaLink","Il sistema PrusaLink è in esecuzione su un sistema operativo Raspberry Pi non aggiornato. Seguire questa guida per flashare l\'ultima versione. Questo ripristinerà PrusaLink","PrusaLink가 오래된 Raspberry Pi OS에서 실행되고 있습니다.이 가이드를 따라 최신 버전을 업데이트하세요. PrusaLink가 재설정됩니다.","","","Twój PrusaLink działa na nieaktualnej wersji systemu operacyjnego Raspberry Pi. Postępuj zgodnie z tym przewodnikiem, aby wgrać najnowszą wersję. Spowoduje to zresetowanie PrusaLink",""],"title":["Raspberry Pi OS je zastaralý","Raspberry Pi OS ist veraltet","Raspberry Pi OS is out-of-date","El SO de la Raspberry Pi está desactualizado","Raspberry Pi OS est obsolète","Il sistema operativo di Raspberry Pi è obsoleto","Raspberry Pi OS가 최신 버전이 아닙니다.","","","System operacyjny Raspberry Pi jest nieaktualny",""]},"del-folder":["Opravdu chcete smazat {{folder_name}} a její obsah?","Möchten Sie wirklich {{folder_name}} und seinen Inhalt löschen?","Do you really want to delete {{folder_name}} and its contents?","¿Realmente deseas borrar {{folder_name}} y su contenido?","Voulez-vous vraiment supprimer {{folder_name}} et son contenu ?","Vuoi davvero cancellare la cartella {{folder_name}} e il suo contenuto?","정말 {{folder_name}} 폴더 및 내용을 지우시겠습니까?","","","Czy naprawdę chcesz usunąć {{folder_name}} i jego zawartość?",""],"del-proj":["Opravdu chcete smazat {{file_name}}?","Wollen Sie wirklich {{file_name}} löschen?","Do you really want to delete {{file_name}}?","¿Realmente deseas borrar {{file_name}}?","Voulez-vous vraiment supprimer le fichier {{file_name}} ?","Vuoi davvero eliminare il file {{file_name}}?","{{file_name}}을 정말로 삭제하겠습니까?","Ar tikrai norite ištrinti {{file_name}}?","Weet u zeker dat u {{file_name}} wilt verwijderen?","Czy na pewno chcesz usunąć plik {{file_name}}?","Naozaj chcete zmazať {{file_name}}?"],"sla-pour-resin":["Nalijte do vaničky dostatečné množství resinu pro vybraný soubor a zavřete víko. Minimální objem resinu je zobrazen na dotykovém displeji tiskárny.","Füllen Sie genügend Harz für die ausgewählte Datei in den Tank und schließen Sie den Deckel. Die minimale Menge des Harzes wird auf dem Touchscreen angezeigt.","Pour enough resin for selected file into the tank and close the lid. Minimal amount of the resin is displayed on the touchscreen.","Vierte suficiente resina para el archivo seleccionado y cierra la tapa.\\nLa mínima cantidad necesaria se muestra en la pantalla táctil.","Versez suffisamment de résine dans le réservoir pour le projet sélectionné et fermez le capot.","Versare nel serbatoio la quantità di resina sufficiente per il file selezionato e chiudere il coperchio. La quantità minima di resina viene visualizzata sul touchscreen.","선택한 파일을 위한 레진을 탱크에 붓고 뚜껑을 닫습니다. 터치스크린에 최소량의 레진이 표시됩니다.","Įpilkite pakankamai dervos pasirinktam failui ir uždarykite dangtį. Mažiausias dervos kiekis yra rodomas ant ekrano.","Doe genoeg resin voor het geselcteerde bestand in het vat en sluit de kap. De minimale hoeveelheid resin wordt aangegeven op het touchscreen.","Wlej do zbiornika wystarczającą ilość żywicy dla wybranego pliku i zamknij pokrywę. Minimalna ilość żywicy jest wyświetlana na ekranie dotykowym.","Do nádržky nalejte dostatočné množstvo resinu pre vybraný súbor a zatvorte veko. Minimálne množstvo resinu sa zobrazí na dotykovom displeji."],"cancel":["Chcete opravdu zrušit tisk?","Wollen Sie den Druck wirklich abbrechen?","Do you really want to cancel print?","¿Realmente quieres cancelar la impresión?","Voulez-vous vraiment annuler l\'impression ?","Vuoi davvero annullare la stampa?","출력을 취소하겠습니까?","Ar tikrai norite atšaukti spausdinimą?","Weet u zeker dat u de print wilt stoppen?","Czy na pewno chcesz anulować wydruk?","Chcete naozaj zrušiť tlač?"],"file-exists":{"title":["Soubor již existuje","Datei existiert bereits","File already exists","El archivo ya existe","Le fichier existe déjà","File già esistente","파일이 이미 존재합니다.","","","Plik już istnieje",""],"overwrite-it":["Chcete ho přepsat?","Möchten Sie sie überschreiben?","Do you want to overwrite it?","¿Quieres sobrescribirlo?","Voulez-vous l\'écraser ?","Vuoi sovrascriverlo?","덮어쓰시겠습니까?","","","Czy chcesz go nadpisać?",""]}},"btn":{"login":["Přihlášení","Login","Login","Iniciar sesión","Connexion","Accedi","로그인","Prisijungti","Login","Zaloguj","Prihlásenie"],"confirm":["Potvrdit","Bestätigen","Confirm","Confirmar","Confirmer","Conferma","결정","Patvirtinti","Bevestigen","Potwierdzam","Potvrdiť"],"cancel":["Zrušit","Abbrechen","Cancel","Cancelar","Annuler","Annulla","취소","Atšaukti","Annuleren","Anuluj","Zrušiť"],"del":["Smazat","löschen","Delete","borrar","supprimer","elimina","삭제","Ištrinti","Verwijderen","usuń","Zmazať"],"download":["Stáhnout","Herunterladen","Download","Descargar","télécharger","Download","다운로드","Atsisiųsti","Download","Pobierz","Stiahnuť"],"pause-pt":["Pozastavit tisk","Druck pausieren","Pause Print","Pausar Impresión","Mettre l\'impression en pause","Pausa Stampa","출력 일시 정지","Pristabdyti spausdinimą","Pauzeer print","Wstrzymaj drukowanie","Pozastaviť tlač"],"resume-pt":["Pokračovat v tisku","Druck fortsetzen","Resume Print","Continuar Impresión","Redémarrer l\'impression","Riprendi stampa","출력 복귀","Tęsti spausdinimą","Hervat print","Wznów drukowanie","Pokračovať v tlači"],"start-pt":["Start tisku","Druck starten","Start Print","Empezar impresión","Lancer l\'impression","Inizia stampa","출력 시작","Pradėti spausdinimą","Start print","Start druku","Štart tlače"],"stop-print":["Zrušit tisk","Druck abbrechen","Stop Print","Cancelar impresión","Annuler l\'impression","Annulla stampa","출력 정지","Sustabdyti spausdinimą","Stop print","Anuluj wydruk","Zrušiť tlač"],"upld-file":["Nahrát soubor","Datei hochladen","Upload File","Cargar Archivo","Télécharger un Fichier","Carica file","파일 업로드","Įkelti failą","Upload bestand","Prześlij plik","Nahrať súbor"],"disable-steppers":["Vypnout motory","Schrittmotoren deaktivieren","Disable Steppers","Desactivar motores","Desactiver les moteurs","Disabilita motori","모터 정지","Išjungti stoperius","Stappenmotors uitschakelen","Wyłącz silniki krokowe","Vypnúť motory"],"extrude":["Vytlačit","Extrudieren","Extrude","Extruye","extruder","Estrudi","압출","Ištraukti","Extrudeer","Ekstruzja","Vysunúť"],"retract":["Vtáhnout","Zurückziehen","Retract","Retraer","Retracter","Retrai","리트렉트","Atitraukti","Retract","Retrakcja","Zasunúť"],"set":["Nastavit","Setzen","Set","Ajuste","Régler","Imposta","설정","Nustatyti","Instellen","Ustaw","Nastaviť"],"check-updates":["Kontrola aktualizací","Updates suchen","Check Updates","Comprobar Actualizaciones","Vérifier les Mises à Jour","Controlla aggiornamenti","업데이트 체크","Tikrinti atnaujinimus","","Sprawdź aktualizacje",""],"connect":{"link":[null,"Verbinden","Link","Enlace","Associer","Collegamento","연결","Susieti","Verbinden","Link",""],"unlink":["Odpojit","Trennen","Unlink","Desenlazar","Dissocier","Scollegato","연결끊기","Atsieti","Verbinding verbreken","Odłącz",""]},"chg":["Změnit","Ändern","Change","Cambia","Mettre à jour","Cambia","변경","Pakeisti","Wijzig","Zmień","Zmeniť"],"reset":["","Reset","Reset","Reset","Réinitialiser","Reset","리셋","Perkrauti","Reset","Reset",""],"yes":["Ano","Ja","Yes","Sí","Oui","Sì","예","Taip","Ja","Tak","Áno"],"no":["Ne","Nein","No","No","Non","No","아니오","Ne","Nee","Nie","Nie"],"upgrade":["Aktualizovat","Aktualisieren","Upgrade","Actualización","Mise à niveau","Aggiorna","업그레이드","Atnaujinti","","Aktualizacja",""],"chg-print-set":["Tisková nastavení","Druckeinstellungen","Print Settings","Ajustes de la Impresora","Réglages d\'Impression","Impostazioni di stampa","프린트 설정","Spausdinimo nustatymai","Printinstellingen","Ustawienia druku",""],"save-chgs":["Uložit změny","Änderungen speichern","Save changes","Guardar cambios","Enregistrer les modifications","Salva le modifiche","변경내용 저장","Išsaugoti pakeitimus","Wijzigingen opslaan","Zapisz zmiany","Uložiť zmeny"]},"camera":{"settings":["Nastavení kamery","Kamera-Einstellungen","Camera Settings","Ajustes de la Cámara","Paramètres de la Caméra","Impostazioni Fotocamera","카메라 세팅","Kameros nustatymai","Camera-instellingen","Ustawienia kamery",""],"name":["Název kamery","Kameraname","Camera Name","Nombre de la Cámara","Nom de la Caméra","Nome Fotocamera","카메라 명칭","Kameros pavadinimas","Cameranaam","Nazwa kamery",""],"resolution":["Rozlišení","Auflösung","Resolution","Resolución","Résolution","Risoluzione","해상도","Raiška","Resolutie","Rozdzielczość",""],"trigger-scheme":["","Auslöse-Schema","Trigger Scheme","Modo de Disparo","Mode de Déclenchement","Schema di attivazione","트리거 계획","Paleisti schemą","Triggerschema","Schemat wyzwalania",""],"focus":["Zaostření","Fokus","Focus","Enfocar","Focus","Messa a fuoco","포커스","","","Ostrość",""],"title":["Kamery","Kameras","Cameras","Cámaras","Caméras","Fotocamere","카메라","Kameros","Camera\'s","Kamery",""],"time":["Čas snímku","Zeit der Momentaufnahme","Snapshot Time","Hora de la Foto","Heure de la Capture","Tempo Istantanea","스냅샷 시간","Momentinis vaizdas","Snapshot-tijd","Czas migawki",""],"cloud":{"linked":["Připojeno","Verbunden","Linked","Enlazado","Associée","Collegato","연결됨","Susieta","Verbonden","Połączono",""],"not-linked":["Nepřipojeno","Unverbunden","Not Linked","No Enlazado","Non Associée","Non collegato","연결되지 않음","Nesusieta","Niet verbonden","Nie połączono",""],"label":["","CONNECT","CONNECT","CONNECT","CONNECT","CONNECT","CONNECT","CONNECT","CONNECT","CONNECT",""]},"btn":{"connect":["Zkusit povolit kameru","Versuche Kamera zu aktivieren","Try to enable camera","Intenta habilitar la cámara","Essayer d\'activer la caméra","Prova ad abilitare la fotocamera","카메라 활성화 시도중","Pabandykite įjungti kamerą","Probeer de camera in te schakelen","Spróbuj aktywować kamerę",""],"settings":["Otevřít nastavení kamery","Öffne Kamera-Einstellungen","Open camera settings","Abrir los ajustes de la cámara","Ouvrir les paramètres de la caméra","Apri impostazioni fotocamera","카메라 설정 열기","Atidaryti kameros nustatymus","Open camera-instellingen","Otwórz ustawienia kamery",""],"link":["Připojit kameru do Connectu","Kamera mit CONNECT verbinden","Link camera to CONNECT","Asocia cámara a CONNECT","Associer la caméra à CONNECT","Collega fotocamera a CONNECT","카메라를 CONNECT에 연결하기","Susieti kamerą su CONNECT","Verbind camera met CONNECT","Połącz kamerę z CONNECT",""],"unlink":["Odpojit kameru z Connectu","Kamera von CONNECT trennen","Unlink camera from CONNECT","Desvincular la cámara de CONNECT","Dissocier la caméra de CONNECT","Scollega fotocamera da CONNECT","카메라를 CONNECT에서 연결 해제하기","Atsieti kamerą nuo CONNECT","Verbreek verbinding van camera met CONNECT","Odłącz kamerę od CONNECT",""]},"path":["Cesta","Pfad","Path","Ruta","Chemin","Percorso","경로","Kelias","Pad","Ścieżka",""],"driver":["Ovladač","Treiber","Driver","Controlador","Driver","Driver","드라이버","Draiveris","Driver","Sterownik",""]},"printer":{"title":["Tiskárna","Drucker","Printer","Impresora","imprimante","Stampante","프린터","Spausdintuvas","Printer","Drukarka","Tlačiareň"],"name":["Název tiskárny","Name des Druckers","Printer name","Nombre de la impresora","nom de l\'imprimante","Nome stampante","프린터 이름","Spausdintuvo pavadinimas","Naam van de printer","Nazwa drukarki","Názov tlačiarne"],"location":["Umístění tiskárny","Standort des Druckers","Printer location","Ubicación de la impresora","localisation de l\'imprimante","Posizione stampante","프린터 위치","Spausdintuvo vieta","Locatie van de printer","Lokalizacja drukarki","Umiestnenie tlačiarne"],"network_error_chime":["Zvukové upozornění při síťové chybě","Netzwerk-Fehlerton","Network Error Chime","Tono Error Red","Carillon d\'erreur réseau","Suono di errore di rete","네트워크 에러 차임벨","","","Dźwięk błędu sieci",""]},"print":{"fdm":{"1":["Je tiskárna připravena?","Ist der Drucker bereit?","Is the printer ready?","¿Está lista la impresora?","L\'imprimante est-elle prête ?","La stampante è pronta?","프린터가 준비되었습니까?","Ar spausdintuvas pasirengęs?","Is de printer klaar voor gebruik?","Czy drukarka jest gotowa?","Je tlačiareň pripravená?"],"2":["Je tisková podložka prázdná a čistá?","Ist der Druckbogen leer und sauber?","Is printing sheet empty and clean?","¿Está vacía y limpia la lámina de impresión?","Le surface d\'impression est-elle vide et propre ?","La piastra di stampa è vuota e pulita?","프린트 시트가 비어있고 깨끗합니까?","Ar spausdinimo lapas tuščias ir švarus?","Is het printplatform leeg en schoon?","Czy arkusz druku jest pusty i czysty?","Je tlačová podložka prázdna a čistá?"]}},"upld":{"title":["Nahrát soubor","Projekt hochladen","Upload file","envía proyecto","Téléversement de projet","Carica file","파일업로드","Įkelti failą","Bestand uploaden","prześlij projekt","Nahrať súbor"],"start-pt":["po přenosu zahájit tisk","Druck nach der Übertragung starten","Start print after transfer","Iniciar la impresión tras la transferencia","Démarrer l\'impression après le transfert","Avvia stampa dopo il trasferimento","전송 후 출력 시작","Pradėti spausdinimą po perdavimo","Start print na het omzetten","Rozpocznij drukowanie po przesłaniu","po prenose spustiť tlač"],"remote":{"source":["URL zdroje","Quell-URL","Source URL","URL Fuente","URL de la source","URL fonte","원본 URL","Šaltinio URL","Bron van URL","Źródłowy URL","URL zdroja"],"hint-fdm":["Zadejte URL souboru G-CODE","URL der G-CODE-Datei eingeben","Type URL of G-CODE file","Escribe URL de archivo G-CODE","Entrez l\'URL du fichier G-CODE du projet","Digitare URL del file G-CODE","G코드 파일의 주소를 입력하십시오","Įveskite G-CODE failo URL","Type de URL van het gcode-bestand","Wpisz adres URL pliku G-Code","Zadajte URL súboru G-code"],"file":["Název souboru","Dateiname","File name","Nombre del archivo","Nom de fichier","Nome file","파일명","Failo pavadinimas","Bestandsnaam","Nazwa pliku","Názov súboru"],"file-hint":["Zadejte název souboru","Dateiname eingeben","Type file name","Escribe nombre de archivo","Tapez le nom du fichier","Digitare nome file","파일명을 입력하십시오","Įveskite failo pavadinimą","Type de bestandsnaam","Wpisz nazwę pliku","Zadajte názov súboru"]},"direct":{"choose":["Vyberte {{file}} nebo jej přetáhněte sem.","Klicke um eine {{file}} Datei auszuwählen oder ziehe sie hier hin","Click to choose a {{file}} file or drag it here","Haz clic para elegir un archivo {{file}} o arrástralo hasta aquí","Cliquer pour choisir un fichier {{file}} ou déposez le ici","Fare clic per scegliere un file {{file}} o trascinarlo qui","선택한 {{file}} 파일을 클릭하거나 여기로 드래그하십시오","Pasirinkite {{file}} failą arba vilkite jį čia","Klik om een {{file}}-bestand te kiezen of sleep het hierheen","Kliknij, aby wybrać plik {{file}} lub przeciągnij go tutaj","Kliknutím vyberte súbor {{file}} alebo ho sem potiahnite."]}},"download":{"dl-started":["Začátek stahování","Herunterladen Gestartet","Download Started","Se inició la descarga","téléchargement démarré","Scaricamento Avviato","다운로드가 시작됨","Atsisiuntimas pradėtas","Downloaden gestart","Pobieranie rozpoczęte","Sťahovanie začalo"],"start-pt":["Spustit tisk","Autostart","Autostart","Inicio automático","Démarrage automatique","Avvio automatico","자동시작","Automatinis paleidimas","Autostart","Autostart","Spustiť tlač"]},"temps":{"title":["Teploty","Temperaturen","Temperatures","Temperaturas","Températures","Temperature","온도","Temperatūros","Temperatuur","Temperatury","Teploty"]},"":[null,null,null,null,null,null,null,null,null,null,null],"version":{"title":["Verze","Version","Version",null,"version",null,"버전","Versija","Versie",null,"Verzia"],"api":["API","API","API",null,"api",null,"API","API","API",null,"API"],"hostname":["Hostname","Hostname","Hostname",null,"nom d\'hôte",null,"호스트명","Hostname","Hostnaam",null,"Hostname"],"firmware":["Firmware","Firmware","Firmware",null,"micrologiciel",null,"펌웨어","Firmware","Firmware",null,"Firmware"],"server":["Server","Server","Server",null,"serveur",null,"서버","Serveris","Server",null,"Server"],"text":["Text","Text","Text",null,"texte",null,"텍스트","Tekstas","Tekst",null,"Text"],"sdk":["SDK","SDK","SDK",null,"sdk",null,"SDK","SDK","SDK",null,"SDK"],"fe":["Frontend","Frontend","Frontend",null,"frontend",null,"프론트엔드","Frontendas","Frontend",null,"Frontend"]},"sys-version":{"title":["Verze systému","Systemversion","System Version",null,"version du système",null,"시스템버전","Sistemos versija","Systeenversie",null,"Verzia systému"],"python":["Python","Python","Python","Python","python","Python","파이썬","Python","Python","Python","Python"],"description":["Popis","Beschreibung","Description","Descripción","description","Descrizione","설명","Aprašymas","Beschrijving","Opis","Popis"],"id":["ID","ID","ID","ID","ID","ID","ID","ID","ID","ID","ID"],"os":["OS","OS","OS","OS","Système d\'exploitation","S.O.","OS","OS","OS","OS","OS"]},"updates":{"title":[null,null,"System Updates",null,null,null,null,"Sistemos atnaujinimai",null,null,null]},"user":{"title":["Uživatel","Benutzer","User",null,"utilisateur",null,"유저","Vartotojas","Gebruiker",null,"Používateľ"],"username":["Uživatelské jméno","Benutzername","User Name","Nombre de usuario","nom d\'utilisateur","Nome Utente","유저명","Vartotojo vardas","Gebruikersnaam","Nazwa użytkownika","Používateľské meno"],"format":{"name":["Uživatelské jméno musí obsahovat alespoň 3 znaky.","Der Benutzername muss mindestens 3 Zeichen lang sein.","Username length must be at least 3 characters long.","El nombre de usuario debe tener al menos 3 caracteres.","La longueur du nom d\'utilisateur doit être d\'au moins 3 caractères.","La lunghezza del nome utente deve essere di almeno 3 caratteri.","유저명은 최소 3자리 이상 입력하십시오.","Vartotojo vardo ilgis turi būti mažiausiai 3 simboliai.","De gebruikersnaam moet minimaal 3 karakters lang zijn.","Długość nazwy użytkownika musi mieć co najmniej 3 znaki.","Používateľské meno musí obsahovať aspoň 3 znaky."],"password-1":["Heslo nesmí začínat či končit mezerami, a zároveň musí splňovat jednu z následujících podmínek:","Das Passwort darf weder am Anfang noch am Ende Leerzeichen enthalten und muss mindestens eine der folgenden Optionen erfüllen:","Password can\'t contain spaces on the beggining nor the end and must meet at least one of these options:","La Contraseña no puede empezar o terminar con espacios y debe cumplir al menos una de estas opciones:","Le mot de passe ne peut pas contenir d\'espaces au début ni à la fin et doit répondre à au moins l\'une de ces options :","La password non può contenere spazi né all\'inizio né alla fine e deve soddisfare almeno una di queste opzioni:","암호는 시작부분에 공백을 둘 수 없으며 다음옵션 중 하나이상을 충족해야 합니다.","Slaptažodis negali turėti tarpų pradžioje arba pabaigoje ir turi atitikti bent vieną iš šių variantų:","Het wachtwoord mag geen spaties aan het begin of het einde bevatten en moet aan minstens één van deze opties voldoen:","Hasło nie może zawierać spacji na początku ani na końcu i musi spełniać przynajmniej jeden z poniższych warunków:","Heslo nesmie začínať ani končiť medzerami a musí spĺňať aspoň jednu z týchto podmienok:"],"password-2":["- Heslo musí obsahovat minimálně 8 znaků, zahrnujících alespoň 1 malé písmeno, 1 velké písmeno a 1 číslo","- Minimale Länge 8 Zeichen, davon ein Kleinbuchstabe, ein Großbuchstabe und eine Zahl","- Minimal length 8 characters, including one lowercase letter, one uppercase letter and one number","- Longitud mínima 8 caracteres, incluyendo una minúscula, una mayúscula y un número","- Longueur minimale de 8 caractères, dont une lettre minuscule, une lettre majuscule et un chiffre","- Lunghezza minima 8 caratteri, di cui una lettera minuscola, una lettera maiuscola e un numero","- 최소 길이 8자(소문자 1개, 대문자 1개, 숫자 1개 포함)","- 8 simbolių ilgio minimalus, įskaitant vieną mažąją raidę, vieną didžiąją raidę ir vieną skaičių","- Minimaal 8 karakters lang, met tenminste een kleine letter, een hoofdletter en een nummber","- Minimalna długość 8 znaków, w tym jedna mała litera, jedna duża litera i jedna cyfra","- Heslo musí obsahovať minimálne 8 znakov, vrátane aspoň 1 malého písmena, 1 veľkého písmena a 1 číslice"],"password-3":["- Heslo musí obsahovat minimálně 8 znaků, zahrnujících alespoň 1 speciální symbol (např. @)","- Mindestlänge 8 Zeichen, einschließlich eines nicht alphanumerischen Zeichens (z. B. @)","- Minimal length 8 characters, including one non-alphanumeric character (e.g. @)","- Longitud mínima 8 caracteres, incluyendo los no alfanuméricos (como @)","- Longueur minimale de 8 caractères, dont un caractère non alphanumérique (par exemple @)","- Lunghezza minima 8 caratteri, compreso un carattere non alfanumerico (es. @)","- 영숫자가 아닌 문자(예: @) 1개를 포함한 최소 길이 8자","- 8 simbolių ilgio minimalus, įskaitant vieną neteisingą simbolį (pvz., @)","- Minimaal 8 karakters lang, met tenminste één niet-alfanumeriek karakter (bijv. @)","- Minimalna długość 8 znaków, w tym jeden znak niealfanumeryczny (np. @)","- Heslo musí obsahovať minimálne 8 znakov, vrátane aspoň 1 špeciálneho symbolu (napr. @)"],"password-4":["- Heslo musí obsahovat minimálně 15 znaků","- Minimale Länge 15 Zeichen","- Minimal length 15 characters","- Longitud mínima 15 caracteres","- Longueur minimale 15 caractères","- Lunghezza minima 15 caratteri","- 최소 길이 15자","- 15 simbolių ilgio minimalus","- Minimaal 15 karakters lang","- Minimalna długość 15 znaków","- Heslo musí obsahovať minimálne 15 znakov"]},"new-password":["Nové heslo","Neues Passwort","New Password","Nueva Contraseña","nouveau mot de passe","Nuova Password","신규 암호","Naujas slaptažodis","Nieuw wachtwoord","Nowe hasło","Nové heslo"],"re-password":["Zopakujte heslo","Passwort wiederholen","Repeat Password","Repetir Contraseña","répéter le mot de passe","Ripeti Password","암호 재입력","Pakartokite slaptažodį","Herhaal wachtwoord","Powtórz hasło","Zopakujte heslo"],"password":["Heslo","Passwort","Password","Contraseña","mot de passe","Password","암호","Slaptažodis","Wachtwoord","Hasło","Heslo"]},"serial":{"label":["Sériové číslo","Seriennummer","Serial Number","Número de serie","numéro de série","Numero Seriale","시리얼넘버","Serijinis numeris","Serienummer","Numer seryjny","Sériové číslo"]},"api_key":{"label":["","API Key","API Key","Clave API","Clé API","Chiave API","API KEY","API raktas","API-sleutel","Klucz API",""]},"logs":{"title":["Logy","Protokolle","Logs",null,"logs",null,"로그","Žurnalai","Logbestanden",null,"Logy"],"select-file":["Vyberte soubor","Datei auswählen","Select File",null,"Sélectionnez une fichier",null,"파일 선택","Pasirinkti failą","Selecteer bestand",null,"Vyberte súbor"],"not-selected":["Není vybrán soubor logů!","Es ist keine Protokolldatei ausgewählt!","No log file is selected!","¡No se ha seleccionado archivo de registro!","Pas de fichier de log sélectionné!","Nessun file di registro selezionato!","로그 파일이 선택되지 않았습니다!","Nepasirinktas joks žurnalų failas!","Geen logbestand geselecteerd!","Nie wybrano żadnego pliku logu!","Nie je vybraný žiadny logovací súbor!"],"select-file-placeholder":["Vyberte soubor logů","Protokolldatei auswählen","Select log file","Selecciona archivo de registro","selectionnez un fichier de log","Selezionare un file di registro","로그 파일 선택","Pasirinkti žurnalų failą","Selecteer logbestand","Wybierz plik logu","Vyberte logovací súbor"],"empty-file":["Soubor je prázdný!","Die Protokolldatei ist leer!","Log file is empty!","¡El archivo de registro está vacío!","Le fichier de log est vide!","Il file di registro è vuoto!","로그파일이 비어있음!","Žurnalų failas yra tuščias!","Logbestand is leeg!","Plik logu jest pusty!","Logovací súbor je prázdny!"],"file-size-unknown":["","Die Protokolldatei hat eine unbekannte Größe und ist daher nur zum Herunterladen verfügbar","The log file is of an unknown size and therefore is available only for downloading","El archivo de registro es de tamaño desconocido y, por lo tanto, solo está disponible para su descarga.","Le fichier de journal est d\'une taille inconnue et n\'est donc disponible que pour le téléchargement","Il file di log è di dimensioni sconosciute e quindi è disponibile solo per il download.","로그파일의 크기를 알 수 없으므로 다운로드만 가능합니다.","","","Plik logu ma nieznany rozmiar i dlatego jest dostępny tylko do pobrania",""],"file-too-large":["Soubory logů o velikosti větší než {{size}} jsou k dispozici pouze ke stažení!","Protokolldateien, die größer als {{size}} sind, stehen nur zum Herunterladen zur Verfügung.","Log files of size larger than {{size}} are available only for downloading.","Los archivos de registro mayores de {{size}} sólo se pueden descargar.","Les fichiers de log d\'une taille supérieure à {{size}} ne sont disponibles que pour le téléchargement.","I file di registro di dimensioni superiori a {{size}} sono disponibili solo per il download.","로그 파일을 다운로드 하기 위해서는 최소 {{size}} 이상의 용량이 필요합니다.","Žurnalų failai, didesni nei {{size}}, yra prieinami tik atsiunčiant.","Logbestanden groter dan {{size}} kunnen alleen gedownload worden.","Pliki logów o rozmiarze większym niż {{size}} są dostępne tylko do pobrania.","Logovacie súbory s veľkosťou väčšou ako {{size}} sú k dispozícii len na stiahnutie.!"]},"ntf":{"success":["Požadavek byl úspěšný","Erfolg","The request was successful","Éxito","la demande a été traitée avec succès","Successo","요청이 성공했습니다.","Užklausa buvo sėkminga","De aanvraag was succesvol","Sukces","Žiadosť bola úspešná"],"settings-suc":["Nastavení bylo úspěšně změněno.","Die Einstellungen wurden erfolgreich geändert.","Settings was changed successfully.","Los ajustes se han cambiado correctamente.","Paramètres modifiés avec succès.","Impostazioni modificate correttamente","설정이 성공적으로 변경되었습니다.","Nustatymai sėkmingai pakeisti.","Instelling succesvol gewijzigd.","Ustawienia zostały zmienione pomyślnie","Nastavenia boli úspešne zmenené."],"camera-suc":["","Kamera Steuerungsanfrage wurde gesendet","Camera control request has been sent","La petición de control de cámara se ha enviado","La demande de contrôle de la caméra a été envoyée","È stata inviata una richiesta di controllo della fotocamera","카메라 조작 요청이 전송되었습니다.","Kameros valdymo užklausa buvo išsiųsta","Aanvraag voor camerabesturing is verzonden","Wysłano żądanie sterowania kamerą",""],"camera-config-success":["Konfigurace kamery byla aktualizovaná","Kamerakonfiguration wurde aktualisiert","Camera configuration has been updated","La configuración de la cámara se ha actualizado","La configuration de la caméra a été mise à jour","La configurazione della fotocamera è stata aggiornata","카메라 구성이 업데이트되었습니다.","Kameros konfigūracija buvo atnaujinta","Cameraconfiguration is geüpdatet","Konfiguracja kamery została zaktualizowana",""],"calibration-error":["","Kalibrierungsfehler","Calibration Error","Error de calibración","Erreur de Calibration","Errore di calibrazione","캘리브레이션 에러","","","Błąd kalibracji",""],"n-calibrated":["Tiskárna není zkalibrovaná!","Der Drucker ist nicht kalibriert!","Printer is not calibrated!","¡Impresora no calibrada!","L\'imprimante n\'est pas calibrée !","La stampante non è calibrata!","프린터가 보정되지 않았습니다!","Spausdintuvas nėra sukalibruotas!","Printer niet gekalibreerd!","Drukarka nie jest skalibrowana!","Tlačiareň nie je kalibrovaná!"],"low-resin":{"title":["","Harz niedrig","Resin low","Poca resina","Niveau de résine bas","Resina bassa","레진 부족함","","","Niski poziom żywicy",""],"message":["","Die gemessene Harzmenge ist zu gering. Der Druck kann fortgesetzt werden, es kann jedoch ein Nachfüllen erforderlich sein.","Measured resin volume is too low. The print can continue, however, a refill might be required.","El volumen de resina medido es demasiado bajo. La impresión puede continuar, sin embargo, es posible que se requiera una recarga.","Le volume de résine mesuré est trop bas. L\'impression peut continuer, mais un remplissage pourra être nécessaire.","Il volume di resina misurato è troppo basso. La stampa può continuare, ma potrebbe essere necessaria una ricarica.","측정된 레진양이 적습니다. 인쇄는 계속할 수 있지만 리필이 필요할 수는 있습니다.","","","Zmierzona ilość żywicy jest zbyt niska. Drukowanie można kontynuować, jednak konieczne może być uzupełnienie w trakcie.",""]},"upld-suc":["Soubor {{file_name}} byl úspěšně nahrán.","Das Hochladen des Projekts {{file_name}} war erfolgreich.","The file {{file_name}} was uploaded successfully.","El envío del proyecto {{file_name}} se completó.","Le projet {{file_name}} a été téléversé correctement.","Caricamento del progetto {{file_name}} riuscito.","{{file_name}}파일이 성공적으로 업로드되었습니다.","Failas {{file_name}} sėkmingai įkeltas.","Het bestand {{file_name}} is succesvol geüpload.","Przesyłanie projektu {{file_name}} powiodło się.","Súbor {{file_name}} bol úspešne nahraný."],"error":["Chyba","Fehler","Error","Error","Erreur","Errore","에러","Klaida","Fout","Błąd","Chyba"],"upld-unsuc":["Nahrání souboru {{file_name}} selhalo.","Das Hochladen des Projekts {{file_name}} war nicht erfolgreich.","The file {{file_name}} upload was unsuccessful.","El envío del proyecto {{file_name}} ha fallado.","Le téléversement du projet {{file_name}} a échoué.","Caricamento del progetto {{file_name}} non riuscito.","{{file_name}}파일의 업로드가 실패하였습니다.","Failo {{file_name}} įkėlimas nepavyko.","Het bestand {{file_name}} is niet succesvol geüpload.","Przesyłanie projektu {{file_name}} nie powiodło się.","Nahrávanie súboru {{file_name}} zlyhalo."],"upld-start":["Požadavek na nahrání souboru byl přijat","Upload-Anfrage wurde angenommen","Upload request has been accepted","Se acepto la petición de subida","La demande de téléchargement a été acceptée","Richiesta di caricamento accettata","업로드 요청이 수락되었습니다.","Įkėlimo užklausa priimta","Uploadverzoek is geaccepteerd","Żądanie przesłania zostało zaakceptowane","Žiadosť o odoslanie bola prijatá"]},"sort":{"by-name":["Název","Name","Name","Nombre","Nom","Nome","이름으로 정렬하기","Pavadinimas","Naam","Nazwa",""],"by-date":["Datum","Datum","Date","Fecha","Date","Data","날짜로 정렬하기","Data","Datum","Data",""],"by-size":["Velikost","Größe","Size","Tamaño","Taille","Dimensioni","크기로 정렬하기","Dydis","Grootte","Rozmiar",""]},"unit":{"h":["h","h","h","h","h","h","h","h","u","g","h"],"min":["min","min","min","min","min","min","min","min","min","min","min"],"ml":["ml","ml","ml","ml","mL","ml","ml","ml","ml","ml","ml"],"rpm":["RPM","UPM","RPM","RPM","TPM","RPM","RPM","RPM","RPM","Obr./min","RPM"],"b":["B","B","B","B","B","B","B","B","B","B","B"],"kb":["KB","KB","KB","KB","KB","KB","KB","KB","kB","KB","KB"],"mb":["MB","MB","MB","MB","MB","MB","MB","MB","MB","MB","MB"],"gb":["GB","GB","GB","GB","GB","GB","GB","GB","GB","GB","GB"]},"exp-times":{"exp-time":["Doba osvitu [s]","Belichtung [s]","Exposure [s]","Exposición [s]","Exposition [s]","Esposizione [s]","[s]초 노출","Ekspozicija [s]","Belichting [s]","Czas naświetlania [s]","Osvit [s]"],"inc":["Navýšení expozice (s)","Belichtungszeit inkr. [s]","Exposure time incr. [s]","Incremento tiempo exp. [s]","Incr. du temps d\'exposition [s]","Incremento tempo di esposizione [s]","[s]초 노출 시간 증가","Ekspozicijos laiko padidėjimas [s]","Belichtingstijd verhogen [s]","Przyrost czasu naświetl. [s]","Prírastok osvitu (s)"],"layer-1st":["Osvit první vrstvy [s]","Erste Schicht Bel. [s]","First Layer Expo. [s]","Primera Capa Expo. [s]","Première couche Expo. [s]","Esposizione Primo layer [s]","첫번째 레이어 노출시간 [s]초","Pirmojo sluoksnio ekspozicija [s]","Belichtingstijd eerste laag [s]","Naśw. 1. warstwy [s]","Osvit prvej vrstvy [s]"],"profile":["Tiskový profil","Druckprofil","Print profile","Perfil de Impresión","Profil d\'Impression","Profilo di stampa","출력 프로필","Spausdinimo profilis","Printprofielen","Profil druku","Tlačový profil"],"faster":["Rychlejší","Schneller","Faster","Rápido","Plus rapide","Più veloce","빠르게","Greičiau","Sneller","Szybciej","Rýchlejšie"],"slower":["Pomalejší","Langsamer","Slower","Lento","Plus lent","Più lento","느리게","Lėčiau","Langzamer","Wolniej","Pomalšie"],"high-viscosity":["Velmi Viskózní","Hohe Viskosität","High Viscosity","Alta Viscosidad","Haute viscosité","Alta Viscosità","고점도","Aukštas klampumas","Hoge viscositeit","Wysoka lepkość","Vysoká viskozita"]}}}')}},t={};function a(i){var s=t[i];if(void 0!==s)return s.exports;var n=t[i]={exports:{}};return e[i](n,n.exports,a),n.exports}a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var i in t)a.o(t,i)&&!a.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.p="",(()=>{"use strict";var e=a(8751),t=a(3532),i=a(6648);const s=()=>{document.getElementById("menu").addEventListener("click",(()=>{document.getElementById("menu").classList.contains("burger-open")?n():(document.getElementById("menu").classList.add("burger-open"),document.getElementById("navbar").classList.remove("burger-menu"))})),document.getElementById("navbar").querySelectorAll("a[href]").forEach((e=>{e.addEventListener("click",n)}))};function n(){document.getElementById("menu").classList.remove("burger-open"),document.getElementById("navbar").classList.add("burger-menu")}var r=a(7780),o=a(1351),l=a(5412);const d=(e,t)=>{const a=l.L.init(e,t);a&&(a.setOptions((0,r.Vb)()),a.value=(0,r.G3)(),a.onselect=e=>{(0,r.m0)(e),window.location.reload()})};let c=!1;const u={status:{get:()=>(0,i.LK)("/api/v1/status"),init:!0,update:!0}};async function p(e){const t=(new Date).getTime(),a=Object.fromEntries(Object.entries(u).map((([a,i])=>[a,(()=>{if(!e)return i.init;if(i.update){if(!i.updateInterval)return!0;if(i.timestamp||(i.timestamp=t+i.updateInterval),t>=i.timestamp)return i.timestamp=t+i.updateInterval,!0}})()?i.get():void 0])).filter((([,e])=>void 0!==e))),i=Object.values(a),s=await Promise.all(i.map((e=>e.then((e=>({ok:!0,payload:e}))).catch((e=>({ok:!e.code&&null,error:e}))))));return Object.fromEntries(Object.entries(a).map((([e],t)=>[e,s[t]])))}async function m(e){let a=!1;for(;;){let i=!1;try{const t=await p(a);t.status&&(c=null===t.status.ok),Object.values(t).forEach((({ok:e,error:t})=>{var a;e||(i=!0,null!==e&&(a=t,(0,o.S)(a,{fallbackMessage:{title:"API error",message:"Cannot connect to printer"}})))})),a?g(t):i||(v({...t,printer:e}),a=!0)}catch(e){h(e)}t.Z.setConnected(!c),await new Promise((e=>setTimeout(e,1e3)))}}function v(a){try{t.Z.init(a),window.onpopstate=t=>t&&(0,e.g9)(t.currentTarget.location.hash||"#dashboard"),(0,e.g9)(window.location.hash||"#dashboard")}catch(e){h(e)}}function g(e){try{t.Z.update(e)}catch(e){h(e)}}function h(e){(0,o.S)(e,{fallbackMessage:{title:"Application error",message:"Something bad happened on application side"}}),console.error(e)}window.onload=()=>{console.log("PrusaLink v.3.12.0 #b'b2c9fe5'"),s(),d("lang-dropdown","lang-dropdown"),(0,r.ot)(),document.querySelectorAll("a[href]").forEach((t=>{t.addEventListener("click",(a=>{(0,e.c4)(t.href)&&a.preventDefault()}))})),(0,i.Z5)().then((e=>{e&&m(e)}))}})()})(); ================================================ FILE: prusa/link/static/main.b3e029296dd89863b3f2.css ================================================ @-webkit-keyframes zoomOut{0%{opacity:1}50%{opacity:0;transform:scale3d(.3,.3,.3)}to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{opacity:0;transform:scale3d(.3,.3,.3)}to{opacity:0}}@-webkit-keyframes slideInUp{0%{transform:translate3d(0,100%,0);visibility:visible}to{transform:translate3d(0,0,0)}}@keyframes slideInUp{0%{transform:translate3d(0,100%,0);visibility:visible}to{transform:translate3d(0,0,0)}}html{font-size:32pt;font-family:Helvetica,sans-serif;overflow:-moz-scrollbars-vertical;overflow-y:scroll}@media only screen and (min-width:992px){html{font-size:14pt}}select{font-family:Helvetica,sans-serif}.txt-bold,select{font-weight:700}.txt-italic{font-style:italic}.uppercase{text-transform:uppercase}.txt-orange{color:#fa6831}.txt-grey{color:#707070}.txt-black{color:#000!important}.txt-lg{font-size:1.25rem}.txt-md,button>p,nav{font-size:1rem}.txt-sm{font-size:.75rem}.txt-center{text-align:center}button>p{font-family:Helvetica,sans-serif;font-weight:700;width:100%;margin:6px}.flex-row{display:flex}.flex-col,.tel-prop>div{display:flex;flex-direction:column}.grow{flex-grow:1}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.mr-md{margin-right:12px}.ml-md{margin-left:12px}.my-md{margin-top:12px;margin-bottom:12px}.rounded{border-radius:50%}button.rounded{padding:0;margin:0}button.rounded img{padding:4px;margin:0}button.rounded:hover{background-color:#5b5b5b}button,html,nav>ul a,select{color:#efefef}#upld-direct p,.progress>p,nav>ul a{text-align:left}html{line-height:1.5;-webkit-text-size-adjust:100%}.node-btn-list>li .icon-small,body,p{margin:0}body,body>.header{background-color:#0a0a0a}a,button.rounded{background-color:transparent}a:not([href]){cursor:pointer}a:not([href]):hover{text-decoration:underline}img{border-style:none;height:auto;width:100%;max-width:100%}[hidden]{display:none!important}.icon{width:48px;height:48px;min-width:48px}.icon-small{width:32px;height:32px;min-width:32px;margin-right:4px}button>img{width:36px;height:36px;min-width:36px;margin:2px}button,input{font-family:inherit;font-size:100%;line-height:1.15;margin:0;overflow:visible}button::-moz-focus-inner{border-style:none;padding:0}.header{position:relative}.header__line{position:absolute;bottom:0;width:100%;height:2px;background-color:#707070;z-index:4}#title,.component>p,.line,.line-y{border-bottom:1px solid #bababa;border-radius:0}.component p.title{padding-bottom:.25rem;line-height:1.5;margin-bottom:15px}.line-y{border-top:1px solid #bababa}button:-moz-focusring{outline:ButtonText dotted 1px}progress{vertical-align:baseline}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.header{padding:32px 0}.node:hover,.upld:hover{background-color:#313131;cursor:pointer}.content{display:block;margin:0 auto;max-width:1280px;padding:0 40px}body>.content{margin-top:16px}.component>p{display:flex;margin:0}.tooltip-handle{position:relative;cursor:pointer;border:0;background-color:transparent;margin:0;padding:0}.tooltip-handle span{visibility:hidden;min-width:120px;max-width:200px;background-color:#0a0a0a;color:#fff;opacity:.9;text-align:center;padding:16px;border-radius:6px;position:absolute;z-index:1;right:8px;top:8px;border:1px solid #fa6831}.tooltip-handle--active span,.tooltip-handle:hover span{visibility:visible}nav{display:flex;flex-wrap:wrap;min-width:685px;align-items:stretch}nav .logo-wrapper{min-width:240px;display:flex;align-self:flex-start;padding:4px 0 16px;flex-grow:1}nav .brand-logo,nav>ul a{display:flex;align-items:center}nav .brand-logo{order:1;flex-grow:1;justify-content:center}nav .brand-logo img{max-width:75%}nav .navbar-burger,nav>ul{display:flex;flex-direction:column}nav>ul{order:3;align-items:stretch;flex-grow:1;width:100%;margin:0;padding:32px 0}nav>ul>li{display:flex;list-style-type:none}nav>ul>li.active{border-bottom:2px solid #fa6831}nav>ul a{list-style:none;text-decoration:none;width:100%;padding:32px}nav .navbar-burger{order:0;align-items:flex-end;align-self:flex-end;margin-bottom:auto;margin-top:auto}nav .navbar-burger div{transition-duration:.5s;background-color:#efefef;width:100px;height:12px;margin:11px 0;display:flex;opacity:1}.burger-menu{display:none;white-space:nowrap}nav .navbar-burger.burger-open div:first-child{transform:rotateZ(45deg) scaleX(1.25) translate(19px,31px)}nav .navbar-burger.burger-open div:nth-child(2){transition-property:opacity;opacity:0}nav .navbar-burger.burger-open div:last-child{transform:rotateZ(-45deg) scaleX(1.25) translate(13px,-24px)}#lang-dropdown{order:2;align-items:center;display:flex;text-transform:uppercase}.telemetry{display:block;padding:0;position:sticky;position:-webkit-sticky;top:78px}.tel-value span:not(:first-child)::before{content:' / '}.tel-prop{display:flex;align-items:center;padding:12px 0;width:45%}#upld-remote .upld-details>div,.tel-prop>img{margin-right:1rem}.tel-prop>div>p{margin:0;line-height:1.5}.content-wrapper{display:flex;flex-direction:column}.main{display:block;flex-grow:1;padding:12px 0 0}#title{width:100%;padding-bottom:.25rem}.title-printer{margin-left:inherit;margin-bottom:0}#title,.main-wrapper{display:flex;flex-direction:column;justify-content:space-between}.main-wrapper{flex-grow:1;margin:0 0 .75rem}.progress-bar{background:#fff;position:relative;min-height:5px;overflow-y:visible;display:flex;align-items:center;justify-content:center}.progress-bar>.fill{background:#fa6831;position:absolute;z-index:1}.progress-bar>p{z-index:2}.hide-scrollbar::-webkit-scrollbar{width:0;height:0}.connection-status[ok=false] [ok]:not([ok=false]),.connection-status[ok=true] [ok]:not([ok=true]),[data-tab]:not([opened=true]){display:none}[data-tab-btn][selected=true]{color:#fa6831}.component,.separator{flex-grow:1}.component{flex-wrap:wrap;padding-top:64px;line-height:1}.component-inline{padding-top:0}.component-fixed{flex:0 0 auto}.abs-center{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%)}#upld a{text-decoration:underline}#upld a:hover{text-decoration:none}#upld .form-row{margin-bottom:24px}#upld .form-submit,#upld-direct-frame div{display:flex;align-items:center}#upld .upld-source{display:flex;align-items:end;flex-wrap:wrap;margin-bottom:24px}#upld .state-choose-checkbox{flex:1 1 0}#upld-direct-frame{position:relative;border:2px dashed #fff;padding:24px}#upld-direct-frame div{flex-direction:row;justify-content:center}#upld-direct input[type=file]{position:absolute;opacity:0;width:100%;height:100%;top:-2.4px;left:-2.4px;padding:2.4px}#upld-direct input[type=file]:hover{cursor:pointer}#upld-direct .state-choose{opacity:0}#upld-direct .state-uploading{display:none;width:100%;height:100%}#drop-zone,#drop-zone div,#upld-direct .state-uploading img{width:100%;height:100%}#upld-direct[data-state=choose] .state-choose{opacity:1}#upld-direct[data-state=uploading] .state-uploading,.kebab>ul.open{display:block}#upld-remote input[type=text]{width:100%;box-sizing:border-box;line-height:1.5;margin:4px 0}#upld-remote .upld-details{display:flex;flex-wrap:wrap;margin-top:1rem}#upld-remote .progress-bar{height:36px;overflow:hidden}#upld-remote .progress-bar p{position:absolute;left:0;right:0;top:50%;transform:translate(0,-50%);padding:2px;overflow-x:auto;overflow-y:hidden;white-space:nowrap}#upld-remote[data-state=choose] .state-uploading,#upld-remote[data-state=uploading] .state-choose{display:none}#drop-zone{position:fixed;top:0;left:0;background-color:#000;opacity:.8;z-index:9999;padding:10px}#drop-zone input[type=file]{position:absolute;top:0;left:0;width:100%;height:100%;opacity:0}#drop-zone div{display:flex;align-items:center;justify-content:center}#graph{position:relative;width:520px}.temp-svg{pointer-events:all;top:0;right:2.5%;bottom:0;left:2.5%}.temp-g-label{max-width:100%;height:100%;width:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.temp-legend-blue{fill:#1b73f8}.temp-legend-orange{fill:#fa6831}#temp-line-blue,#temp-line-orange,#temp-line-yellow{fill:transparent;opacity:1;stroke-width:1%}#temp-line-blue{stroke:#1b73f8}#temp-line-orange{stroke:#fa6831}#temp-line-yellow{stroke:#f3f12c}.temp-text{fill:#efefef;font-size:10.5pt;letter-spacing:normal;padding:8px;stroke:transparent;stroke-width:0}.temp-label-line{stroke:#efefef;fill:transparent;stroke-width:2px}.temp-connect-text,.temp-h-line,.temp-label-line{stroke-linecap:round;stroke-linejoin:round}.temp-h-line{stroke:gray;stroke-dasharray:none;fill:none;pointer-events:painted}.temp-connect-text{stroke:#efefef;size:5px;fill:transparent;stroke-width:1px}.temp-line-text{font-size:10.5pt;padding:5px;fill:#efefef;letter-spacing:normal;stroke:transparent;stroke-width:0}#files{width:100%;margin-top:16px}.node-icon-box,.node-img-box{margin-right:12px}.node,.node-btn-list>li{display:flex;align-items:center;padding:6px 0}.node-icon,.node-img{position:absolute;width:100%;height:100%;top:0;left:0;right:0;bottom:0;-o-object-fit:contain;object-fit:contain}.node-icon{max-width:80px;max-height:80px;margin:auto}.node-icon-box,.node-img-box{flex-shrink:0;position:relative;width:150px;height:150px;align-self:stretch}.node-icon-box{height:100px}.node-header,.node-wrapper{display:flex;grid-gap:12px;gap:12px}.node-wrapper{width:100%;flex-direction:column}.node-header{justify-content:space-between}.node-header>p{-ms-word-break:break-all;word-break:break-all;margin:0}.node-details{display:flex;flex-wrap:wrap;width:100%;grid-column-gap:15px;-moz-column-gap:15px;column-gap:15px}.node-details .details{display:flex;flex-direction:column;justify-content:flex-end}.node-current{display:flex;flex-direction:column;align-items:stretch;padding:6px 0}.node-btn-list{display:flex;list-style:none;margin:0;padding:0}.node-btn-list>li{padding:5px;cursor:pointer}.node-btn-list>li:not([disabled]):hover{background-color:rgba(255,255,255,.1);border-radius:50%}.node-btn-list>li[disabled]{cursor:default;pointer-events:none;opacity:.3}#node-storage{margin-top:16px}.node-storage-space{display:flex;margin-top:10px;grid-column-gap:15px;-moz-column-gap:15px;column-gap:15px;align-items:center;justify-content:flex-end}.node-storage-space .progress-bar{flex-grow:1;max-width:200px}.node-upload-source{margin:0;padding:0;list-style:none;display:flex}.node-upload-source>li{display:flex}.dropdown li.selected,.node-upload-source>li[selected=true],.storage-select-btn:hover span{color:#fa6831}.node-upload-source>li:not(:first-child)::before{content:"\00a0|\00a0";color:#fff}#preview-wrapper{display:flex;align-items:start;justify-content:start;flex-wrap:wrap;position:relative}.loading-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background-color:rgba(10,10,10,.5)}.loading-overlay>img{width:20%;height:20%}.job-details{width:100%;margin-left:1rem}.job-prop{display:flex;padding-bottom:12px}.job-prop>.icon{margin:12px 6px 6px}.job-prop p{padding:.2rem}.job-prop-grid{display:grid;grid-template-columns:repeat(2,1fr);flex-grow:1}.job-buttons{margin-top:16px;flex-wrap:wrap;justify-content:flex-start}.job-buttons button{margin:0 12px 12px 0}.job-buttons:first-child button{margin-left:0}button{text-transform:none;display:flex;align-items:center;border-radius:6px;border-color:transparent}button:hover{cursor:pointer}button:disabled{cursor:default;pointer-events:none;box-shadow:none;opacity:.5}.action{background-color:#5b5b5b}.action:hover{background-color:#6b6868}.action:disabled{background-color:#5b5b5b;border-color:#dbdbdb}.yes{background-color:#35a913}.yes:hover{background-color:#6ba959}.yes:disabled{background-color:#35a913;border-color:#dbdbdb}.no{background-color:#be0000}.no:hover{background-color:#b34a4a}.no:disabled{background-color:#be0000;border-color:#dbdbdb}.outlined{border-style:solid;background-color:transparent;border-color:#fff}.outlined:hover{background-color:#fff;color:#000}.message{display:flex;justify-content:center;margin-top:15%;margin-bottom:15%}.progress{margin-bottom:16px}#job{width:100%;margin-top:1rem}#job .file-name{word-wrap:anywhere;margin-bottom:20px}#job .progress{width:100%;font-size:40px}.progress-img{flex:1 1 auto;min-width:320px}.progress-img>.background{filter:grayscale(1);background:#363636;width:100%;height:auto;display:block}.progress-img>.foreground{position:absolute;top:0;left:0;width:100%;height:auto;display:block}.progress-with-img,.progress-with-img>div{display:flex;flex:1 1 auto}.progress-with-img .progress-pct{display:flex;padding:0 0 0 1rem;align-items:center;justify-content:left;flex:0 0 auto;flex-wrap:nowrap}.progress-with-img .progress-bar{height:auto;width:20px;flex-shrink:0;background:#242526}.preview-img-wrapper{display:flex;flex-grow:1;flex-wrap:wrap;position:relative}.job-buttons,.refill{display:flex}.refill{margin:10px 0 20px}.refill div:first-child{padding-right:6px}.refill div:last-child{padding-left:6px}#control .row{display:flex;flex-direction:row;width:100%}#control .col,#control .resp-row{display:flex;flex-direction:column}#control .resp-row{width:100%;flex-wrap:wrap}#control .resp-group{display:flex;justify-content:space-between;flex-grow:1}#control .resp-group .col:last-child{margin:0 auto}#control .txt-underline{border-bottom:1px solid #707070;padding-bottom:.25rem}#control .title{margin:1rem 0}#control img{-webkit-user-drag:none}#control .rectangle,#control .square{min-width:6rem;width:6rem;height:6rem;margin:.5rem;border-radius:8px;padding:0}#control .square{display:flex;align-items:center;justify-content:center}#control .rectangle{width:100%}#control .input-wrapper{display:flex;margin:.25rem;border-radius:12px;background:#fff}#control .input-wrapper>.square{margin:0;width:auto}#control .input-wrapper>.square:first-child{border-radius:8px 0 0 8px;background-color:#5b5b5b}#control .input-wrapper>.square:last-child{border-radius:0 8px 8px 0}#control input[type=text]{margin:0;padding:0;border:0;width:150px;flex-grow:1;text-align:center}#control .grid-container{display:grid;grid-template-columns:auto auto}#control .grid-item:nth-child(even){text-align:right}#control .separator{display:flex;flex-grow:1}#control button[selected],#control button[selected]:disabled{border:0;background:#fa6831;opacity:1}.modal{position:fixed;left:0;top:0;width:100%;height:100%;background-color:rgba(10,10,10,.86);opacity:0;visibility:hidden;z-index:5}.modal-box{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background-color:#4a4a4a;padding:1rem 1.5rem;border-radius:.5rem;z-index:1000}.modal-box.offline-screen{background-color:transparent;text-align:center}.close-button{float:right;text-align:center;border-radius:.25rem;background-color:rgba(10,10,10,.2);border:0;border-radius:50px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:40px;max-height:40px;max-width:40px;min-height:40px;min-width:40px;outline:0;position:relative;vertical-align:top;width:40px}.close-button:after{height:50%;width:2px}.close-button:before{height:2px;width:50%}.close-button:after,.close-button:before{background-color:#efefef;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.show-modal{opacity:1;visibility:visible;transform:scale(1)}.modal-welcome img{width:90%;padding-bottom:20px}.modal-welcome p{padding:.2rem;text-align:center}.modal-welcome p:last-child{margin-top:2rem}.modal-apiKey>p{padding:.2rem}.modal-apiKey>p:first-child{padding-bottom:1.5rem;padding-top:1rem}.modal-apiKey>input{width:100%;box-sizing:border-box;margin-bottom:5px;border-radius:4px;min-height:2.2rem;margin-top:5px}.modal-apiKey>button{float:right}.modal-confirm{max-width:320px;overflow:hidden;text-overflow:ellipsis}.modal-confirm>div>div{margin:15px 15px 15px 0}.modal-exposure{display:grid;grid-template-columns:7fr 1fr 3fr 1fr;grid-template-rows:auto;margin-bottom:10px;justify-items:center}.modal-exposure>p{width:100%;align-self:center;white-space:nowrap}.modal-exposure>img{border-radius:50%;cursor:pointer;width:48px;height:48px}.modal-exposure #next,.modal-exposure #previous{display:none}.modal-sysupgrade__message{padding-top:16px}.file-name__form flex-row,.modal-sysupgrade__message p{margin-bottom:16px}#question{display:flex;justify-content:center;margin-top:15%;margin-bottom:15%}#prusa-toast{line-height:1.5;display:flex;flex-direction:column;z-index:4;position:fixed;right:0;bottom:0;font-size:1.25rem}#prusa-toast>article{display:block;text-align:center;margin:7px;-webkit-animation:slideInUp .5s,zoomOut .5s 10s;animation:slideInUp .5s,zoomOut .5s 10s}.toast-header{text-align:center;align-items:center;border-radius:10px 10px 0 0;display:flex;justify-content:space-between;line-height:1.25;padding:.75em 1em;position:relative;color:#efefef;background-color:#4a4a4a}.toast-body{word-break:break-all;padding:1.25em 1.5em;border-radius:0 0 10px 10px;color:defines.#efefef;background-color:#707070}#prusa-toast>article.warning>div.toast-header{color:rgba(0,0,0,.7);background-color:#ffdd57}#prusa-toast>article.warning>div.toast-body{color:#947600;background-color:#fffbeb}#prusa-toast>article.success>div.toast-header{color:#efefef;background-color:#48c774}#prusa-toast>article.success>div.toast-body{color:#257942;background-color:#effaf3}#prusa-toast>article.error>div.toast-header{color:#efefef;background-color:#f14668}#prusa-toast>article.error>div.toast-body{color:#cc0f35;background-color:#feecf0}#prusa-toast>article.error,#prusa-toast>article.warning{-webkit-animation:slideInUp .5s;animation:slideInUp .5s}.dropdown{align-self:center;position:relative;width:-webkit-max-content;width:-moz-max-content;width:max-content}#log-list{text-transform:none}.dropdown .open,.storage-select .open{visibility:visible}.dropdown ul{background-color:#0a0a0a;border:1px solid #707070;list-style:none;padding:0;margin:0;max-height:280px;overflow-y:auto}.dropdown li,.dropdown-btn{cursor:pointer;padding:16px 0}.dropdown li{padding:15px 20px;white-space:nowrap}.dropdown li.select{color:#fff;background:#fa6831}.dropdown-btn img{height:.7em;width:.7em;max-width:none;padding:0}.dropdown-content{top:90%;min-width:100%;position:absolute;visibility:collapse;z-index:5}.storage-select{display:flex;align-items:stretch;text-transform:uppercase;position:relative;width:100%;color:#000}.storage-select-left{width:0;border-bottom:1px solid #d5d5d5}.storage-select-right{flex:0 0 auto;border-bottom:1px solid #d5d5d5}.storage-select .icon{padding:0 8px}.storage-select-content{background:#fff;position:absolute;visibility:collapse;z-index:3;list-style:none;padding:0;margin:0;border-radius:6px;border:1px solid #d5d5d5;width:100%}.storage-select-content>li{display:flex;align-items:center;padding:16px 0;cursor:pointer}.storage-select-content>li:hover{background:#d5d5d5}.storage-select-btn{flex:1 0 0}.storage-select-btn-inner{display:flex;align-items:center;flex:1 0 0}.storage-select-btn{cursor:pointer;padding:16px 0;display:flex;align-items:center;border-radius:6px;background:#fff;border:1px solid #d5d5d5}.storage-select-btn img:last-child{margin-left:auto;border-left:1px solid #d5d5d5;padding:0 10px}.kebab{position:relative}.kebab>ul{position:absolute;right:0;z-index:3;display:none;list-style:none;width:-webkit-max-content;width:-moz-max-content;width:max-content;margin:0;padding:0;background-color:#f1f1f1;border:1px solid rgba(0,0,0,.15);border-radius:4px;cursor:default}.kebab>ul>li{padding:0 24px;display:flex;align-items:center;cursor:pointer}.kebab>ul>li:not([disabled]):hover{background-color:rgba(0,0,0,.1)}.kebab>ul>li>.icon{margin:12px 12px 12px 0}.kebab>ul>li[disabled]{cursor:default;pointer-events:none;opacity:.3}.kebab>ul>li:first-child{padding-top:12px}.kebab>ul>li:last-child{padding-bottom:12px}.loading{display:flex;justify-content:center;width:100%;margin-top:15%;margin-bottom:15%}.loading>img{width:60px;height:auto}input[type=password]:disabled,input[type=text]:disabled{background:0 0;border:0;color:#fff}.edit{cursor:pointer}#settings input[type=password],#settings input[type=text]{line-height:1.5;width:100%}#settings button{min-width:120px}#settings .table{padding:7px 0;max-width:800px}#settings .row{display:flex;flex-wrap:wrap}#settings .col{word-wrap:break-word;box-sizing:border-box;display:flex;align-items:center;padding-bottom:7px}#settings .col:nth-child(1){width:30%;justify-content:flex-end;text-align:right}#settings .col:nth-child(2){width:70%;flex-grow:1}#settings .col:nth-child(3){width:70%;margin-left:30%}#settings .col:nth-child(1),#settings .col:nth-child(2){padding-right:15px}#settings .logs{display:block;background:#fff;color:#000;height:12rem;overflow:auto;line-height:1.5;list-style:none;padding:10px;position:relative}#settings .logs li{width:100%;word-wrap:break-word;word-break:break-all}.connection-status p{display:flex}.connection-status img{margin-left:7px}.conn-status-msg{margin:0}.camera__snapshot,.file-name__form input{display:block}.camera-snapshot-meta{list-style:none;padding:0;display:flex;flex-direction:row;grid-gap:16px;gap:16px;margin:16px 0}.camera-snapshot-meta p{line-height:1.5}.file-name__form,.file-name__submit{margin-top:16px}.camera-settings__form label{flex:1 0 0;padding-right:8px;display:block;white-space:nowrap}.camera-settings__form input,.camera-settings__form select{flex-grow:1 0 0;display:block;max-width:196px}.camera-settings__form .flex-row{margin:16px 0;align-items:center;justify-content:space-evenly}#cameras-list{margin:0;padding:0}#cameras-list li{list-style:none}.camera__snapshot{cursor:pointer}.camera__preview{width:100px;height:100px;display:flex;align-items:center}.camera__preview img{width:100%;height:auto}.camera-settings__form .dropdown-btn{padding:0}.sort-bar{margin:0;padding:0}.sort-by{margin-left:16px;cursor:pointer;align-items:center}.sort-by p{margin-right:8px}.sort-asc,.sort-desc{fill:#efefef}.asc .sort-asc,.desc .sort-desc{fill:#fa6831}.sort-direction{justify-content:center}.slider{-webkit-appearance:none;height:15px;border-radius:5px;background:#707070;outline:0;opacity:.7;transition:opacity .2s}.slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:25px;height:25px;border-radius:50%;background:#fa6831;cursor:pointer}.slider::-moz-range-thumb{width:25px;height:25px;border-radius:50%;background:#fa6831;cursor:pointer}@media only screen and (max-width:991.5px){.pc-only{display:none}.camera-snapshot-meta{flex-direction:column}}@media only screen and (min-width:992px){.mobile-only{display:none}.header{padding:0;position:-webkit-sticky;position:sticky;top:0;z-index:4}nav .logo-wrapper{flex-grow:0}nav{flex-direction:inherit}nav>ul{flex-wrap:nowrap;flex-direction:row;width:inherit;padding:0}nav>ul>li{position:relative;z-index:5;margin-top:4px;border-bottom:2px solid #707070}nav>ul>li a{padding:0 16px}nav>ul>li:hover{border-bottom:2px solid #efefef}nav .dropdown{padding:0;align-self:unset!important;min-width:-webkit-fit-content!important;min-width:-moz-fit-content!important;min-width:fit-content!important}.dropdown li{padding:2px 4px}#lang-dropdown{order:4}.burger-menu,.kebab-responsive>ul.open{display:flex}nav .brand-logo{max-height:100%;max-width:300px;min-height:auto;margin-bottom:auto;flex-grow:0;justify-content:flex-start}nav .brand-logo img{max-width:none;max-height:none;width:auto;height:auto}nav .navbar-burger{display:none}#telemetry-wrapper{flex:0 0 auto;width:256px}.tel-prop{width:100%}#title,.content-wrapper{flex-direction:row}.title-printer{margin-left:auto}.icon,button>img{width:24px;height:24px;min-width:24px}.icon-small{width:20px;height:20px;min-width:20px}button{border-radius:4px}#preview-wrapper{flex-wrap:nowrap}.job-title{display:flex;flex-direction:row}.job-title p{flex:1 0 0}.progress>progress{width:96%}.modal-welcome img{width:75%}#lang{margin-top:0}#settings .col:nth-child(1){width:25%}#settings .col:nth-child(2){width:45%}#settings .col:nth-child(3){width:30%;margin-left:0}#control .rectangle,#control .square{min-width:3.5rem;height:3.5rem;margin:.25rem;border-radius:4px}#control .square{width:3.5rem}#control .resp-row{flex-direction:row;justify-content:space-between}#control .input-wrapper>.square:first-child{border-radius:4px 0 0 4px;background-color:#5b5b5b}#control .input-wrapper>.square:last-child{border-radius:0 4px 4px 0}#job .progress,#prusa-toast{font-size:1rem}.close-button{height:40px;max-height:40px;max-width:40px;min-height:40px;min-width:40px}.node-current{flex-direction:row}.node,.node-btn-list{padding-right:5px}.node .node-btn-list{padding-right:0}.node-icon{max-width:48px;max-height:48px}.node-img-box{width:100px;height:100px}.node-icon-box{width:100px;height:60px}.kebab-responsive>.kebab-menu{display:none}.kebab-responsive>ul{position:static;display:flex;background:0 0;border:0}.kebab-responsive>ul>li,.kebab-responsive>ul>li:first-child,.kebab-responsive>ul>li:last-child{padding:5px}.kebab-responsive>ul>li:last-child{margin-right:-5px}.kebab-responsive>ul>li:not([disabled]):hover{background-color:rgba(255,255,255,.1);border-radius:50%}.kebab-responsive>ul>li .icon{margin:0}.storage-select{color:#fff;border-radius:0}.storage-select-btn{display:none}.storage-select-content{visibility:visible;position:static;border:0;background:0 0;list-style:none;display:flex;width:auto}.storage-select-content>li{display:flex;align-items:center;padding:5px 30px 5px 5px;cursor:pointer;border:1px solid transparent;border-bottom-color:#bababa}.storage-select-content>li:hover{background:0 0}.storage-select-content>li[selected=false]:hover{background:#313131}.storage-select-content>li[selected=true]{color:#fa6831;border:1px solid #bababa;border-bottom:1px solid #0a0a0a}.storage-select-left{width:10px}.storage-select-right{flex:1 0 auto}.job-buttons-separator{flex:1 0 0}.camera-snapshot-meta{flex-direction:row}}@media only screen and (min-width:1200px){.home-row{flex-direction:row}.home-row>div:not(:first-child){padding-left:12px}.home-row>div:first-child{padding-right:12px}} ================================================ FILE: prusa/link/templates/_footer.html ================================================ {% if wx is defined %}
    {% endif %}
    Copyright © 2024 Prusa Research a.s. All rights reserved.
    {%- if template_info is defined %} {%- from 'template_info.html' import render_info %}
                {{ render_info(template_info.context().get_exported()) }}
            
    {% endif %}
    ================================================ FILE: prusa/link/templates/_header.html ================================================ Prusa Link{{ ' | ' + title if title is defined else '' }} {%- if refresh %} {%- endif %} {% if wx is not defined %}
    {% else %}
    Prusa Link
    {% endif %} ================================================ FILE: prusa/link/templates/_wizard.html ================================================
    {%- if active in ('welcome', 'restore', 'credentials', 'printer', 'finish') %} {%- endif %} {%- if active in ('credentials', 'printer', 'finish') %} {%- endif %} {%- if active in ('printer', 'finish') %} {%- endif %} {%- if active == 'finish' %} {%- endif %}
    ================================================ FILE: prusa/link/templates/error-gone.html ================================================ {# vim:set softtabstop=2: -#} {% set title = '410 Gone' -%} {#% set refresh = 15 %#} {% include "_header.html" %}

    {{ title }}

    Target resource {{ this_uri }} is no longer available on this server.

    {% include "_footer.html" %} ================================================ FILE: prusa/link/templates/error-internal-server-error.html ================================================ {# vim:set softtabstop=2: -#} {% set wx = True %} {% set title = '500 Internal Server Error' -%} {#% set refresh = 15 %#} {% include "_header.html" %}

    {{ title }}

    We're sorry, but there is a error in service.

    Please try again later.

    {% if debug %}

    Exception Traceback

    {%- for line in traceback -%}
    
    {{ line }}
    {%- endfor -%}
    {% endif %} {% include "_footer.html" %} ================================================ FILE: prusa/link/templates/error.html ================================================ {# vim:set softtabstop=2: -#} {% set wx = True %} {#% set refresh = 15 %#} {% include "_header.html" %}

    {{ status_code }} {{ title }}

    {{ text }}

    {% include "_footer.html" %} ================================================ FILE: prusa/link/templates/index.html ================================================ {# vim:set softtabstop=2: -#} {% set title = 'Home' -%} {#% set refresh = 15 %#} {% include "_header.html" %}

    API key

    For uploading gcodes from the Prusa Slicer use this Api-Key

    {{ api_key }}

    Status

    {{ errors }}