├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── announcement.md │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ └── pull_request_template.md └── workflows │ ├── publish_assets.yaml │ └── test_code.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── docs ├── api_changes.md ├── changelog.md ├── components.md ├── configuration.md ├── contributing.md ├── dev_changelog.md ├── developer-certificate-of-origin ├── dictionaries │ └── moonraker.txt ├── doc-requirements.txt ├── example-home-assistant-extended.yaml ├── example-home-assistant.yaml ├── external_api │ ├── announcements.md │ ├── authorization.md │ ├── database.md │ ├── devices.md │ ├── extensions.md │ ├── file_manager.md │ ├── history.md │ ├── integrations.md │ ├── introduction.md │ ├── job_queue.md │ ├── jsonrpc_notifications.md │ ├── machine.md │ ├── printer.md │ ├── server.md │ ├── update_manager.md │ └── webcams.md ├── index.md ├── installation.md ├── moonraker.conf ├── printer_objects.md ├── pymdown-extras │ ├── collapse_code.py │ └── setup.py ├── src │ ├── css │ │ ├── extra-950ac449d4.css │ │ └── extra-950ac449d4.css.map │ └── js │ │ ├── compact-tables-qqTQvuZ9.js │ │ └── compact-tables-qqTQvuZ9.js.map └── user_changes.md ├── mkdocs.yml ├── moonraker ├── __init__.py ├── __main__.py ├── assets │ ├── __init__.py │ ├── default_allowed_services │ └── welcome.html ├── common.py ├── components │ ├── __init__.py │ ├── analysis.py │ ├── announcements.py │ ├── application.py │ ├── authorization.py │ ├── button.py │ ├── data_store.py │ ├── database.py │ ├── dbus_manager.py │ ├── extensions.py │ ├── file_manager │ │ ├── __init__.py │ │ ├── file_manager.py │ │ └── metadata.py │ ├── gpio.py │ ├── history.py │ ├── http_client.py │ ├── job_queue.py │ ├── job_state.py │ ├── klippy_apis.py │ ├── klippy_connection.py │ ├── ldap.py │ ├── machine.py │ ├── mqtt.py │ ├── notifier.py │ ├── octoprint_compat.py │ ├── paneldue.py │ ├── power.py │ ├── proc_stats.py │ ├── secrets.py │ ├── sensor.py │ ├── shell_command.py │ ├── simplyprint.py │ ├── spoolman.py │ ├── template.py │ ├── update_manager │ │ ├── __init__.py │ │ ├── app_deploy.py │ │ ├── base_deploy.py │ │ ├── common.py │ │ ├── git_deploy.py │ │ ├── net_deploy.py │ │ ├── python_deploy.py │ │ ├── system_deploy.py │ │ └── update_manager.py │ ├── webcam.py │ ├── websockets.py │ ├── wled.py │ └── zeroconf.py ├── confighelper.py ├── eventloop.py ├── loghelper.py ├── moonraker.py ├── server.py ├── thirdparty │ ├── __init__.py │ └── packagekit │ │ ├── __init__.py │ │ └── enums.py └── utils │ ├── __init__.py │ ├── async_serial.py │ ├── cansocket.py │ ├── exceptions.py │ ├── filelock.py │ ├── ioctl_macros.py │ ├── json_wrapper.py │ ├── pip_utils.py │ ├── source_info.py │ ├── sysdeps_parser.py │ ├── sysfs_devs.py │ └── versions.py ├── pdm_build.py ├── pyproject.toml ├── pytest.ini ├── scripts ├── backup-database.sh ├── build-zip-release.sh ├── build_release.py ├── data-path-fix.sh ├── dbtool.py ├── fetch-apikey.sh ├── finish-upgrade.sh ├── install-moonraker.sh ├── make_sysdeps.py ├── moonraker-dev-reqs.txt ├── moonraker-requirements.txt ├── moonraker-speedups.txt ├── pk-enum-convertor.py ├── python_wheels │ └── zeroconf-0.131.0-py3-none-any.whl ├── restore-database.sh ├── set-policykit-rules.sh ├── sudo_fix.sh ├── sync_dependencies.py ├── system-dependencies.json ├── tag-release.sh └── uninstall-moonraker.sh └── tests ├── assets ├── klipper │ ├── base_printer.cfg │ ├── error_printer.cfg │ ├── klipper.dict │ └── missing_reqs.cfg └── moonraker │ ├── bare_db.cdb │ ├── base_server.conf │ ├── base_server_ssl.conf │ ├── invalid_config.conf │ ├── secrets.ini │ ├── secrets.json │ ├── supplemental.conf │ └── unparsed_server.conf ├── conftest.py ├── fixtures ├── __init__.py ├── http_client.py ├── klippy_process.py └── websocket_client.py ├── mocks ├── __init__.py └── mock_gpio.py ├── test_config.py ├── test_database.py ├── test_klippy_connection.py └── test_server.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editorconfig file for moonraker repo, courtesy of @trevjonez 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | charset = utf-8 11 | 12 | [*.py] 13 | max_line_length = 80 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: arksine 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/announcement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Announcement 3 | about: Create a Moonraker Announcement. For dev use only. 4 | title: '' 5 | labels: announcement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: ["bug", "triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This issue form is for reporting bugs only! 9 | If you have a feature request, please use [Feature Request](/new?template=feature_request.yml). 10 | All bug reports MUST include `moonraker.log` as an attachment. 11 | - type: textarea 12 | id: what-happened 13 | attributes: 14 | label: What happened 15 | description: >- 16 | A clear and concise description of what the bug is. 17 | validations: 18 | required: true 19 | - type: dropdown 20 | id: client 21 | attributes: 22 | label: Client 23 | multiple: true 24 | description: On what clients are you seeing the problem? 25 | options: 26 | - Mainsail 27 | - Fluidd 28 | - MoonCord 29 | - KlipperScreen 30 | - MobileRaker 31 | - OctoApp 32 | - Other 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: browser 37 | attributes: 38 | label: Browser 39 | multiple: true 40 | description: On what browsers are you seeing the problem? 41 | options: 42 | - Chrome 43 | - Firefox 44 | - Safari 45 | - Microsoft Edge 46 | - Brave 47 | - Other or N/A 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: repro-steps 52 | attributes: 53 | label: How to reproduce 54 | description: >- 55 | Minimal and precise steps to reproduce this bug. 56 | validations: 57 | required: true 58 | - type: textarea 59 | id: additional-info 60 | attributes: 61 | label: Additional information 62 | description: | 63 | Please attach moonraker.log using the field below. If you are using 64 | a Browser or Client not provided in the options above please specify 65 | them here. 66 | 67 | You may also include any additional information, screenshots, or files 68 | that helpful in describing the issue. 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Klipper Discord 4 | url: https://discord.klipper3d.org/ 5 | about: Quickest way to get in contact 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This issue form is for feature requests only! 9 | If you've found a bug, please use [bug_report](/new?template=bug_report.yml) 10 | - type: textarea 11 | id: problem-description 12 | attributes: 13 | label: Is your feature request related to a problem? Please describe 14 | description: >- 15 | A clear and concise description of what the problem is. 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: solution-description 20 | attributes: 21 | label: Describe the solution you'd like 22 | description: >- 23 | A clear and concise description of what you want to happen. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: possible-alternatives 28 | attributes: 29 | label: Describe alternatives you've considered 30 | description: >- 31 | A clear and concise description of any alternative solutions or features you've considered. 32 | - type: textarea 33 | id: additional-info 34 | attributes: 35 | label: Additional information 36 | description: | 37 | If you have any additional information for us, use the field below. 38 | 39 | Please note, you can attach screenshots or screen recordings here, by 40 | dragging and dropping files in the field below. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/workflows/publish_assets.yaml: -------------------------------------------------------------------------------- 1 | # CI Code for generating and publishing beta assets 2 | 3 | name: publish_assets 4 | on: 5 | release: 6 | types: [published] 7 | jobs: 8 | generate_assets: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Moonraker 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | ref: ${{ github.ref }} 16 | path: moonraker 17 | 18 | - name: Checkout Klipper 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | repository: Klipper3d/klipper 23 | path: klipper 24 | 25 | - name: Build Beta Assets 26 | if: ${{ github.event.release.prerelease }} 27 | run: > 28 | ./moonraker/scripts/build-zip-release.sh -b 29 | -o ${{ github.workspace }} 30 | -k ${{ github.workspace }}/klipper 31 | 32 | - name: Build Stable Assets 33 | if: ${{ !github.event.release.prerelease }} 34 | run: > 35 | ./moonraker/scripts/build-zip-release.sh 36 | -o ${{ github.workspace }} 37 | -k ${{ github.workspace }}/klipper 38 | 39 | - name: Upload assets 40 | run: | 41 | cd moonraker 42 | gh release upload ${{ env.TAG }} ${{ env.FILES }} 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | FILES: > 46 | ${{ github.workspace }}/moonraker.zip 47 | ${{ github.workspace }}/klipper.zip 48 | ${{ github.workspace }}/RELEASE_INFO 49 | ${{ github.workspace }}/COMMIT_LOG 50 | TAG: ${{ github.event.release.tag_name }} 51 | 52 | -------------------------------------------------------------------------------- /.github/workflows/test_code.yaml: -------------------------------------------------------------------------------- 1 | # CI for code style and application tests 2 | 3 | name: test-code 4 | on: [push, pull_request] 5 | jobs: 6 | lint-python-code: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: weibullguy/python-lint-plus@v1.12.0 11 | with: 12 | python-root-list: "moonraker scripts" 13 | virtual-env: "python-lint-plus" 14 | python-version: "3.10" 15 | use-flake8: true 16 | flake8-version: "==6.1.0" 17 | use-mypy: true 18 | mypy-version: "==1.5.1" 19 | extra-flake8-options: "--ignore=E226,E301,E302,E303,W503,W504 --max-line-length=88 --max-doc-length=88" 20 | extra-mypy-options: "--ignore-missing-imports --follow-imports=silent" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .devel 6 | 7 | .venv 8 | venv 9 | start_moonraker 10 | *.env 11 | .pdm-python 12 | build 13 | dist 14 | share 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: sync-requirements 5 | name: sync python requirements 6 | language: system 7 | entry: python3 scripts/sync_dependencies.py 8 | files: ^pyproject.toml$ 9 | - id: sync-os-packages 10 | name: sync packages 11 | language: system 12 | entry: python3 scripts/sync_dependencies.py 13 | files: ^scripts/system-dependencies.json$ 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.12" 7 | 8 | mkdocs: 9 | configuration: mkdocs.yml 10 | fail_on_warning: false 11 | 12 | python: 13 | install: 14 | - requirements: docs/doc-requirements.txt 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Moonraker - API Web Server for Klipper 3 | 4 | Moonraker is a Python 3 based web server that exposes APIs with which 5 | client applications may use to interact with the 3D printing firmware 6 | [Klipper](https://github.com/KevinOConnor/klipper). Communication between 7 | the Klippy host and Moonraker is done over a Unix Domain Socket. Tornado 8 | is used to provide Moonraker's server functionality. 9 | 10 | Documentation for users and developers can be found on 11 | [Read the Docs](https://moonraker.readthedocs.io/en/latest/). 12 | 13 | ### Clients 14 | 15 | Note that Moonraker does not come bundled with a client, you will need to 16 | install one. The following clients are currently available: 17 | 18 | - [Mainsail](https://github.com/mainsail-crew/mainsail) by [Mainsail-Crew](https://github.com/mainsail-crew) 19 | - [Fluidd](https://github.com/fluidd-core/fluidd) by Cadriel 20 | - [KlipperScreen](https://github.com/jordanruthe/KlipperScreen) by jordanruthe 21 | - [mooncord](https://github.com/eliteSchwein/mooncord) by eliteSchwein 22 | 23 | ### Raspberry Pi Images 24 | 25 | Moonraker is available pre-installed with the following Raspberry Pi images: 26 | 27 | - [MainsailOS](https://github.com/mainsail-crew/MainsailOS) by [Mainsail-Crew](https://github.com/mainsail-crew) 28 | - Includes Klipper, Moonraker, and Mainsail 29 | - [FluiddPi](https://github.com/fluidd-core/FluiddPi) by Cadriel 30 | - Includes Klipper, Moonraker, and Fluidd 31 | 32 | ### Docker Containers 33 | 34 | The following projects deploy Moonraker via Docker: 35 | 36 | - [prind](https://github.com/mkuf/prind) by mkuf 37 | - A suite of containers which allow you to run Klipper in 38 | Docker. Includes support for OctoPrint and Moonraker. 39 | 40 | ### Changes 41 | 42 | Please refer to the [changelog](https://moonraker.readthedocs.io/en/latest/changelog) 43 | for a list of notable changes to Moonraker. 44 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Moonraker 2 | 3 | Prior to submitting a pull request prospective contributors must read this 4 | entire document. Care should be taken to [format git commits](#git-commit-format) 5 | correctly. This eases the review process and provides the reviewer with 6 | confidence that the submission will be of sufficient quality. 7 | 8 | Prospective contributors should consider the following: 9 | 10 | - Does the contribution have significant impact? Bug fixes to existing 11 | functionality and new features requested by 100+ users qualify as 12 | items of significant impact. 13 | - Has the submission been well tested? Submissions with substantial code 14 | change must include details about the testing procedure and results. 15 | - Does the submission include blocking code? Moonraker is an asynchronous 16 | application, thus blocking code must be avoided. 17 | - If any dependencies are included, are they pure python? Many low-powered SBCs 18 | running Armbian do not have prebuilt wheels and are not capable of building wheels 19 | themselves, thus breaking updates on these systems. 20 | - Does the submission change the API? If so, could the change potentially break 21 | frontends using the API? 22 | - Does the submission include updates to the documentation? 23 | 24 | When performing reviews these are the questions that will be asked during the 25 | initial stages. 26 | 27 | #### New Module Contributions 28 | 29 | All source files should begin with a copyright notice in the following format: 30 | 31 | ```python 32 | # Module name and brief description of module 33 | # 34 | # Copyright (C) 2021 YOUR NAME 35 | # 36 | # This file may be distributed under the terms of the GNU GPLv3 license 37 | ``` 38 | 39 | #### Git Commit Format 40 | 41 | Commits should be contain one functional change. Changes that are unrelated 42 | or independent should be broken up into multiple commits. It is acceptable 43 | for a commit to contain multiple files if a change to one module depends on a 44 | change to another (ie: changing the name of a method). 45 | 46 | Avoid merge commits. If it is necessary to update a Pull Request from the 47 | master branch use git's interactive rebase and force push. 48 | 49 | Each Commit message should be in the following format: 50 | 51 | ```text 52 | module: brief description of commit 53 | 54 | More detailed explanation of the change if required 55 | 56 | Signed-off-by: Your Name 57 | ``` 58 | 59 | Where: 60 | 61 | - `module`: is the name of the Python module you are changing or parent 62 | folder if not applicable 63 | - `Your Name`: Your real first and last name 64 | - ``: A real, reachable email address 65 | 66 | For example, the git log of a new `power.py` device implementation might look 67 | like the following: 68 | 69 | ```git 70 | power: add support for mqtt devices 71 | 72 | Signed-off-by: Eric Callahan 73 | ``` 74 | ```git 75 | docs: add mqtt power device documentation 76 | 77 | Signed-off-by: Eric Callahan 78 | ``` 79 | 80 | By signing off on commits, you acknowledge that you agree to the 81 | [developer certificate of origin](https://developercertificate.org/) 82 | shown below. As mentioned above, your signature must contain your 83 | real name and a current email address. 84 | 85 | ```text 86 | Developer Certificate of Origin 87 | Version 1.1 88 | 89 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 90 | 1 Letterman Drive 91 | Suite D4700 92 | San Francisco, CA, 94129 93 | 94 | Everyone is permitted to copy and distribute verbatim copies of this 95 | license document, but changing it is not allowed. 96 | 97 | 98 | Developer's Certificate of Origin 1.1 99 | 100 | By making a contribution to this project, I certify that: 101 | 102 | (a) The contribution was created in whole or in part by me and I 103 | have the right to submit it under the open source license 104 | indicated in the file; or 105 | 106 | (b) The contribution is based upon previous work that, to the best 107 | of my knowledge, is covered under an appropriate open source 108 | license and I have the right under that license to submit that 109 | work with modifications, whether created in whole or in part 110 | by me, under the same open source license (unless I am 111 | permitted to submit under a different license), as indicated 112 | in the file; or 113 | 114 | (c) The contribution was provided directly to me by some other 115 | person who certified (a), (b) or (c) and I have not modified 116 | it. 117 | 118 | (d) I understand and agree that this project and the contribution 119 | are public and that a record of the contribution (including all 120 | personal information I submit with it, including my sign-off) is 121 | maintained indefinitely and may be redistributed consistent with 122 | this project or the open source license(s) involved. 123 | ``` 124 | #### Code Style 125 | Python methods should be fully annotated. Variables should be annotated where 126 | the type cannot be inferred. Moonraker uses `mypy` version 1.5.1 for static 127 | type checking with the following options: 128 | 129 | - `--ignore-missing-imports` 130 | - `--follow-imports=silent` 131 | 132 | No line in the source code should exceed 88 characters. Be sure there is no 133 | trailing whitespace. To validate code before submission one may use 134 | `flake8` version 6.1.0 with the following options: 135 | 136 | - `--ignore=E226,E301,E302,E303,W503,W504` 137 | - `--max-line-length=88` 138 | - `--max-doc-length=88` 139 | 140 | Generally speaking, each line in submitted documentation should also be no 141 | longer than 88 characters, however there are situations where this isn't 142 | possible, such as long hyperlinks or example return values. 143 | 144 | Avoid peeking into the member variables of another class. Use getters or 145 | properties to access object state. 146 | -------------------------------------------------------------------------------- /docs/developer-certificate-of-origin: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 1 Letterman Drive 6 | Suite D4700 7 | San Francisco, CA, 94129 8 | 9 | Everyone is permitted to copy and distribute verbatim copies of this 10 | license document, but changing it is not allowed. 11 | 12 | 13 | Developer's Certificate of Origin 1.1 14 | 15 | By making a contribution to this project, I certify that: 16 | 17 | (a) The contribution was created in whole or in part by me and I 18 | have the right to submit it under the open source license 19 | indicated in the file; or 20 | 21 | (b) The contribution is based upon previous work that, to the best 22 | of my knowledge, is covered under an appropriate open source 23 | license and I have the right under that license to submit that 24 | work with modifications, whether created in whole or in part 25 | by me, under the same open source license (unless I am 26 | permitted to submit under a different license), as indicated 27 | in the file; or 28 | 29 | (c) The contribution was provided directly to me by some other 30 | person who certified (a), (b) or (c) and I have not modified 31 | it. 32 | 33 | (d) I understand and agree that this project and the contribution 34 | are public and that a record of the contribution (including all 35 | personal information I submit with it, including my sign-off) is 36 | maintained indefinitely and may be redistributed consistent with 37 | this project or the open source license(s) involved. 38 | -------------------------------------------------------------------------------- /docs/dictionaries/moonraker.txt: -------------------------------------------------------------------------------- 1 | adxl 2 | apikey 3 | apirequest 4 | apiresponse 5 | argone 6 | argtwo 7 | arksine 8 | armv 9 | asvc 10 | asyncio 11 | atmega 12 | Benchy 13 | bigtreetech 14 | binutils 15 | calicat 16 | camerastreamer 17 | canbus 18 | cansocket 19 | cmnd 20 | comms 21 | compat 22 | configfile 23 | configheler 24 | confighelper 25 | configurator 26 | Connor 27 | crowsnest 28 | Cura 29 | datapath 30 | datasheet 31 | dbus 32 | devel 33 | distro 34 | distros 35 | Domoticz 36 | Donkie 37 | eeprom 38 | endstop 39 | endstops 40 | EPCOS 41 | eventtime 42 | extr 43 | feedrate 44 | filelist 45 | fileobj 46 | firemon 47 | Fluidd 48 | fourcc 49 | fromjson 50 | frontends 51 | ftdi 52 | gcode 53 | gcodes 54 | getboolean 55 | getfloat 56 | getfqdn 57 | getint 58 | getitem 59 | getsection 60 | gpio 61 | gpiochip 62 | hexeditor 63 | highspeed 64 | hlsstream 65 | homeassistant 66 | homeseer 67 | httpx 68 | inotify 69 | ipstream 70 | jmuxer 71 | journalctl 72 | Kasa 73 | katapult 74 | klipper 75 | klippy 76 | klippysocket 77 | kwargs 78 | libcamera 79 | libgpiod 80 | libnacl 81 | lmdb 82 | logind 83 | Loxone 84 | loxonev 85 | mediamtx 86 | metascan 87 | microsteps 88 | mjpeg 89 | mjpegstreamer 90 | MJPG 91 | Mobileraker 92 | moko 93 | moonagent 94 | moontest 95 | msgspec 96 | Muxer 97 | mypassword 98 | mypy 99 | neopixel 100 | neopixels 101 | Obico 102 | octoeverywhere 103 | octoprint 104 | oneshot 105 | packagekit 106 | packagename 107 | paneldue 108 | piezo 109 | PKGLIST 110 | poweroff 111 | Prusa 112 | pstats 113 | pullup 114 | pyapp 115 | Pycurl 116 | pyproject 117 | pyserial 118 | raspberrypi 119 | ratos 120 | rawparams 121 | readwrite 122 | sdcard 123 | sdcardinfo 124 | sdist 125 | Semitec 126 | setitem 127 | sgbrg 128 | simplyprint 129 | smartplug 130 | smartthings 131 | spoolman 132 | SSDP 133 | stallguard 134 | subdir 135 | supervisord 136 | sysfs 137 | sysinfo 138 | tasmota 139 | telegam 140 | testcam 141 | testdir 142 | testuser 143 | TESTZ 144 | tojson 145 | tplink 146 | trapq 147 | uart 148 | ububctl 149 | uhubctl 150 | unicam 151 | unixsocket 152 | userpass 153 | uvcvideo 154 | uvloop 155 | vcgencmd 156 | venv 157 | virt 158 | virtualenv 159 | volted 160 | webcamd 161 | webrtc 162 | websockets 163 | wlan 164 | WLED 165 | worktrees 166 | yuyv 167 | zeroconf 168 | Zigbee 169 | -------------------------------------------------------------------------------- /docs/doc-requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==9.5.50 2 | pymdown-extensions~=10.14.1 3 | compact_tables@git+https://github.com/Arksine/markdown-compact-tables@v1.1.0 4 | ./docs/pymdown-extras -------------------------------------------------------------------------------- /docs/example-home-assistant-extended.yaml: -------------------------------------------------------------------------------- 1 | # Example Home Assistant configuration file for a Artillery Sidewinder X1 2 | # Credit to GitHub users @Kruppes and @pedrolamas 3 | # extended by @tispokes 4 | camera: 5 | - platform: generic 6 | still_image_url: http://192.168.178.66/webcam/?action=snapshot 7 | stream_source: http://192.168.178.66/webcam/?action=stream 8 | framerate: 10 9 | 10 | sensor: 11 | - platform: rest 12 | name: SWX1_sensor 13 | resource: "http://192.168.178.66:7125/printer/objects/query?heater_bed&extruder&print_stats&toolhead&display_status&virtual_sdcard" 14 | json_attributes_path: "$.result.status" 15 | json_attributes: 16 | - heater_bed 17 | - extruder 18 | - print_stats 19 | - toolhead 20 | - display_status 21 | - virtual_sdcard 22 | value_template: >- 23 | {{ "OK" if ("result" in value_json) else "offline" }} 24 | # Adding an API key is only necessary while using the [authorization] component 25 | # and if Home Assistant is not a trusted client 26 | headers: 27 | x-api-key: 123456789abcdefghijklmno 28 | 29 | - platform: template 30 | sensors: 31 | swx1_state: 32 | unique_id: sensor.swx1_state 33 | friendly_name: "Status" 34 | icon_template: mdi:printer-3d 35 | value_template: >- 36 | {{ states.sensor.swx1_sensor.attributes['print_stats']['state'] if is_state('sensor.swx1_sensor', 'OK') else None }} 37 | 38 | swx1_current_print: 39 | unique_id: sensor.swx1_current_print 40 | friendly_name: >- 41 | {{ "Printed" if states.sensor.swx1_sensor.attributes['display_status']['progress'] == 1 else "Printing..." }} 42 | icon_template: mdi:video-3d 43 | value_template: >- 44 | {{ states.sensor.swx1_sensor.attributes['print_stats']['filename'].split(".")[0] if is_state('sensor.swx1_sensor', 'OK') else None }} 45 | 46 | swx1_current_progress: 47 | unique_id: sensor.swx1_current_progress 48 | friendly_name: "Progress" 49 | unit_of_measurement: '%' 50 | icon_template: mdi:file-percent 51 | value_template: >- 52 | {{ (states.sensor.swx1_sensor.attributes['display_status']['progress'] * 100) | round(1) if is_state('sensor.swx1_sensor', 'OK') else None }} 53 | 54 | swx1_print_time: 55 | unique_id: sensor.swx1_print_time 56 | friendly_name: "T-elapsed" 57 | icon_template: mdi:clock-start 58 | value_template: >- 59 | {{ states.sensor.swx1_sensor.attributes['print_stats']['print_duration'] | timestamp_custom("%H:%M:%S", 0) if is_state('sensor.swx1_sensor', 'OK') else None }} 60 | 61 | swx1_time_remaining: 62 | unique_id: sensor.swx1_time_remaining 63 | friendly_name: "T-remaining" 64 | icon_template: mdi:clock-end 65 | value_template: >- 66 | {{ (((states.sensor.swx1_sensor.attributes['print_stats']['print_duration'] / states.sensor.swx1_sensor.attributes['display_status']['progress'] - states.sensor.swx1_sensor.attributes['print_stats']['print_duration']) if states.sensor.swx1_sensor.attributes['display_status']['progress'] > 0 else 0) | timestamp_custom('%H:%M:%S', 0)) if is_state('sensor.swx1_sensor', 'OK') else None }} 67 | 68 | swx1_eta: 69 | unique_id: sensor.swx1_eta 70 | friendly_name: "T-ETA" 71 | icon_template: mdi:clock-outline 72 | value_template: >- 73 | {{ (as_timestamp(now()) + 2 * 60 * 60 + ((states.sensor.swx1_sensor.attributes['print_stats']['print_duration'] / states.sensor.swx1_sensor.attributes['display_status']['progress'] - states.sensor.swx1_sensor.attributes['print_stats']['print_duration']) if states.sensor.swx1_sensor.attributes['display_status']['progress'] > 0 else 0)) | timestamp_custom("%H:%M:%S", 0) if is_state('sensor.swx1_sensor', 'OK') else None }} 74 | 75 | swx1_nozzletemp: 76 | unique_id: sensor.swx1_nozzletemp 77 | friendly_name: >- 78 | Nozzle 79 | {{ ["(shall ", (states.sensor.swx1_sensor.attributes['extruder']['target'] | float | round(1)), "°C)"] | join if states.sensor.swx1_sensor.attributes['display_status']['progress'] < 1 }} 80 | icon_template: >- 81 | {{ "mdi:printer-3d-nozzle-heat" if states.sensor.swx1_sensor.attributes['extruder']['target'] > 0 else "mdi:printer-3d-nozzle-heat-outline" }} 82 | value_template: >- 83 | {{ states.sensor.swx1_sensor.attributes['extruder']['temperature'] | float | round(1) if is_state('sensor.swx1_sensor', 'OK') else None }} 84 | 85 | swx1_bedtemp: 86 | unique_id: sensor.swx1_bedtemp 87 | friendly_name: >- 88 | Bed 89 | {{ ["(shall ", (states.sensor.swx1_sensor.attributes['heater_bed']['target'] | float | round(1)), "°C)"] | join if states.sensor.swx1_sensor.attributes['display_status']['progress'] < 1 }} 90 | icon_template: >- 91 | {{ "mdi:radiator" if states.sensor.swx1_sensor.attributes['extruder']['target'] > 0 else "mdi:radiator-off" }} 92 | value_template: >- 93 | {{ states.sensor.swx1_sensor.attributes['heater_bed']['temperature'] | float | round(1) if is_state('sensor.swx1_sensor', 'OK') else None }} 94 | # The following will allow you to control the power of devices configured in the "[power]" sections of moonraker 95 | # Make sure to change the `Printer` name below to the device name on your configuration 96 | # 97 | switch: 98 | - platform: rest 99 | name: SWX1_power 100 | resource: "http://192.168.178.66:7125/machine/device_power/device?device=SWX1" 101 | body_on: '{"action": "on"}' 102 | body_off: '{"action": "off"}' 103 | headers: 104 | Content-Type: 'application/json' 105 | is_on_template: >- 106 | {{ 'result' in value_json and (value_json.result.values() | list | first == "on") }} 107 | -------------------------------------------------------------------------------- /docs/example-home-assistant.yaml: -------------------------------------------------------------------------------- 1 | # Example Home Assistant configuration file for a Voron V0. 2 | # Credit to GitHub users @Kruppes and @pedrolamas 3 | # 4 | sensor: 5 | - platform: rest 6 | name: Voron_V0_sensor 7 | resource: "http://192.168.178.56:7125/printer/objects/query?heater_bed&extruder&print_stats&toolhead&display_status&virtual_sdcard" 8 | json_attributes_path: "$.result.status" 9 | json_attributes: 10 | - heater_bed 11 | - extruder 12 | - print_stats 13 | - toolhead 14 | - display_status 15 | - virtual_sdcard 16 | value_template: >- 17 | {{ 'OK' if ('result' in value_json) else None }} 18 | # Adding an API key is only necessary while using the [authorization] component 19 | # and if Home Assistant is not a trusted client 20 | headers: 21 | x-api-key: 123456789abcdefghijklmno 22 | 23 | - platform: template 24 | sensors: 25 | 26 | vzero_hotend_target: 27 | friendly_name: 'V0.126 Hotend Target' 28 | device_class: temperature 29 | unit_of_measurement: '°C' 30 | value_template: >- 31 | {{ states.sensor.voron_v0_sensor.attributes['extruder']['target'] | float | round(1) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 32 | 33 | vzero_hotend_actual: 34 | device_class: temperature 35 | unit_of_measurement: '°C' 36 | value_template: >- 37 | {{ states.sensor.voron_v0_sensor.attributes['extruder']['temperature'] | float | round(1) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 38 | 39 | vzero_bed_target: 40 | device_class: temperature 41 | unit_of_measurement: '°C' 42 | value_template: >- 43 | {{ states.sensor.voron_v0_sensor.attributes['heater_bed']['target'] | float | round(1) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 44 | 45 | vzero_bed_actual: 46 | device_class: temperature 47 | unit_of_measurement: '°C' 48 | value_template: >- 49 | {{ states.sensor.voron_v0_sensor.attributes['heater_bed']['temperature'] | float | round(1) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 50 | 51 | vzero_state: 52 | icon_template: mdi:printer-3d 53 | value_template: >- 54 | {{ states.sensor.voron_v0_sensor.attributes['print_stats']['state'] if is_state('sensor.voron_v0_sensor', 'OK') else None }} 55 | 56 | vzero_current_print: 57 | value_template: >- 58 | {{ states.sensor.voron_v0_sensor.attributes['print_stats']['filename'] if is_state('sensor.voron_v0_sensor', 'OK') else None }} 59 | 60 | vzero_current_progress: 61 | unit_of_measurement: '%' 62 | icon_template: mdi:file-percent 63 | value_template: >- 64 | {{ (states.sensor.voron_v0_sensor.attributes['display_status']['progress'] * 100) | round(1) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 65 | 66 | vzero_print_time: 67 | icon_template: mdi:clock-start 68 | value_template: >- 69 | {{ states.sensor.voron_v0_sensor.attributes['print_stats']['print_duration'] | timestamp_custom("%H:%M:%S", 0) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 70 | 71 | vzero_time_remaining: 72 | icon_template: mdi:clock-end 73 | value_template: >- 74 | {{ (((states.sensor.voron_v0_sensor.attributes['print_stats']['print_duration'] / states.sensor.voron_v0_sensor.attributes['display_status']['progress'] - states.sensor.voron_v0_sensor.attributes['print_stats']['print_duration']) if states.sensor.voron_v0_sensor.attributes['display_status']['progress'] > 0 else 0) | timestamp_custom('%H:%M:%S', 0)) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 75 | 76 | vzero_eta: 77 | icon_template: mdi:clock-outline 78 | value_template: >- 79 | {{ (as_timestamp(now()) + 2 * 60 * 60 + ((states.sensor.voron_v0_sensor.attributes['print_stats']['print_duration'] / states.sensor.voron_v0_sensor.attributes['display_status']['progress'] - states.sensor.voron_v0_sensor.attributes['print_stats']['print_duration']) if states.sensor.voron_v0_sensor.attributes['display_status']['progress'] > 0 else 0)) | timestamp_custom("%H:%M:%S", 0) if is_state('sensor.voron_v0_sensor', 'OK') else None }} 80 | 81 | vzero_nozzletemp: 82 | icon_template: mdi:thermometer 83 | value_template: >- 84 | {{ [(states.sensor.voron_v0_sensor.attributes['extruder']['temperature'] | float | round(1) | string), " / ", (states.sensor.voron_v0_sensor.attributes['extruder']['target'] | float | round(1) | string)] | join if is_state('sensor.voron_v0_sensor', 'OK') else None }} 85 | 86 | vzero_bedtemp: 87 | icon_template: mdi:thermometer 88 | value_template: >- 89 | {{ [(states.sensor.voron_v0_sensor.attributes['heater_bed']['temperature'] | float | round(1) | string), " / ", (states.sensor.voron_v0_sensor.attributes['heater_bed']['target'] | float | round(1) | string)] | join if is_state('sensor.voron_v0_sensor', 'OK') else None }} 90 | 91 | # The following will allow you to control the power of devices configured in the "[power]" sections of moonraker 92 | # Make sure to change the `Printer` name below to the device name on your configuration 93 | # 94 | switch: 95 | - platform: rest 96 | name: Voron_V0_power 97 | resource: "http://192.168.178.56:7125/machine/device_power/device?device=Printer" 98 | body_on: '{"action": "on"}' 99 | body_off: '{"action": "off"}' 100 | headers: 101 | Content-Type: 'application/json' 102 | is_on_template: >- 103 | {{ 'result' in value_json and (value_json.result.values() | list | first == "on") }} 104 | 105 | # MJPEG camera can be exposed to HA 106 | # 107 | camera: 108 | - platform: mjpeg 109 | name: Voron_V0_camera 110 | still_image_url: http://192.168.178.56/webcam/?action=snapshot 111 | mjpeg_url: http://192.168.178.56/webcam/?action=stream 112 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Moonraker Documentation 2 | 3 | Moonraker is a Python 3 based web server that exposes APIs with which 4 | client applications may use to interact with the 3D printing firmware 5 | [Klipper](https://github.com/Klipper3d/klipper). Communication between 6 | the Klippy host and Moonraker is done over a Unix Domain Socket. Tornado 7 | is used to provide Moonraker's server functionality. 8 | 9 | Users should refer to the [Installation](installation.md) and 10 | [Configuration](configuration.md) sections for documentation on how 11 | to install and configure Moonraker. 12 | 13 | Front end and other client developers may refer to the 14 | [External API](./external_api/introduction.md) 15 | documentation. 16 | 17 | Backend developers should refer to the 18 | [contributing](contributing.md) section for basic contribution 19 | guidelines prior to creating a pull request. The 20 | [components](components.md) document provides a brief overview 21 | of how to create a component and interact with Moonraker's 22 | primary internal APIs. 23 | -------------------------------------------------------------------------------- /docs/moonraker.conf: -------------------------------------------------------------------------------- 1 | # Sample Moonraker Configuration File 2 | # 3 | # !!! Moonraker does not load this file. See configuration.md !!! 4 | # !!! for details on path to Moonraker's configuration. !!! 5 | # 6 | 7 | [server] 8 | # Bind server defaults of 0.0.0.0, port 7125 9 | enable_debug_logging: False 10 | 11 | [authorization] 12 | enabled: True 13 | trusted_clients: 14 | # Enter your client IP here or range here 15 | 192.168.1.0/24 16 | cors_domains: 17 | # Allow CORS requests for Fluidd 18 | http://app.fluidd.xyz 19 | 20 | # Enable OctoPrint compatibility for Slicer uploads 21 | # Supports Cura, Slic3r, and Slic3r derivatives 22 | # (PrusaSlicer, SuperSlicer) 23 | [octoprint_compat] 24 | # Default webcam config values: 25 | # flip_h = false 26 | # flip_v = false 27 | # rotate_90 = false 28 | # stream_url = /webcam/?action=stream 29 | # webcam_enabled = true 30 | -------------------------------------------------------------------------------- /docs/pymdown-extras/collapse_code.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014 - 2023 Isaac Muse 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | # flake8: noqa 24 | 25 | """Collapsible code.""" 26 | import xml.etree.ElementTree as etree 27 | from markdown import util as mutil 28 | import re 29 | from pymdownx.blocks.block import Block 30 | from pymdownx.blocks import BlocksExtension 31 | 32 | # Fenced block placeholder for SuperFences 33 | FENCED_BLOCK_RE = re.compile( 34 | r'^([\> ]*){}({}){}$'.format( 35 | mutil.HTML_PLACEHOLDER[0], 36 | mutil.HTML_PLACEHOLDER[1:-1] % r'([0-9]+)', 37 | mutil.HTML_PLACEHOLDER[-1] 38 | ) 39 | ) 40 | 41 | 42 | class CollapseCode(Block): 43 | """Collapse code.""" 44 | 45 | NAME = 'collapse-code' 46 | 47 | def on_init(self): 48 | """Handle initialization.""" 49 | 50 | # Track tab group count across the entire page. 51 | if 'collapse_code_count' not in self.tracker: 52 | self.tracker['collapse_code_count'] = 0 53 | 54 | self.expand = self.config['expand_text'] 55 | if not isinstance(self.expand, str): 56 | raise ValueError("'expand_text' must be a string") 57 | 58 | self.collapse = self.config['collapse_text'] 59 | if not isinstance(self.collapse, str): 60 | raise ValueError("'collapse_text' must be a string") 61 | 62 | self.expand_title = self.config['expand_title'] 63 | if not isinstance(self.expand_title, str): 64 | raise ValueError("'expand_title' must be a string") 65 | 66 | self.collapse_title = self.config['collapse_title'] 67 | if not isinstance(self.collapse_title, str): 68 | raise ValueError("'collapse_title' must be a string") 69 | 70 | def on_create(self, parent): 71 | """Create the element.""" 72 | 73 | self.count = self.tracker['collapse_code_count'] 74 | self.tracker['collapse_code_count'] += 1 75 | el = etree.SubElement(parent, 'div', {'class': 'collapse-code'}) 76 | etree.SubElement( 77 | el, 78 | 'input', 79 | { 80 | "type": "checkbox", 81 | "id": "__collapse{}".format(self.count), 82 | "name": "__collapse{}".format(self.count), 83 | 'checked': 'checked' 84 | } 85 | ) 86 | return el 87 | 88 | def on_end(self, block): 89 | """Convert non list items to details.""" 90 | 91 | el = etree.SubElement(block, 'div', {'class': 'code-footer'}) 92 | attrs = {'for': '__collapse{}'.format(self.count), 'class': 'expand', 'tabindex': '0'} 93 | if self.expand_title: 94 | attrs['title'] = self.expand_title 95 | expand = etree.SubElement(el, 'label', attrs) 96 | expand.text = self.expand 97 | 98 | attrs = {'for': '__collapse{}'.format(self.count), 'class': 'collapse', 'tabindex': '0'} 99 | if self.collapse_title: 100 | attrs['title'] = self.collapse_title 101 | collapse = etree.SubElement(el, 'label', attrs) 102 | collapse.text = self.collapse 103 | 104 | 105 | class CollapseCodeExtension(BlocksExtension): 106 | """Admonition Blocks Extension.""" 107 | 108 | def __init__(self, *args, **kwargs): 109 | """Initialize.""" 110 | 111 | self.config = { 112 | 'expand_text': ['Expand', "Set the text for the expand button."], 113 | 'collapse_text': ['Collapse', "Set the text for the collapse button."], 114 | 'expand_title': ['expand', "Set the text for the expand title."], 115 | 'collapse_title': ['collapse', "Set the text for the collapse title."] 116 | } 117 | 118 | super().__init__(*args, **kwargs) 119 | 120 | def extendMarkdownBlocks(self, md, blocks): 121 | """Extend Markdown blocks.""" 122 | 123 | blocks.register(CollapseCode, self.getConfigs()) 124 | 125 | 126 | def makeExtension(*args, **kwargs): 127 | """Return extension.""" 128 | 129 | return CollapseCodeExtension(*args, **kwargs) 130 | -------------------------------------------------------------------------------- /docs/pymdown-extras/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup( 3 | name='pymdown_extras', 4 | version='1.0.0', 5 | py_modules=['collapse_code'], 6 | install_requires=['pymdown-extensions>=10.7'], 7 | ) 8 | -------------------------------------------------------------------------------- /docs/src/js/compact-tables-qqTQvuZ9.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var t;t=function(){for(var t=document.querySelectorAll("tbody"),e=0;e {\n const main = () => {\n // Iterate through all tables looking for containers. This\n // works without assigning IDs to everything.\n const tbody_list = document.querySelectorAll(\"tbody\")\n for (let i = 0; i < tbody_list.length; i++) {\n const tbody = tbody_list[i]\n const row_list = tbody.children\n for (let j = 1; j < row_list.length; j++) {\n const cur_row = row_list[j]\n if (cur_row.hasAttribute(\"compact-container\")) {\n const control_row = row_list[j-1]\n cur_row.classList.add(\"table-container\")\n control_row.classList.add(\"table-container-ctrl\")\n control_row.addEventListener(\"click\", (event) => {\n if (control_row.hasAttribute(\"open\")) {\n cur_row.removeAttribute(\"open\")\n control_row.removeAttribute(\"open\")\n } else {\n cur_row.setAttribute(\"open\", \"\")\n control_row.setAttribute(\"open\", \"\")\n }\n })\n }\n }\n }\n }\n\n if (window.document$) {\n // Material specific hook\n window.document$.subscribe(main)\n } else {\n // Normal non-Material specific hook\n document.addEventListener(\"DOMContentLoaded\", main)\n }\n\n})()"],"names":["main","tbody_list","document","querySelectorAll","i","length","row_list","children","_loop","cur_row","j","hasAttribute","control_row","classList","add","addEventListener","event","removeAttribute","setAttribute","window","document$","subscribe"],"mappings":"yBAAA,IACUA,IAAO,WAIT,IADA,IAAMC,EAAaC,SAASC,iBAAiB,SACpCC,EAAI,EAAGA,EAAIH,EAAWI,OAAQD,IAGnC,IAFA,IACME,EADQL,EAAWG,GACFG,SAAQC,EAAAA,WAE3B,IAAMC,EAAUH,EAASI,GACzB,GAAID,EAAQE,aAAa,qBAAsB,CAC3C,IAAMC,EAAcN,EAASI,EAAE,GAC/BD,EAAQI,UAAUC,IAAI,mBACtBF,EAAYC,UAAUC,IAAI,wBAC1BF,EAAYG,iBAAiB,SAAS,SAACC,GAC/BJ,EAAYD,aAAa,SACzBF,EAAQQ,gBAAgB,QACxBL,EAAYK,gBAAgB,UAE5BR,EAAQS,aAAa,OAAQ,IAC7BN,EAAYM,aAAa,OAAQ,IAEzC,GACJ,GAfKR,EAAI,EAAGA,EAAIJ,EAASD,OAAQK,IAAGF,KAoB5CW,OAAOC,UAEPD,OAAOC,UAAUC,UAAUrB,GAG3BE,SAASa,iBAAiB,mBAAoBf"} -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Moonraker 2 | site_url: https://moonraker.readthedocs.io 3 | repo_url: https://github.com/Arksine/moonraker 4 | edit_uri: blob/master/docs/ 5 | nav: 6 | - Installation: installation.md 7 | - Configuration : configuration.md 8 | - Developer Documentation: 9 | - External API: 10 | - Introduction: external_api/introduction.md 11 | - Server Administration: external_api/server.md 12 | - Printer Administration: external_api/printer.md 13 | - System Administration: external_api/machine.md 14 | - File Management: external_api/file_manager.md 15 | - Authorization and Authentication: external_api/authorization.md 16 | - Database Management: external_api/database.md 17 | - Job Queue Management: external_api/job_queue.md 18 | - Job History Management: external_api/history.md 19 | - Announcements: external_api/announcements.md 20 | - Webcam Management: external_api/webcams.md 21 | - Update Management: external_api/update_manager.md 22 | - Switches, Sensors, and Devices: external_api/devices.md 23 | - Third Party Integrations: external_api/integrations.md 24 | - Extensions: external_api/extensions.md 25 | - JSON-RPC Notifications: external_api/jsonrpc_notifications.md 26 | - Printer Objects: printer_objects.md 27 | - Components: components.md 28 | - Contribution Guidelines: contributing.md 29 | - Changelog: changelog.md 30 | theme: 31 | name: material 32 | palette: 33 | - scheme: default 34 | primary: blue grey 35 | accent: light blue 36 | toggle: 37 | icon: material/weather-sunny 38 | name: Switch to Dark Mode 39 | - scheme: slate 40 | primary: black 41 | accent: light blue 42 | toggle: 43 | icon: material/weather-night 44 | name: Switch to Light Mode 45 | font: 46 | text: Roboto 47 | code: Roboto Mono 48 | features: 49 | - navigation.top 50 | - navigation.instant 51 | - navigation.indexes 52 | - navigation.expand 53 | - toc.follow 54 | - content.tabs.link 55 | - search.share 56 | - search.highlight 57 | - search.suggest 58 | - content.code.copy 59 | - content.code.annotations 60 | plugins: 61 | - search 62 | markdown_extensions: 63 | - abbr 64 | - admonition 65 | - attr_list 66 | - def_list 67 | - footnotes 68 | - md_in_html 69 | - toc: 70 | permalink: true 71 | - pymdownx.arithmatex: 72 | generic: true 73 | - pymdownx.betterem: 74 | smart_enable: all 75 | - pymdownx.caret 76 | - pymdownx.details 77 | - pymdownx.emoji: 78 | emoji_index: !!python/name:material.extensions.emoji.twemoji 79 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 80 | - pymdownx.highlight 81 | - pymdownx.inlinehilite 82 | - pymdownx.keys 83 | - pymdownx.mark 84 | - pymdownx.smartsymbols 85 | - pymdownx.superfences 86 | - pymdownx.tabbed: 87 | alternate_style: true 88 | - pymdownx.tasklist: 89 | custom_checkbox: true 90 | - pymdownx.tilde 91 | - pymdownx.saneheaders 92 | - pymdownx.blocks.admonition 93 | - pymdownx.blocks.details: 94 | types: 95 | - name: details-new 96 | class: new 97 | - name: details-settings 98 | class: settings 99 | - name: details-note 100 | class: note 101 | - name: details-abstract 102 | class: abstract 103 | - name: details-info 104 | class: info 105 | - name: details-tip 106 | class: tip 107 | - name: details-success 108 | class: success 109 | - name: details-question 110 | class: question 111 | - name: details-warning 112 | class: warning 113 | - name: details-failure 114 | class: failure 115 | - name: details-danger 116 | class: danger 117 | - name: details-bug 118 | class: bug 119 | - name: details-example 120 | class: example 121 | - name: details-quote 122 | class: quote 123 | - name: api-example-response 124 | class: example 125 | title: "Response Example" 126 | - name: api-response-spec 127 | class: info 128 | title: "Response Specification" 129 | - name: api-parameters 130 | class: info 131 | title: "Parameters" 132 | - name: api-notification-spec 133 | class: info 134 | title: "Notification Parameter Specification" 135 | - tables 136 | - compact_tables: 137 | auto_insert_break: false 138 | - collapse_code: 139 | expand_text: '' 140 | collapse_text: '' 141 | extra_css: 142 | - src/css/extra-950ac449d4.css 143 | extra_javascript: 144 | - src/js/compact-tables-qqTQvuZ9.js 145 | -------------------------------------------------------------------------------- /moonraker/__init__.py: -------------------------------------------------------------------------------- 1 | # Top level package definition for Moonraker 2 | # 3 | # Copyright (C) 2022 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | -------------------------------------------------------------------------------- /moonraker/__main__.py: -------------------------------------------------------------------------------- 1 | # Package entry point for Moonraker 2 | # 3 | # Copyright (C) 2022 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from .server import main 8 | 9 | main() 10 | -------------------------------------------------------------------------------- /moonraker/assets/__init__.py: -------------------------------------------------------------------------------- 1 | # Assets Package Definition 2 | -------------------------------------------------------------------------------- /moonraker/assets/default_allowed_services: -------------------------------------------------------------------------------- 1 | klipper_mcu 2 | webcamd 3 | MoonCord 4 | KlipperScreen 5 | moonraker-telegram-bot 6 | moonraker-obico 7 | sonar 8 | crowsnest 9 | octoeverywhere 10 | ratos-configurator 11 | -------------------------------------------------------------------------------- /moonraker/components/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # Package definition for the components directory 3 | # 4 | # Copyright (C) 2020 Eric Callahan 5 | # 6 | # This file may be distributed under the terms of the GNU GPLv3 license. 7 | -------------------------------------------------------------------------------- /moonraker/components/button.py: -------------------------------------------------------------------------------- 1 | # Support for GPIO Button actions 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | from __future__ import annotations 7 | import asyncio 8 | import logging 9 | 10 | from typing import ( 11 | TYPE_CHECKING, 12 | Any, 13 | Dict 14 | ) 15 | if TYPE_CHECKING: 16 | from ..confighelper import ConfigHelper 17 | from .application import InternalTransport as ITransport 18 | 19 | 20 | class ButtonManager: 21 | def __init__(self, config: ConfigHelper) -> None: 22 | self.server = config.get_server() 23 | self.buttons: Dict[str, GpioButton] = {} 24 | prefix_sections = config.get_prefix_sections("button") 25 | logging.info(f"Loading Buttons: {prefix_sections}") 26 | for section in prefix_sections: 27 | cfg = config[section] 28 | # Reserve the "type" option for future use 29 | btn_type = cfg.get('type', "gpio") # noqa: F841 30 | try: 31 | btn = GpioButton(cfg) 32 | except Exception as e: 33 | msg = f"Failed to load button [{cfg.get_name()}]\n{e}" 34 | self.server.add_warning(msg, exc_info=e) 35 | continue 36 | self.buttons[btn.name] = btn 37 | self.server.register_notification("button:button_event") 38 | 39 | def component_init(self) -> None: 40 | for btn in self.buttons.values(): 41 | btn.initialize() 42 | 43 | class GpioButton: 44 | def __init__(self, config: ConfigHelper) -> None: 45 | self.server = config.get_server() 46 | self.eventloop = self.server.get_event_loop() 47 | self.name = config.get_name().split()[-1] 48 | self.itransport: ITransport = self.server.lookup_component("internal_transport") 49 | self.mutex = asyncio.Lock() 50 | self.gpio_event = config.getgpioevent("pin", self._on_gpio_event) 51 | self.min_event_time = config.getfloat("minimum_event_time", 0, minval=0.0) 52 | debounce_period = config.getfloat("debounce_period", .05, minval=0.01) 53 | self.gpio_event.setup_debounce(debounce_period, self._on_gpio_error) 54 | self.press_template = config.gettemplate("on_press", None, is_async=True) 55 | self.release_template = config.gettemplate("on_release", None, is_async=True) 56 | if ( 57 | self.press_template is None and 58 | self.release_template is None 59 | ): 60 | raise config.error( 61 | f"[{config.get_name()}]: No template option configured" 62 | ) 63 | self.notification_sent: bool = False 64 | self.user_data: Dict[str, Any] = {} 65 | self.context: Dict[str, Any] = { 66 | 'call_method': self.itransport.call_method, 67 | 'send_notification': self._send_notification, 68 | 'event': { 69 | 'elapsed_time': 0., 70 | 'received_time': 0., 71 | 'render_time': 0., 72 | 'pressed': False, 73 | }, 74 | 'user_data': self.user_data 75 | } 76 | 77 | def initialize(self) -> None: 78 | self.gpio_event.start() 79 | self.context['event']['pressed'] = bool(self.gpio_event.get_value()) 80 | 81 | def get_status(self) -> Dict[str, Any]: 82 | return { 83 | 'name': self.name, 84 | 'type': "gpio", 85 | 'event': self.context['event'], 86 | } 87 | 88 | def _send_notification(self, result: Any = None) -> None: 89 | if self.notification_sent: 90 | # Only allow execution once per template 91 | return 92 | self.notification_sent = True 93 | data = self.get_status() 94 | data['aux'] = result 95 | self.server.send_event("button:button_event", data) 96 | 97 | async def _on_gpio_event( 98 | self, eventtime: float, elapsed_time: float, pressed: int 99 | ) -> None: 100 | if elapsed_time < self.min_event_time: 101 | return 102 | template = self.press_template if pressed else self.release_template 103 | if template is None: 104 | return 105 | async with self.mutex: 106 | self.notification_sent = False 107 | event_info: Dict[str, Any] = { 108 | 'elapsed_time': elapsed_time, 109 | 'received_time': eventtime, 110 | 'render_time': self.eventloop.get_loop_time(), 111 | 'pressed': bool(pressed) 112 | } 113 | self.context['event'] = event_info 114 | try: 115 | await template.render_async(self.context) 116 | except Exception: 117 | action = "on_press" if pressed else "on_release" 118 | logging.exception( 119 | f"Button {self.name}: '{action}' template error") 120 | 121 | def _on_gpio_error(self, message: str) -> None: 122 | self.server.add_warning(f"Button {self.name}: {message}") 123 | 124 | def load_component(config: ConfigHelper) -> ButtonManager: 125 | return ButtonManager(config) 126 | -------------------------------------------------------------------------------- /moonraker/components/data_store.py: -------------------------------------------------------------------------------- 1 | # Klipper data logging and storage storage 2 | # 3 | # Copyright (C) 2020 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import logging 9 | import time 10 | from collections import deque 11 | from ..common import RequestType 12 | 13 | # Annotation imports 14 | from typing import ( 15 | TYPE_CHECKING, 16 | Any, 17 | Optional, 18 | Dict, 19 | List, 20 | Deque, 21 | ) 22 | if TYPE_CHECKING: 23 | from ..confighelper import ConfigHelper 24 | from ..common import WebRequest 25 | from .klippy_connection import KlippyConnection 26 | from .klippy_apis import KlippyAPI as APIComp 27 | GCQueue = Deque[Dict[str, Any]] 28 | TempStore = Dict[str, Dict[str, Deque[Optional[float]]]] 29 | 30 | TEMP_UPDATE_TIME = 1. 31 | 32 | def _round_null(val: Optional[float], ndigits: int) -> Optional[float]: 33 | if val is None: 34 | return val 35 | return round(val, ndigits) 36 | 37 | class DataStore: 38 | def __init__(self, config: ConfigHelper) -> None: 39 | self.server = config.get_server() 40 | self.temp_store_size = config.getint('temperature_store_size', 1200) 41 | self.gcode_store_size = config.getint('gcode_store_size', 1000) 42 | 43 | # Temperature Store Tracking 44 | kconn: KlippyConnection = self.server.lookup_component("klippy_connection") 45 | self.subscription_cache = kconn.get_subscription_cache() 46 | self.gcode_queue: GCQueue = deque(maxlen=self.gcode_store_size) 47 | self.temperature_store: TempStore = {} 48 | self.temp_monitors: List[str] = [] 49 | eventloop = self.server.get_event_loop() 50 | self.temp_update_timer = eventloop.register_timer( 51 | self._update_temperature_store) 52 | 53 | self.server.register_event_handler( 54 | "server:gcode_response", self._update_gcode_store) 55 | self.server.register_event_handler( 56 | "server:klippy_ready", self._init_sensors) 57 | self.server.register_event_handler( 58 | "klippy_connection:gcode_received", self._store_gcode_command 59 | ) 60 | 61 | # Register endpoints 62 | self.server.register_endpoint( 63 | "/server/temperature_store", RequestType.GET, 64 | self._handle_temp_store_request 65 | ) 66 | self.server.register_endpoint( 67 | "/server/gcode_store", RequestType.GET, 68 | self._handle_gcode_store_request 69 | ) 70 | 71 | async def _init_sensors(self) -> None: 72 | klippy_apis: APIComp = self.server.lookup_component('klippy_apis') 73 | # Fetch sensors 74 | try: 75 | result: Dict[str, Any] 76 | result = await klippy_apis.query_objects({'heaters': None}) 77 | except self.server.error as e: 78 | logging.info(f"Error Configuring Sensors: {e}") 79 | return 80 | heaters: Dict[str, List[str]] = result.get("heaters", {}) 81 | sensors = heaters.get("available_sensors", []) 82 | self.temp_monitors = heaters.get("available_monitors", []) 83 | sensors.extend(self.temp_monitors) 84 | 85 | if sensors: 86 | # Add Subscription 87 | sub: Dict[str, Optional[List[str]]] = {s: None for s in sensors} 88 | try: 89 | status: Dict[str, Any] 90 | status = await klippy_apis.subscribe_objects(sub) 91 | except self.server.error as e: 92 | logging.info(f"Error subscribing to sensors: {e}") 93 | return 94 | logging.info(f"Configuring available sensors: {sensors}") 95 | new_store: TempStore = {} 96 | valid_fields = ("temperature", "target", "power", "speed") 97 | for sensor in sensors: 98 | reported_fields = [ 99 | f for f in list(status.get(sensor, {}).keys()) if f in valid_fields 100 | ] 101 | if not reported_fields: 102 | logging.info(f"No valid fields reported for sensor: {sensor}") 103 | self.temperature_store.pop(sensor, None) 104 | continue 105 | if sensor in self.temperature_store: 106 | new_store[sensor] = self.temperature_store[sensor] 107 | for field in list(new_store[sensor].keys()): 108 | if field not in reported_fields: 109 | new_store[sensor].pop(field, None) 110 | else: 111 | initial_val: Optional[float] 112 | initial_val = _round_null(status[sensor][field], 2) 113 | new_store[sensor][field].append(initial_val) 114 | else: 115 | new_store[sensor] = {} 116 | for field in reported_fields: 117 | if field not in new_store[sensor]: 118 | initial_val = _round_null(status[sensor][field], 2) 119 | new_store[sensor][field] = deque( 120 | [initial_val], maxlen=self.temp_store_size 121 | ) 122 | self.temperature_store = new_store 123 | self.temp_update_timer.start(delay=1.) 124 | else: 125 | logging.info("No sensors found") 126 | self.temperature_store = {} 127 | self.temp_monitors = [] 128 | self.temp_update_timer.stop() 129 | 130 | def _update_temperature_store(self, eventtime: float) -> float: 131 | for sensor_name, sensor in self.temperature_store.items(): 132 | sdata: Dict[str, Any] = self.subscription_cache.get(sensor_name, {}) 133 | for field, store in sensor.items(): 134 | store.append(_round_null(sdata.get(field, store[-1]), 2)) 135 | return eventtime + TEMP_UPDATE_TIME 136 | 137 | async def _handle_temp_store_request( 138 | self, web_request: WebRequest 139 | ) -> Dict[str, Dict[str, List[Optional[float]]]]: 140 | include_monitors = web_request.get_boolean("include_monitors", False) 141 | store = {} 142 | for name, sensor in self.temperature_store.items(): 143 | if not include_monitors and name in self.temp_monitors: 144 | continue 145 | store[name] = {f"{k}s": list(v) for k, v in sensor.items()} 146 | return store 147 | 148 | async def close(self) -> None: 149 | self.temp_update_timer.stop() 150 | 151 | def _update_gcode_store(self, response: str) -> None: 152 | curtime = time.time() 153 | self.gcode_queue.append( 154 | {'message': response, 'time': curtime, 'type': "response"}) 155 | 156 | def _store_gcode_command(self, script: str) -> None: 157 | curtime = time.time() 158 | if script.strip(): 159 | self.gcode_queue.append( 160 | {'message': script, 'time': curtime, 'type': "command"} 161 | ) 162 | 163 | async def _handle_gcode_store_request(self, 164 | web_request: WebRequest 165 | ) -> Dict[str, List[Dict[str, Any]]]: 166 | count = web_request.get_int("count", None) 167 | if count is not None: 168 | gc_responses = list(self.gcode_queue)[-count:] 169 | else: 170 | gc_responses = list(self.gcode_queue) 171 | return {'gcode_store': gc_responses} 172 | 173 | def load_component(config: ConfigHelper) -> DataStore: 174 | return DataStore(config) 175 | -------------------------------------------------------------------------------- /moonraker/components/dbus_manager.py: -------------------------------------------------------------------------------- 1 | # DBus Connection Management 2 | # 3 | # Copyright (C) 2022 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | from __future__ import annotations 7 | import os 8 | import asyncio 9 | import pathlib 10 | import logging 11 | import dbus_fast 12 | from dbus_fast.aio import MessageBus, ProxyInterface 13 | from dbus_fast.constants import BusType 14 | 15 | # Annotation imports 16 | from typing import ( 17 | TYPE_CHECKING, 18 | List, 19 | Optional, 20 | Any, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | from ..confighelper import ConfigHelper 25 | 26 | STAT_PATH = "/proc/self/stat" 27 | DOC_URL = ( 28 | "https://moonraker.readthedocs.io/en/latest/" 29 | "installation/#policykit-permissions" 30 | ) 31 | 32 | class DbusManager: 33 | Variant = dbus_fast.Variant 34 | DbusError = dbus_fast.errors.DBusError 35 | def __init__(self, config: ConfigHelper) -> None: 36 | self.server = config.get_server() 37 | self.bus: Optional[MessageBus] = None 38 | self.polkit: Optional[ProxyInterface] = None 39 | self.warned: bool = False 40 | st_path = pathlib.Path(STAT_PATH) 41 | self.polkit_subject: List[Any] = [] 42 | if not st_path.is_file(): 43 | return 44 | proc_data = st_path.read_text() 45 | start_clk_ticks = int(proc_data.split()[21]) 46 | self.polkit_subject = [ 47 | "unix-process", 48 | { 49 | "pid": dbus_fast.Variant("u", os.getpid()), 50 | "start-time": dbus_fast.Variant("t", start_clk_ticks) 51 | } 52 | ] 53 | 54 | def is_connected(self) -> bool: 55 | return self.bus is not None and self.bus.connected 56 | 57 | async def component_init(self) -> None: 58 | try: 59 | self.bus = MessageBus(bus_type=BusType.SYSTEM) 60 | await self.bus.connect() 61 | except asyncio.CancelledError: 62 | raise 63 | except Exception: 64 | logging.info("Unable to Connect to D-Bus") 65 | return 66 | # Make sure that all required actions are register 67 | try: 68 | self.polkit = await self.get_interface( 69 | "org.freedesktop.PolicyKit1", 70 | "/org/freedesktop/PolicyKit1/Authority", 71 | "org.freedesktop.PolicyKit1.Authority") 72 | except asyncio.CancelledError: 73 | raise 74 | except Exception as e: 75 | if self.server.is_debug_enabled(): 76 | logging.exception("Failed to get PolKit interface") 77 | else: 78 | logging.info(f"Failed to get PolKit interface: {e}") 79 | self.polkit = None 80 | 81 | async def check_permission(self, 82 | action: str, 83 | err_msg: str = "" 84 | ) -> bool: 85 | if self.polkit is None: 86 | self.server.add_warning( 87 | "Unable to find DBus PolKit Interface, this suggests PolKit " 88 | "is not installed on your OS.", 89 | "dbus_polkit" 90 | ) 91 | return False 92 | try: 93 | ret = await self.polkit.call_check_authorization( # type: ignore 94 | self.polkit_subject, action, {}, 0, "") 95 | except asyncio.CancelledError: 96 | raise 97 | except Exception as e: 98 | self._check_warned() 99 | self.server.add_warning( 100 | f"Error checking authorization for action [{action}]: {e}. " 101 | "This suggests that a dependency is not installed or " 102 | f"up to date. {err_msg}.") 103 | return False 104 | if not ret[0]: 105 | self._check_warned() 106 | self.server.add_warning( 107 | "Moonraker not authorized for PolicyKit action: " 108 | f"[{action}], {err_msg}") 109 | return ret[0] 110 | 111 | def _check_warned(self): 112 | if not self.warned: 113 | self.server.add_warning( 114 | f"PolKit warnings detected. See {DOC_URL} for instructions " 115 | "on how to resolve.") 116 | self.warned = True 117 | 118 | async def get_interface(self, 119 | bus_name: str, 120 | bus_path: str, 121 | interface_name: str 122 | ) -> ProxyInterface: 123 | ret = await self.get_interfaces(bus_name, bus_path, 124 | [interface_name]) 125 | return ret[0] 126 | 127 | async def get_interfaces(self, 128 | bus_name: str, 129 | bus_path: str, 130 | interface_names: List[str] 131 | ) -> List[ProxyInterface]: 132 | if self.bus is None: 133 | raise self.server.error("Bus not avaialable") 134 | interfaces: List[ProxyInterface] = [] 135 | introspection = await self.bus.introspect(bus_name, bus_path) 136 | proxy_obj = self.bus.get_proxy_object(bus_name, bus_path, 137 | introspection) 138 | for ifname in interface_names: 139 | intf = proxy_obj.get_interface(ifname) 140 | interfaces.append(intf) 141 | return interfaces 142 | 143 | async def close(self): 144 | if self.bus is not None and self.bus.connected: 145 | self.bus.disconnect() 146 | await self.bus.wait_for_disconnect() 147 | 148 | 149 | def load_component(config: ConfigHelper) -> DbusManager: 150 | return DbusManager(config) 151 | -------------------------------------------------------------------------------- /moonraker/components/file_manager/__init__.py: -------------------------------------------------------------------------------- 1 | # Package definition for the file_manager 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | from . import file_manager as fm 9 | 10 | from typing import TYPE_CHECKING 11 | if TYPE_CHECKING: 12 | from ...confighelper import ConfigHelper 13 | 14 | def load_component(config: ConfigHelper) -> fm.FileManager: 15 | return fm.load_component(config) 16 | -------------------------------------------------------------------------------- /moonraker/components/job_state.py: -------------------------------------------------------------------------------- 1 | # Klippy job state event handlers 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import logging 9 | 10 | # Annotation imports 11 | from typing import ( 12 | TYPE_CHECKING, 13 | Any, 14 | Optional, 15 | Dict, 16 | List, 17 | ) 18 | from ..common import JobEvent, KlippyState 19 | if TYPE_CHECKING: 20 | from ..confighelper import ConfigHelper 21 | from .klippy_apis import KlippyAPI 22 | 23 | class JobState: 24 | def __init__(self, config: ConfigHelper) -> None: 25 | self.server = config.get_server() 26 | self.last_print_stats: Dict[str, Any] = {} 27 | self.last_event: JobEvent = JobEvent.STANDBY 28 | self.server.register_event_handler( 29 | "server:klippy_started", self._handle_started 30 | ) 31 | self.server.register_event_handler( 32 | "server:klippy_disconnect", self._handle_disconnect 33 | ) 34 | 35 | def _handle_disconnect(self): 36 | state = self.last_print_stats.get("state", "") 37 | if state in ("printing", "paused"): 38 | # set error state 39 | self.last_print_stats["state"] = "error" 40 | self.last_event = JobEvent.ERROR 41 | 42 | async def _handle_started(self, state: KlippyState) -> None: 43 | if state != KlippyState.READY: 44 | return 45 | kapis: KlippyAPI = self.server.lookup_component('klippy_apis') 46 | sub: Dict[str, Optional[List[str]]] = {"print_stats": None} 47 | try: 48 | result = await kapis.subscribe_objects(sub, self._status_update) 49 | except self.server.error: 50 | logging.info("Error subscribing to print_stats") 51 | self.last_print_stats = result.get("print_stats", {}) 52 | if "state" in self.last_print_stats: 53 | state = self.last_print_stats["state"] 54 | logging.info(f"Job state initialized: {state}") 55 | 56 | async def _status_update(self, data: Dict[str, Any], _: float) -> None: 57 | if 'print_stats' not in data: 58 | return 59 | ps = data['print_stats'] 60 | if "state" in ps: 61 | prev_ps = dict(self.last_print_stats) 62 | old_state: str = prev_ps['state'] 63 | new_state: str = ps['state'] 64 | new_ps = dict(self.last_print_stats) 65 | new_ps.update(ps) 66 | if new_state is not old_state: 67 | if new_state == "printing": 68 | # The "printing" state needs some special handling 69 | # to detect "resets" and a transition from pause to resume 70 | if self._check_resumed(prev_ps, new_ps): 71 | new_state = "resumed" 72 | else: 73 | logging.info( 74 | f"Job Started: {new_ps['filename']}" 75 | ) 76 | new_state = "started" 77 | logging.debug( 78 | f"Job State Changed - Prev State: {old_state}, " 79 | f"New State: {new_state}" 80 | ) 81 | # NOTE: Individual job_state events are DEPRECATED. New modules 82 | # should register handlers for "job_state: status_changed" and 83 | # match against the JobEvent object provided. 84 | self.server.send_event(f"job_state:{new_state}", prev_ps, new_ps) 85 | self.last_event = JobEvent.from_string(new_state) 86 | self.server.send_event( 87 | "job_state:state_changed", 88 | self.last_event, 89 | prev_ps, 90 | new_ps 91 | ) 92 | if "info" in ps: 93 | cur_layer: Optional[int] = ps["info"].get("current_layer") 94 | if cur_layer is not None: 95 | total: int = ps["info"].get("total_layer", 0) 96 | self.server.send_event( 97 | "job_state:layer_changed", cur_layer, total 98 | ) 99 | self.last_print_stats.update(ps) 100 | 101 | def _check_resumed(self, 102 | prev_ps: Dict[str, Any], 103 | new_ps: Dict[str, Any] 104 | ) -> bool: 105 | return ( 106 | prev_ps['state'] == "paused" and 107 | prev_ps['filename'] == new_ps['filename'] and 108 | prev_ps['total_duration'] < new_ps['total_duration'] 109 | ) 110 | 111 | def get_last_stats(self) -> Dict[str, Any]: 112 | return dict(self.last_print_stats) 113 | 114 | def get_last_job_event(self) -> JobEvent: 115 | return self.last_event 116 | 117 | def load_component(config: ConfigHelper) -> JobState: 118 | return JobState(config) 119 | -------------------------------------------------------------------------------- /moonraker/components/ldap.py: -------------------------------------------------------------------------------- 1 | # LDAP authentication for Moonraker 2 | # 3 | # Copyright (C) 2022 Eric Callahan 4 | # Copyright (C) 2022 Luca Schöneberg 5 | # 6 | # This file may be distributed under the terms of the GNU GPLv3 license 7 | 8 | from __future__ import annotations 9 | import asyncio 10 | import logging 11 | import ldap3 12 | from ldap3.core.exceptions import LDAPExceptionError 13 | 14 | # Annotation imports 15 | from typing import ( 16 | TYPE_CHECKING, 17 | Optional 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from ..confighelper import ConfigHelper 22 | from ldap3.abstract.entry import Entry 23 | 24 | class MoonrakerLDAP: 25 | def __init__(self, config: ConfigHelper) -> None: 26 | self.server = config.get_server() 27 | self.ldap_host = config.get('ldap_host') 28 | self.ldap_port = config.getint("ldap_port", None) 29 | self.ldap_secure = config.getboolean("ldap_secure", False) 30 | base_dn_template = config.gettemplate('base_dn') 31 | self.base_dn = base_dn_template.render() 32 | self.group_dn: Optional[str] = None 33 | group_dn_template = config.gettemplate("group_dn", None) 34 | if group_dn_template is not None: 35 | self.group_dn = group_dn_template.render() 36 | self.active_directory = config.getboolean('is_active_directory', False) 37 | self.bind_dn: Optional[str] = None 38 | self.bind_password: Optional[str] = None 39 | bind_dn_template = config.gettemplate('bind_dn', None) 40 | bind_pass_template = config.gettemplate('bind_password', None) 41 | if bind_dn_template is not None: 42 | self.bind_dn = bind_dn_template.render() 43 | if bind_pass_template is None: 44 | raise config.error( 45 | "Section [ldap]: Option 'bind_password' is " 46 | "required when 'bind_dn' is provided" 47 | ) 48 | self.bind_password = bind_pass_template.render() 49 | self.user_filter: Optional[str] = None 50 | user_filter_template = config.gettemplate('user_filter', None) 51 | if user_filter_template is not None: 52 | self.user_filter = user_filter_template.render() 53 | if "USERNAME" not in self.user_filter: 54 | raise config.error( 55 | "Section [ldap]: Option 'user_filter' is " 56 | "is missing required token USERNAME" 57 | ) 58 | self.lock = asyncio.Lock() 59 | 60 | async def authenticate_ldap_user(self, username, password) -> None: 61 | eventloop = self.server.get_event_loop() 62 | async with self.lock: 63 | await eventloop.run_in_thread( 64 | self._perform_ldap_auth, username, password 65 | ) 66 | 67 | def _perform_ldap_auth(self, username, password) -> None: 68 | server = ldap3.Server( 69 | self.ldap_host, self.ldap_port, use_ssl=self.ldap_secure, 70 | connect_timeout=10. 71 | ) 72 | conn_args = { 73 | "user": self.bind_dn, 74 | "password": self.bind_password, 75 | "auto_bind": ldap3.AUTO_BIND_NO_TLS, 76 | } 77 | attr_name = "sAMAccountName" if self.active_directory else "uid" 78 | ldfilt = f"(&(objectClass=Person)({attr_name}={username}))" 79 | if self.user_filter: 80 | ldfilt = self.user_filter.replace("USERNAME", username) 81 | try: 82 | with ldap3.Connection(server, **conn_args) as conn: 83 | ret = conn.search( 84 | self.base_dn, ldfilt, attributes=["memberOf"] 85 | ) 86 | if not ret: 87 | raise self.server.error( 88 | f"LDAP User '{username}' Not Found", 401 89 | ) 90 | user: Entry = conn.entries[0] 91 | rebind_success = conn.rebind(user.entry_dn, password) 92 | if not rebind_success: 93 | # Server may not allow rebinding, attempt to start 94 | # a new connection to validate credentials 95 | logging.debug( 96 | "LDAP Rebind failed, attempting to validate credentials " 97 | "with new connection." 98 | ) 99 | conn_args["user"] = user.entry_dn 100 | conn_args["password"] = password 101 | with ldap3.Connection(server, **conn_args) as conn: 102 | if self._validate_group(username, user): 103 | return 104 | elif self._validate_group(username, user): 105 | return 106 | except LDAPExceptionError: 107 | err_msg = "LDAP authentication failed" 108 | else: 109 | err_msg = "Invalid LDAP Username or Password" 110 | raise self.server.error(err_msg, 401) 111 | 112 | def _validate_group(self, username: str, user: Entry) -> bool: 113 | if self.group_dn is None: 114 | logging.debug(f"LDAP User {username} login successful") 115 | return True 116 | if not hasattr(user, "memberOf"): 117 | return False 118 | for group in user.memberOf.values: 119 | if group == self.group_dn: 120 | logging.debug( 121 | f"LDAP User {username} group match success, " 122 | "login successful" 123 | ) 124 | return True 125 | return False 126 | 127 | 128 | def load_component(config: ConfigHelper) -> MoonrakerLDAP: 129 | return MoonrakerLDAP(config) 130 | -------------------------------------------------------------------------------- /moonraker/components/secrets.py: -------------------------------------------------------------------------------- 1 | # Support for password/token secrets 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | from __future__ import annotations 7 | import pathlib 8 | import logging 9 | import configparser 10 | from ..utils import json_wrapper as jsonw 11 | from typing import ( 12 | TYPE_CHECKING, 13 | Dict, 14 | Optional, 15 | Any 16 | ) 17 | if TYPE_CHECKING: 18 | from ..confighelper import ConfigHelper 19 | 20 | class Secrets: 21 | def __init__(self, config: ConfigHelper) -> None: 22 | server = config.get_server() 23 | path: Optional[str] = config.get("secrets_path", None, deprecate=True) 24 | app_args = server.get_app_args() 25 | data_path = app_args["data_path"] 26 | fpath = pathlib.Path(data_path).joinpath("moonraker.secrets") 27 | if not fpath.is_file() and path is not None: 28 | fpath = pathlib.Path(path).expanduser().resolve() 29 | self.type = "invalid" 30 | self.values: Dict[str, Any] = {} 31 | self.secrets_file = fpath 32 | if fpath.is_file(): 33 | data = self.secrets_file.read_text() 34 | vals = self._parse_json(data) 35 | if vals is not None: 36 | if not isinstance(vals, dict): 37 | server.add_warning( 38 | f"[secrets]: option 'secrets_path', top level item in" 39 | f" json file '{self.secrets_file}' must be an Object.") 40 | return 41 | self.values = vals 42 | self.type = "json" 43 | else: 44 | vals = self._parse_ini(data) 45 | if vals is None: 46 | server.add_warning( 47 | "[secrets]: option 'secrets_path', invalid file " 48 | f"format, must be json or ini: '{self.secrets_file}'") 49 | return 50 | self.values = vals 51 | self.type = "ini" 52 | logging.debug(f"[secrets]: Loaded {self.type} file: " 53 | f"{self.secrets_file}") 54 | elif path is not None: 55 | server.add_warning( 56 | "[secrets]: option 'secrets_path', file does not exist: " 57 | f"'{self.secrets_file}'") 58 | else: 59 | logging.debug( 60 | "[secrets]: Option `secrets_path` not supplied") 61 | 62 | def get_secrets_file(self) -> pathlib.Path: 63 | return self.secrets_file 64 | 65 | def _parse_ini(self, data: str) -> Optional[Dict[str, Any]]: 66 | try: 67 | cfg = configparser.ConfigParser(interpolation=None) 68 | cfg.read_string(data) 69 | return {sec: dict(cfg.items(sec)) for sec in cfg.sections()} 70 | except Exception: 71 | return None 72 | 73 | def _parse_json(self, data: str) -> Optional[Dict[str, Any]]: 74 | try: 75 | return jsonw.loads(data) 76 | except jsonw.JSONDecodeError: 77 | return None 78 | 79 | def get_type(self) -> str: 80 | return self.type 81 | 82 | def __getitem__(self, key: str) -> Any: 83 | return self.values[key] 84 | 85 | def get(self, key: str, default: Any = None) -> Any: 86 | return self.values.get(key, default) 87 | 88 | 89 | def load_component(config: ConfigHelper) -> Secrets: 90 | return Secrets(config) 91 | -------------------------------------------------------------------------------- /moonraker/components/template.py: -------------------------------------------------------------------------------- 1 | # Template Factory helper 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | from __future__ import annotations 7 | import logging 8 | import asyncio 9 | import jinja2 10 | from ..utils import json_wrapper as jsonw 11 | from ..common import RenderableTemplate 12 | 13 | # Annotation imports 14 | from typing import ( 15 | TYPE_CHECKING, 16 | Any, 17 | Dict 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from ..server import Server 22 | from ..confighelper import ConfigHelper 23 | from .secrets import Secrets 24 | 25 | class TemplateFactory: 26 | def __init__(self, config: ConfigHelper) -> None: 27 | self.server = config.get_server() 28 | secrets: Secrets = self.server.load_component(config, 'secrets') 29 | self.jenv = jinja2.Environment('{%', '%}', '{', '}') 30 | self.async_env = jinja2.Environment( 31 | '{%', '%}', '{', '}', enable_async=True 32 | ) 33 | self.ui_env = jinja2.Environment(enable_async=True) 34 | self.jenv.add_extension("jinja2.ext.do") 35 | self.jenv.filters['fromjson'] = jsonw.loads 36 | self.async_env.add_extension("jinja2.ext.do") 37 | self.async_env.filters['fromjson'] = jsonw.loads 38 | self.ui_env.add_extension("jinja2.ext.do") 39 | self.ui_env.filters['fromjson'] = jsonw.loads 40 | self.add_environment_global('raise_error', self._raise_error) 41 | self.add_environment_global('secrets', secrets) 42 | 43 | def add_environment_global(self, name: str, value: Any): 44 | if name in self.jenv.globals: 45 | raise self.server.error( 46 | f"Jinja 2 environment already contains global {name}") 47 | self.jenv.globals[name] = value 48 | self.async_env.globals[name] = value 49 | 50 | def _raise_error(self, err_msg: str, err_code: int = 400) -> None: 51 | raise self.server.error(err_msg, err_code) 52 | 53 | def create_template(self, 54 | source: str, 55 | is_async: bool = False 56 | ) -> JinjaTemplate: 57 | env = self.async_env if is_async else self.jenv 58 | try: 59 | template = env.from_string(source) 60 | except Exception: 61 | logging.exception(f"Error creating template from source:\n{source}") 62 | raise 63 | return JinjaTemplate(source, self.server, template, is_async) 64 | 65 | def create_ui_template(self, source: str) -> JinjaTemplate: 66 | try: 67 | template = self.ui_env.from_string(source) 68 | except Exception: 69 | logging.exception(f"Error creating template from source:\n{source}") 70 | raise 71 | return JinjaTemplate(source, self.server, template, True) 72 | 73 | 74 | class JinjaTemplate(RenderableTemplate): 75 | def __init__(self, 76 | source: str, 77 | server: Server, 78 | template: jinja2.Template, 79 | is_async: bool 80 | ) -> None: 81 | self.server = server 82 | self.orig_source = source 83 | self.template = template 84 | self.is_async = is_async 85 | 86 | def __str__(self) -> str: 87 | return self.orig_source 88 | 89 | def render(self, context: Dict[str, Any] = {}) -> str: 90 | if self.is_async: 91 | raise self.server.error( 92 | "Cannot render async templates with the render() method" 93 | ", use render_async()") 94 | try: 95 | return self.template.render(context).strip() 96 | except Exception as e: 97 | msg = "Error rending Jinja2 Template" 98 | if self.server.is_configured(): 99 | raise self.server.error(msg, 500) from e 100 | raise self.server.config_error(msg) from e 101 | 102 | async def render_async(self, context: Dict[str, Any] = {}) -> str: 103 | try: 104 | ret = await self.template.render_async(context) 105 | except asyncio.CancelledError: 106 | raise 107 | except Exception as e: 108 | raise self.server.error("Error rending Jinja2 Template", 500) from e 109 | return ret.strip() 110 | 111 | def load_component(config: ConfigHelper) -> TemplateFactory: 112 | return TemplateFactory(config) 113 | -------------------------------------------------------------------------------- /moonraker/components/update_manager/__init__.py: -------------------------------------------------------------------------------- 1 | # Package definition for the update_manager 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | from . import update_manager as um 9 | 10 | from typing import TYPE_CHECKING 11 | if TYPE_CHECKING: 12 | from ...confighelper import ConfigHelper 13 | 14 | def load_component(config: ConfigHelper) -> um.UpdateManager: 15 | return um.load_component(config) 16 | -------------------------------------------------------------------------------- /moonraker/components/update_manager/base_deploy.py: -------------------------------------------------------------------------------- 1 | # Base Deployment Interface 2 | # 3 | # Copyright (C) 2021 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import logging 9 | import time 10 | from ...utils import pretty_print_time 11 | 12 | from typing import TYPE_CHECKING, Dict, Any, Optional, Coroutine 13 | if TYPE_CHECKING: 14 | from ...confighelper import ConfigHelper 15 | from ...utils import ServerError 16 | from .update_manager import CommandHelper 17 | 18 | class BaseDeploy: 19 | cmd_helper: CommandHelper 20 | def __init__( 21 | self, 22 | config: ConfigHelper, 23 | name: Optional[str] = None, 24 | prefix: str = "", 25 | cfg_hash: Optional[str] = None 26 | ) -> None: 27 | if name is None: 28 | name = self.parse_name(config) 29 | self.name = name 30 | if prefix: 31 | prefix = f"{prefix} {self.name}: " 32 | self.prefix = prefix 33 | self.server = config.get_server() 34 | self.refresh_interval = self.cmd_helper.get_refresh_interval() 35 | refresh_interval = config.getint('refresh_interval', None) 36 | if refresh_interval is not None: 37 | self.refresh_interval = refresh_interval * 60 * 60 38 | if cfg_hash is None: 39 | cfg_hash = config.get_hash().hexdigest() 40 | self.cfg_hash = cfg_hash 41 | 42 | @staticmethod 43 | def parse_name(config: ConfigHelper) -> str: 44 | name = config.get_name().split(maxsplit=1)[-1] 45 | if name.startswith("client "): 46 | # allow deprecated [update_manager client app] style names 47 | name = name[7:] 48 | return name 49 | 50 | @staticmethod 51 | def set_command_helper(cmd_helper: CommandHelper) -> None: 52 | BaseDeploy.cmd_helper = cmd_helper 53 | 54 | async def initialize(self) -> Dict[str, Any]: 55 | umdb = self.cmd_helper.get_umdb() 56 | storage: Dict[str, Any] = await umdb.get(self.name, {}) 57 | self.last_refresh_time: float = storage.get('last_refresh_time', 0.0) 58 | self.last_cfg_hash: str = storage.get('last_config_hash', "") 59 | return storage 60 | 61 | def needs_refresh(self, log_remaining_time: bool = False) -> bool: 62 | next_refresh_time = self.last_refresh_time + self.refresh_interval 63 | remaining_time = int(next_refresh_time - time.time() + .5) 64 | if self.cfg_hash != self.last_cfg_hash or remaining_time <= 0: 65 | return True 66 | if log_remaining_time: 67 | self.log_info(f"Next refresh in: {pretty_print_time(remaining_time)}") 68 | return False 69 | 70 | def get_last_refresh_time(self) -> float: 71 | return self.last_refresh_time 72 | 73 | async def refresh(self) -> None: 74 | pass 75 | 76 | async def update(self) -> bool: 77 | return False 78 | 79 | async def rollback(self) -> bool: 80 | raise self.server.error(f"Rollback not available for {self.name}") 81 | 82 | def get_update_status(self) -> Dict[str, Any]: 83 | return {} 84 | 85 | def get_persistent_data(self) -> Dict[str, Any]: 86 | return { 87 | 'last_config_hash': self.cfg_hash, 88 | 'last_refresh_time': self.last_refresh_time 89 | } 90 | 91 | def _save_state(self) -> None: 92 | umdb = self.cmd_helper.get_umdb() 93 | self.last_refresh_time = time.time() 94 | self.last_cfg_hash = self.cfg_hash 95 | umdb[self.name] = self.get_persistent_data() 96 | 97 | def log_exc(self, msg: str, traceback: bool = True) -> ServerError: 98 | log_msg = f"{self.prefix}{msg}" 99 | if traceback: 100 | logging.exception(log_msg) 101 | else: 102 | logging.info(log_msg) 103 | return self.server.error(msg) 104 | 105 | def log_info(self, msg: str) -> None: 106 | log_msg = f"{self.prefix}{msg}" 107 | logging.info(log_msg) 108 | 109 | def log_debug(self, msg: str) -> None: 110 | log_msg = f"{self.prefix}{msg}" 111 | logging.debug(log_msg) 112 | 113 | def notify_status(self, msg: str, is_complete: bool = False) -> None: 114 | log_msg = f"{self.prefix}{msg}" 115 | logging.debug(log_msg) 116 | self.cmd_helper.notify_update_response(log_msg, is_complete) 117 | 118 | def close(self) -> Optional[Coroutine]: 119 | return None 120 | -------------------------------------------------------------------------------- /moonraker/components/update_manager/common.py: -------------------------------------------------------------------------------- 1 | # Moonraker/Klipper update configuration 2 | # 3 | # Copyright (C) 2022 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import sys 9 | import copy 10 | import pathlib 11 | from ...common import ExtendedEnum 12 | from ...utils import source_info 13 | from typing import ( 14 | TYPE_CHECKING, 15 | Dict, 16 | Union, 17 | List 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from ...confighelper import ConfigHelper 22 | from ..klippy_connection import KlippyConnection 23 | 24 | BASE_CONFIG: Dict[str, Dict[str, str]] = { 25 | "moonraker": { 26 | "origin": "https://github.com/arksine/moonraker.git", 27 | "requirements": "scripts/moonraker-requirements.txt", 28 | "venv_args": "-p python3", 29 | "system_dependencies": "scripts/system-dependencies.json", 30 | "virtualenv": sys.exec_prefix, 31 | "pip_environment_variables": "SKIP_CYTHON=Y", 32 | "path": str(source_info.source_path()), 33 | "managed_services": "moonraker" 34 | }, 35 | "klipper": { 36 | "moved_origin": "https://github.com/kevinoconnor/klipper.git", 37 | "origin": "https://github.com/Klipper3d/klipper.git", 38 | "requirements": "scripts/klippy-requirements.txt", 39 | "venv_args": "-p python3", 40 | "install_script": "scripts/install-octopi.sh", 41 | "managed_services": "klipper" 42 | } 43 | } 44 | 45 | OPTION_OVERRIDES = ("channel", "pinned_commit", "refresh_interval") 46 | 47 | class AppType(ExtendedEnum): 48 | NONE = 1 49 | WEB = 2 50 | GIT_REPO = 3 51 | ZIP = 4 52 | PYTHON = 5 53 | EXECUTABLE = 6 54 | 55 | @classmethod 56 | def detect(cls, app_path: Union[str, pathlib.Path, None] = None): 57 | # If app path is None, detect Moonraker 58 | if isinstance(app_path, str): 59 | app_path = pathlib.Path(app_path).expanduser() 60 | if source_info.is_git_repo(app_path): 61 | return AppType.GIT_REPO 62 | elif app_path is None and source_info.is_vitualenv_project(): 63 | return AppType.PYTHON 64 | else: 65 | return AppType.NONE 66 | 67 | @classmethod 68 | def valid_types(cls) -> List[AppType]: 69 | all_types = list(cls) 70 | all_types.remove(AppType.NONE) 71 | return all_types 72 | 73 | @property 74 | def supported_channels(self) -> List[Channel]: 75 | if self == AppType.NONE: 76 | return [] 77 | elif self in [AppType.WEB, AppType.ZIP, AppType.EXECUTABLE]: 78 | return [Channel.STABLE, Channel.BETA] # type: ignore 79 | else: 80 | return list(Channel) 81 | 82 | @property 83 | def default_channel(self) -> Channel: 84 | if self == AppType.GIT_REPO: 85 | return Channel.DEV # type: ignore 86 | return Channel.STABLE # type: ignore 87 | 88 | class Channel(ExtendedEnum): 89 | STABLE = 1 90 | BETA = 2 91 | DEV = 3 92 | 93 | def get_base_configuration(config: ConfigHelper) -> ConfigHelper: 94 | server = config.get_server() 95 | base_cfg = copy.deepcopy(BASE_CONFIG) 96 | kconn: KlippyConnection = server.lookup_component("klippy_connection") 97 | base_cfg["moonraker"]["type"] = str(AppType.detect()) 98 | base_cfg["klipper"]["path"] = str(kconn.path) 99 | base_cfg["klipper"]["env"] = str(kconn.executable) 100 | base_cfg["klipper"]["type"] = str(AppType.detect(kconn.path)) 101 | default_channel = config.get("channel", None) 102 | # Check for configuration overrides 103 | for app_name in base_cfg.keys(): 104 | if default_channel is not None: 105 | base_cfg[app_name]["channel"] = default_channel 106 | override_section = f"update_manager {app_name}" 107 | if not config.has_section(override_section): 108 | continue 109 | app_cfg = config[override_section] 110 | for opt in OPTION_OVERRIDES: 111 | if app_cfg.has_option(opt): 112 | base_cfg[app_name][opt] = app_cfg.get(opt) 113 | return config.read_supplemental_dict(base_cfg) 114 | -------------------------------------------------------------------------------- /moonraker/moonraker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Legacy entry point for Moonraker 3 | # 4 | # Copyright (C) 2022 Eric Callahan 5 | # 6 | # This file may be distributed under the terms of the GNU GPLv3 license 7 | 8 | 9 | if __name__ == "__main__": 10 | import sys 11 | import importlib 12 | import pathlib 13 | pkg_parent = pathlib.Path(__file__).parent.parent 14 | sys.path.pop(0) 15 | sys.path.insert(0, str(pkg_parent)) 16 | svr = importlib.import_module(".server", "moonraker") 17 | svr.main(False) # type: ignore 18 | -------------------------------------------------------------------------------- /moonraker/thirdparty/__init__.py: -------------------------------------------------------------------------------- 1 | # package definition for thirdparty package 2 | -------------------------------------------------------------------------------- /moonraker/thirdparty/packagekit/__init__.py: -------------------------------------------------------------------------------- 1 | # package definition for packagekit thirdparty files 2 | -------------------------------------------------------------------------------- /moonraker/utils/async_serial.py: -------------------------------------------------------------------------------- 1 | # Asyncio wrapper for serial communications 2 | # 3 | # Copyright (C) 2024 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import os 9 | import errno 10 | import logging 11 | import asyncio 12 | import contextlib 13 | from serial import Serial, SerialException 14 | from typing import TYPE_CHECKING, Optional, List, Tuple, Awaitable 15 | 16 | if TYPE_CHECKING: 17 | from ..confighelper import ConfigHelper 18 | 19 | READER_LIMIT = 4*1024*1024 20 | 21 | 22 | class AsyncSerialConnection: 23 | error = SerialException 24 | def __init__(self, config: ConfigHelper, default_baud: int = 57600) -> None: 25 | self.name = config.get_name() 26 | self.eventloop = config.get_server().get_event_loop() 27 | self.port: str = config.get("serial") 28 | self.baud = config.getint("baud", default_baud) 29 | self.ser: Optional[Serial] = None 30 | self.send_task: Optional[asyncio.Task] = None 31 | self.send_buffer: List[Tuple[asyncio.Future, bytes]] = [] 32 | self._reader = asyncio.StreamReader(limit=READER_LIMIT) 33 | 34 | @property 35 | def connected(self) -> bool: 36 | return self.ser is not None 37 | 38 | @property 39 | def reader(self) -> asyncio.StreamReader: 40 | return self._reader 41 | 42 | def close(self) -> Awaitable: 43 | if self.ser is not None: 44 | self.eventloop.remove_reader(self.ser.fileno()) 45 | self.ser.close() 46 | logging.info(f"{self.name}: Disconnected") 47 | self.ser = None 48 | for (fut, _) in self.send_buffer: 49 | fut.set_exception(SerialException("Serial Device Closed")) 50 | self.send_buffer.clear() 51 | self._reader.feed_eof() 52 | if self.send_task is not None and not self.send_task.done(): 53 | async def _cancel_send(send_task: asyncio.Task): 54 | with contextlib.suppress(asyncio.TimeoutError): 55 | await asyncio.wait_for(send_task, 2.) 56 | return self.eventloop.create_task(_cancel_send(self.send_task)) 57 | self.send_task = None 58 | fut = self.eventloop.create_future() 59 | fut.set_result(None) 60 | return fut 61 | 62 | def open(self, exclusive: bool = True) -> None: 63 | if self.connected: 64 | return 65 | logging.info(f"{self.name} :Attempting to open serial device: {self.port}") 66 | ser = Serial(self.port, self.baud, timeout=0, exclusive=exclusive) 67 | self.ser = ser 68 | fd = self.ser.fileno() 69 | os.set_blocking(fd, False) 70 | self.eventloop.add_reader(fd, self._handle_incoming) 71 | self._reader = asyncio.StreamReader(limit=READER_LIMIT) 72 | logging.info(f"{self.name} Connected") 73 | 74 | def _handle_incoming(self) -> None: 75 | # Process incoming data using same method as gcode.py 76 | if self.ser is None: 77 | return 78 | try: 79 | data = os.read(self.ser.fileno(), 4096) 80 | except OSError: 81 | return 82 | 83 | if not data: 84 | # possibly an error, disconnect 85 | logging.info(f"{self.name}: No data received, disconnecting") 86 | self.close() 87 | else: 88 | self._reader.feed_data(data) 89 | 90 | def send(self, data: bytes) -> asyncio.Future: 91 | fut = self.eventloop.create_future() 92 | if not self.connected: 93 | fut.set_exception(SerialException("Serial Device Closed")) 94 | return fut 95 | self.send_buffer.append((fut, data)) 96 | if self.send_task is None or self.send_task.done(): 97 | self.send_task = self.eventloop.create_task(self._do_send()) 98 | return fut 99 | 100 | async def _do_send(self) -> None: 101 | while self.send_buffer: 102 | fut, data = self.send_buffer.pop() 103 | while data: 104 | if self.ser is None: 105 | sent = 0 106 | else: 107 | try: 108 | sent = os.write(self.ser.fileno(), data) 109 | except OSError as e: 110 | if e.errno == errno.EBADF or e.errno == errno.EPIPE: 111 | sent = 0 112 | else: 113 | await asyncio.sleep(.001) 114 | continue 115 | if sent: 116 | data = data[sent:] 117 | else: 118 | logging.exception( 119 | f"{self.name}: Error writing data, closing serial connection" 120 | ) 121 | fut.set_exception(SerialException("Serial Device Closed")) 122 | self.send_task = None 123 | self.close() 124 | return 125 | fut.set_result(None) 126 | self.send_task = None 127 | -------------------------------------------------------------------------------- /moonraker/utils/cansocket.py: -------------------------------------------------------------------------------- 1 | # Async CAN Socket utility 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license. 6 | 7 | from __future__ import annotations 8 | import socket 9 | import asyncio 10 | import errno 11 | import struct 12 | import logging 13 | from . import ServerError 14 | from typing import List, Dict, Optional, Union 15 | 16 | CAN_FMT = " None: 26 | self.node_id = node_id 27 | self._reader = asyncio.StreamReader(CAN_READER_LIMIT) 28 | self._cansocket = cansocket 29 | 30 | async def read( 31 | self, n: int = -1, timeout: Optional[float] = 2 32 | ) -> bytes: 33 | return await asyncio.wait_for(self._reader.read(n), timeout) 34 | 35 | async def readexactly( 36 | self, n: int, timeout: Optional[float] = 2 37 | ) -> bytes: 38 | return await asyncio.wait_for(self._reader.readexactly(n), timeout) 39 | 40 | async def readuntil( 41 | self, sep: bytes = b"\x03", timeout: Optional[float] = 2 42 | ) -> bytes: 43 | return await asyncio.wait_for(self._reader.readuntil(sep), timeout) 44 | 45 | def write(self, payload: Union[bytes, bytearray]) -> None: 46 | if isinstance(payload, bytearray): 47 | payload = bytes(payload) 48 | self._cansocket.send(self.node_id, payload) 49 | 50 | async def write_with_response( 51 | self, 52 | payload: Union[bytearray, bytes], 53 | resp_length: int, 54 | timeout: Optional[float] = 2. 55 | ) -> bytes: 56 | self.write(payload) 57 | return await self.readexactly(resp_length, timeout) 58 | 59 | def feed_data(self, data: bytes) -> None: 60 | self._reader.feed_data(data) 61 | 62 | def close(self) -> None: 63 | self._reader.feed_eof() 64 | 65 | class CanSocket: 66 | def __init__(self, interface: str): 67 | self._loop = asyncio.get_running_loop() 68 | self.nodes: Dict[int, CanNode] = {} 69 | self.cansock = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW) 70 | self.input_buffer = b"" 71 | self.output_packets: List[bytes] = [] 72 | self.input_busy = False 73 | self.output_busy = False 74 | self.closed = True 75 | try: 76 | self.cansock.bind((interface,)) 77 | except Exception: 78 | raise ServerError(f"Unable to bind socket to interface '{interface}'", 500) 79 | self.closed = False 80 | self.cansock.setblocking(False) 81 | self._loop.add_reader(self.cansock.fileno(), self._handle_can_response) 82 | 83 | def register_node(self, node_id: int) -> CanNode: 84 | if node_id in self.nodes: 85 | return self.nodes[node_id] 86 | node = CanNode(node_id, self) 87 | self.nodes[node_id + 1] = node 88 | return node 89 | 90 | def remove_node(self, node_id: int) -> None: 91 | node = self.nodes.pop(node_id + 1, None) 92 | if node is not None: 93 | node.close() 94 | 95 | def _handle_can_response(self) -> None: 96 | try: 97 | data = self.cansock.recv(4096) 98 | except socket.error as e: 99 | # If bad file descriptor allow connection to be 100 | # closed by the data check 101 | if e.errno == errno.EBADF: 102 | logging.exception("Can Socket Read Error, closing") 103 | data = b'' 104 | else: 105 | return 106 | if not data: 107 | # socket closed 108 | self.close() 109 | return 110 | self.input_buffer += data 111 | if self.input_busy: 112 | return 113 | self.input_busy = True 114 | while len(self.input_buffer) >= 16: 115 | packet = self.input_buffer[:16] 116 | self._process_packet(packet) 117 | self.input_buffer = self.input_buffer[16:] 118 | self.input_busy = False 119 | 120 | def _process_packet(self, packet: bytes) -> None: 121 | can_id, length, data = struct.unpack(CAN_FMT, packet) 122 | can_id &= socket.CAN_EFF_MASK 123 | payload = data[:length] 124 | node = self.nodes.get(can_id) 125 | if node is not None: 126 | node.feed_data(payload) 127 | 128 | def send(self, can_id: int, payload: bytes = b"") -> None: 129 | if can_id > 0x7FF: 130 | can_id |= socket.CAN_EFF_FLAG 131 | if not payload: 132 | packet = struct.pack(CAN_FMT, can_id, 0, b"") 133 | self.output_packets.append(packet) 134 | else: 135 | while payload: 136 | length = min(len(payload), 8) 137 | pkt_data = payload[:length] 138 | payload = payload[length:] 139 | packet = struct.pack( 140 | CAN_FMT, can_id, length, pkt_data) 141 | self.output_packets.append(packet) 142 | if self.output_busy: 143 | return 144 | self.output_busy = True 145 | asyncio.create_task(self._do_can_send()) 146 | 147 | async def _do_can_send(self): 148 | while self.output_packets: 149 | packet = self.output_packets.pop(0) 150 | try: 151 | await self._loop.sock_sendall(self.cansock, packet) 152 | except socket.error: 153 | logging.info("Socket Write Error, closing") 154 | self.close() 155 | break 156 | self.output_busy = False 157 | 158 | def close(self): 159 | if self.closed: 160 | return 161 | self.closed = True 162 | for node in self.nodes.values(): 163 | node.close() 164 | self._loop.remove_reader(self.cansock.fileno()) 165 | self.cansock.close() 166 | 167 | async def query_klipper_uuids(can_socket: CanSocket) -> List[Dict[str, str]]: 168 | loop = asyncio.get_running_loop() 169 | admin_node = can_socket.register_node(KLIPPER_ADMIN_ID) 170 | payload = bytes([CMD_QUERY_UNASSIGNED]) 171 | admin_node.write(payload) 172 | curtime = loop.time() 173 | endtime = curtime + 2. 174 | uuids: List[Dict[str, str]] = [] 175 | while curtime < endtime: 176 | timeout = max(.1, endtime - curtime) 177 | try: 178 | resp = await admin_node.read(8, timeout) 179 | except asyncio.TimeoutError: 180 | continue 181 | finally: 182 | curtime = loop.time() 183 | if len(resp) < 7 or resp[0] != CANBUS_RESP_NEED_NODEID: 184 | continue 185 | app_names = { 186 | KLIPPER_SET_NODE_CMD: "Klipper", 187 | KATAPULT_SET_NODE_CMD: "Katapult" 188 | } 189 | app = "Unknown" 190 | if len(resp) > 7: 191 | app = app_names.get(resp[7], "Unknown") 192 | data = resp[1:7] 193 | uuids.append( 194 | { 195 | "uuid": data.hex(), 196 | "application": app 197 | } 198 | ) 199 | return uuids 200 | -------------------------------------------------------------------------------- /moonraker/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # Moonraker Exceptions 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | class ServerError(Exception): 8 | def __init__(self, message: str, status_code: int = 400) -> None: 9 | Exception.__init__(self, message) 10 | self.status_code = status_code 11 | -------------------------------------------------------------------------------- /moonraker/utils/filelock.py: -------------------------------------------------------------------------------- 1 | # Async file locking using flock 2 | # 3 | # Copyright (C) 2024 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from __future__ import annotations 8 | import os 9 | import fcntl 10 | import errno 11 | import logging 12 | import pathlib 13 | import contextlib 14 | import asyncio 15 | from . import ServerError 16 | from typing import Optional, Type, Union 17 | from types import TracebackType 18 | 19 | class LockTimeout(ServerError): 20 | pass 21 | 22 | class AsyncExclusiveFileLock(contextlib.AbstractAsyncContextManager): 23 | def __init__( 24 | self, file_path: pathlib.Path, timeout: Union[int, float] = 0 25 | ) -> None: 26 | self.lock_path = file_path.parent.joinpath(f".{file_path.name}.lock") 27 | self.timeout = timeout 28 | self.fd: int = -1 29 | self.locked: bool = False 30 | self.required_wait: bool = False 31 | 32 | async def __aenter__(self) -> AsyncExclusiveFileLock: 33 | await self.acquire() 34 | return self 35 | 36 | async def __aexit__( 37 | self, 38 | __exc_type: Optional[Type[BaseException]], 39 | __exc_value: Optional[BaseException], 40 | __traceback: Optional[TracebackType] 41 | ) -> None: 42 | await self.release() 43 | 44 | def _get_lock(self) -> bool: 45 | flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC 46 | fd = os.open(str(self.lock_path), flags, 0o644) 47 | with contextlib.suppress(PermissionError): 48 | os.chmod(fd, 0o644) 49 | try: 50 | fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) 51 | except OSError as err: 52 | os.close(fd) 53 | if err.errno == errno.ENOSYS: 54 | raise 55 | return False 56 | stat = os.fstat(fd) 57 | if stat.st_nlink == 0: 58 | # File was deleted before opening and after acquiring 59 | # lock, create a new one 60 | os.close(fd) 61 | return False 62 | self.fd = fd 63 | return True 64 | 65 | async def acquire(self) -> None: 66 | self.required_wait = False 67 | if self.timeout < 0: 68 | return 69 | loop = asyncio.get_running_loop() 70 | endtime = loop.time() + self.timeout 71 | logged: bool = False 72 | while True: 73 | try: 74 | self.locked = await loop.run_in_executor(None, self._get_lock) 75 | except OSError as err: 76 | logging.info( 77 | "Failed to aquire advisory lock, allowing unlocked entry." 78 | f"Error: {err}" 79 | ) 80 | self.locked = False 81 | return 82 | if self.locked: 83 | return 84 | self.required_wait = True 85 | await asyncio.sleep(.25) 86 | if not logged: 87 | logged = True 88 | logging.info( 89 | f"File lock {self.lock_path} is currently acquired by another " 90 | "process, waiting for release." 91 | ) 92 | if self.timeout > 0 and endtime >= loop.time(): 93 | raise LockTimeout( 94 | f"Attempt to acquire lock '{self.lock_path}' timed out" 95 | ) 96 | 97 | def _release_file(self) -> None: 98 | with contextlib.suppress(OSError, PermissionError): 99 | if self.lock_path.is_file(): 100 | self.lock_path.unlink() 101 | with contextlib.suppress(OSError, PermissionError): 102 | fcntl.flock(self.fd, fcntl.LOCK_UN) 103 | with contextlib.suppress(OSError, PermissionError): 104 | os.close(self.fd) 105 | 106 | async def release(self) -> None: 107 | if not self.locked: 108 | return 109 | loop = asyncio.get_running_loop() 110 | await loop.run_in_executor(None, self._release_file) 111 | self.locked = False 112 | -------------------------------------------------------------------------------- /moonraker/utils/ioctl_macros.py: -------------------------------------------------------------------------------- 1 | # Methods to create IOCTL requests 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from __future__ import annotations 8 | import ctypes 9 | from typing import Union, Type, TYPE_CHECKING 10 | 11 | """ 12 | This module contains of Python port of the macros avaialble in 13 | "/include/uapi/asm-generic/ioctl.h" from the linux kernel. 14 | """ 15 | 16 | if TYPE_CHECKING: 17 | IOCParamSize = Union[int, str, Type[ctypes._CData]] 18 | 19 | _IOC_NRBITS = 8 20 | _IOC_TYPEBITS = 8 21 | 22 | # NOTE: The following could be platform specific. 23 | _IOC_SIZEBITS = 14 24 | _IOC_DIRBITS = 2 25 | 26 | _IOC_NRMASK = (1 << _IOC_NRBITS) - 1 27 | _IOC_TYPEMASK = (1 << _IOC_TYPEBITS) - 1 28 | _IOC_SIZEMASK = (1 << _IOC_SIZEBITS) - 1 29 | _IOC_DIRMASK = (1 << _IOC_DIRBITS) - 1 30 | 31 | _IOC_NRSHIFT = 0 32 | _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS 33 | _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS 34 | _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS 35 | 36 | # The constants below may also be platform specific 37 | IOC_NONE = 0 38 | IOC_WRITE = 1 39 | IOC_READ = 2 40 | 41 | def _check_value(val: int, name: str, maximum: int): 42 | if val > maximum: 43 | raise ValueError(f"Value '{val}' for '{name}' exceeds max of {maximum}") 44 | 45 | def _IOC_TYPECHECK(param_size: IOCParamSize) -> int: 46 | if isinstance(param_size, int): 47 | return param_size 48 | elif isinstance(param_size, bytearray): 49 | return len(param_size) 50 | elif isinstance(param_size, str): 51 | ctcls = getattr(ctypes, param_size) 52 | return ctypes.sizeof(ctcls) 53 | return ctypes.sizeof(param_size) 54 | 55 | def IOC(direction: int, cmd_type: int, cmd_number: int, param_size: int) -> int: 56 | _check_value(direction, "direction", _IOC_DIRMASK) 57 | _check_value(cmd_type, "cmd_type", _IOC_TYPEMASK) 58 | _check_value(cmd_number, "cmd_number", _IOC_NRMASK) 59 | _check_value(param_size, "ioc_size", _IOC_SIZEMASK) 60 | return ( 61 | (direction << _IOC_DIRSHIFT) | 62 | (param_size << _IOC_SIZESHIFT) | 63 | (cmd_type << _IOC_TYPESHIFT) | 64 | (cmd_number << _IOC_NRSHIFT) 65 | ) 66 | 67 | def IO(cmd_type: int, cmd_number: int) -> int: 68 | return IOC(IOC_NONE, cmd_type, cmd_number, 0) 69 | 70 | def IOR(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: 71 | return IOC(IOC_READ, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) 72 | 73 | def IOW(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: 74 | return IOC(IOC_WRITE, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) 75 | 76 | def IOWR(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: 77 | return IOC(IOC_READ | IOC_WRITE, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) 78 | -------------------------------------------------------------------------------- /moonraker/utils/json_wrapper.py: -------------------------------------------------------------------------------- 1 | # Wrapper for msgspec with stdlib fallback 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from __future__ import annotations 8 | import os 9 | import contextlib 10 | from typing import Any, Union, TYPE_CHECKING 11 | 12 | if TYPE_CHECKING: 13 | def dumps(obj: Any) -> bytes: ... # type: ignore # noqa: E704 14 | def loads(data: Union[str, bytes, bytearray]) -> Any: ... # noqa: E704 15 | 16 | MSGSPEC_ENABLED = False 17 | _msgspc_var = os.getenv("MOONRAKER_ENABLE_MSGSPEC", "y").lower() 18 | if _msgspc_var in ["y", "yes", "true"]: 19 | with contextlib.suppress(ImportError): 20 | import msgspec 21 | from msgspec import DecodeError as JSONDecodeError 22 | encoder = msgspec.json.Encoder() 23 | decoder = msgspec.json.Decoder() 24 | dumps = encoder.encode # noqa: F811 25 | loads = decoder.decode # noqa: F811 26 | MSGSPEC_ENABLED = True 27 | if not MSGSPEC_ENABLED: 28 | import json 29 | from json import JSONDecodeError # type: ignore # noqa: F401,F811 30 | loads = json.loads # type: ignore 31 | 32 | def dumps(obj) -> bytes: # type: ignore # noqa: F811 33 | return json.dumps(obj).encode("utf-8") 34 | -------------------------------------------------------------------------------- /moonraker/utils/source_info.py: -------------------------------------------------------------------------------- 1 | # General Server Utilities 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from __future__ import annotations 8 | import importlib.resources as ilr 9 | import pathlib 10 | import sys 11 | import site 12 | import re 13 | import json 14 | import logging 15 | from dataclasses import dataclass 16 | from importlib_metadata import Distribution, PathDistribution, PackageMetadata 17 | from .exceptions import ServerError 18 | 19 | # Annotation imports 20 | from typing import ( 21 | Optional, 22 | Dict, 23 | Any 24 | ) 25 | 26 | def package_path() -> pathlib.Path: 27 | return pathlib.Path(__file__).parent.parent 28 | 29 | def source_path() -> pathlib.Path: 30 | return package_path().parent 31 | 32 | def is_git_repo(src_path: Optional[pathlib.Path] = None) -> bool: 33 | if src_path is None: 34 | src_path = source_path() 35 | return src_path.joinpath(".git").exists() 36 | 37 | def find_git_repo(src_path: Optional[pathlib.Path] = None) -> Optional[pathlib.Path]: 38 | if src_path is None: 39 | src_path = source_path() 40 | if src_path.joinpath(".git").exists(): 41 | return src_path 42 | for parent in src_path.parents: 43 | if parent.joinpath(".git").exists(): 44 | return parent 45 | return None 46 | 47 | def is_dist_package(item_path: Optional[pathlib.Path] = None) -> bool: 48 | """ 49 | Check if the supplied path exists within a python dist installation or 50 | site installation. 51 | """ 52 | if item_path is None: 53 | # Check Moonraker's package path 54 | item_path = package_path() 55 | if hasattr(site, "getsitepackages"): 56 | # The site module is present, get site packages for Moonraker's venv. 57 | # This is more "correct" than the fallback method. 58 | for site_dir in site.getsitepackages(): 59 | site_path = pathlib.Path(site_dir) 60 | try: 61 | if site_path.samefile(item_path.parent): 62 | return True 63 | except Exception: 64 | pass 65 | # Make an assumption based on the item and/or its parents. If a folder 66 | # is named site-packages or dist-packages then presumably it is an 67 | # installed package 68 | if item_path.name in ("dist-packages", "site-packages"): 69 | return True 70 | for parent in item_path.parents: 71 | if parent.name in ("dist-packages", "site-packages"): 72 | return True 73 | return False 74 | 75 | def package_version() -> Optional[str]: 76 | try: 77 | import moonraker.__version__ as ver # type: ignore 78 | version = ver.__version__ 79 | except Exception: 80 | pass 81 | else: 82 | if version: 83 | return version 84 | return None 85 | 86 | def read_asset(asset_name: str) -> Optional[str]: 87 | if sys.version_info < (3, 10): 88 | with ilr.path("moonraker.assets", asset_name) as p: 89 | if not p.is_file(): 90 | return None 91 | return p.read_text() 92 | else: 93 | files = ilr.files("moonraker.assets") 94 | with ilr.as_file(files.joinpath(asset_name)) as p: 95 | if not p.is_file(): 96 | return None 97 | return p.read_text() 98 | 99 | def get_asset_path() -> Optional[pathlib.Path]: 100 | if sys.version_info < (3, 10): 101 | with ilr.path("moonraker.assets", "__init__.py") as p: 102 | asset_path = p.parent 103 | else: 104 | files = ilr.files("moonraker.assets") 105 | with ilr.as_file(files.joinpath("__init__.py")) as p: 106 | asset_path = p.parent 107 | if not asset_path.is_dir(): 108 | # Somehow running in a zipapp. This is an error. 109 | return None 110 | return asset_path 111 | 112 | def _load_release_info_json(dist_info: Distribution) -> Optional[Dict[str, Any]]: 113 | files = dist_info.files 114 | if files is None: 115 | return None 116 | for dist_file in files: 117 | if dist_file.parts[0] in ["..", "/"]: 118 | continue 119 | if dist_file.name == "release_info": 120 | pkg = dist_file.parts[0] 121 | logging.info(f"Package {pkg}: Detected release_info json file") 122 | try: 123 | return json.loads(dist_file.read_text()) 124 | except Exception: 125 | logging.exception(f"Failed to load release_info from {dist_file}") 126 | return None 127 | 128 | def _load_direct_url_json(dist_info: Distribution) -> Optional[Dict[str, Any]]: 129 | ret: Optional[str] = dist_info.read_text("direct_url.json") 130 | if ret is None: 131 | return None 132 | try: 133 | direct_url: Dict[str, Any] = json.loads(ret) 134 | except json.JSONDecodeError: 135 | return None 136 | return direct_url 137 | 138 | def normalize_project_name(name: str) -> str: 139 | return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') 140 | 141 | def load_distribution_info( 142 | venv_path: pathlib.Path, project_name: str 143 | ) -> PackageInfo: 144 | proj_name_normalized = normalize_project_name(project_name) 145 | site_items = venv_path.joinpath("lib").glob("python*/site-packages/") 146 | lib_paths = [str(p) for p in site_items if p.is_dir()] 147 | for dist_info in Distribution.discover(name=project_name, path=lib_paths): 148 | metadata = dist_info.metadata 149 | if metadata is None: 150 | continue 151 | if not isinstance(dist_info, PathDistribution): 152 | logging.info(f"Project {dist_info.name} not a PathDistribution") 153 | continue 154 | metaname = normalize_project_name(metadata["Name"] or "") 155 | if metaname != proj_name_normalized: 156 | continue 157 | release_info = _load_release_info_json(dist_info) 158 | install_info = _load_direct_url_json(dist_info) 159 | return PackageInfo( 160 | dist_info, metadata, release_info, install_info 161 | ) 162 | raise ServerError(f"Failed to find distribution info for project {project_name}") 163 | 164 | def is_vitualenv_project( 165 | venv_path: Optional[pathlib.Path] = None, 166 | pkg_path: Optional[pathlib.Path] = None, 167 | project_name: str = "moonraker" 168 | ) -> bool: 169 | if venv_path is None: 170 | venv_path = pathlib.Path(sys.exec_prefix) 171 | if pkg_path is None: 172 | pkg_path = package_path() 173 | if not pkg_path.exists(): 174 | return False 175 | try: 176 | pkg_info = load_distribution_info(venv_path, project_name) 177 | except Exception: 178 | return False 179 | site_path = pathlib.Path(str(pkg_info.dist_info.locate_file(""))) 180 | for parent in pkg_path.parents: 181 | try: 182 | if site_path.samefile(parent): 183 | return True 184 | except Exception: 185 | pass 186 | return True 187 | 188 | @dataclass(frozen=True) 189 | class PackageInfo: 190 | dist_info: Distribution 191 | metadata: PackageMetadata 192 | release_info: Optional[Dict[str, Any]] 193 | direct_url_data: Optional[Dict[str, Any]] 194 | -------------------------------------------------------------------------------- /moonraker/utils/sysdeps_parser.py: -------------------------------------------------------------------------------- 1 | # Helpers for parsing system dependencies 2 | # 3 | # Copyright (C) 2025 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | from __future__ import annotations 7 | import shlex 8 | import re 9 | import pathlib 10 | import logging 11 | 12 | from typing import Tuple, Dict, List, Any 13 | 14 | def _get_distro_info() -> Dict[str, Any]: 15 | try: 16 | # try importing the distro module first. It can detect 17 | # old/obscure releases that do not have the standard 18 | # os-release fle. 19 | import distro 20 | except ModuleNotFoundError: 21 | pass 22 | else: 23 | return dict( 24 | distro_id=distro.id(), 25 | distro_version=distro.version(), 26 | aliases=distro.like().split() 27 | ) 28 | # fall back to manual parsing of /etc/os-release 29 | release_file = pathlib.Path("/etc/os-release") 30 | release_info: Dict[str, str] = {} 31 | with release_file.open("r") as f: 32 | lexer = shlex.shlex(f, posix=True) 33 | lexer.whitespace_split = True 34 | for item in list(lexer): 35 | if "=" in item: 36 | key, val = item.split("=", maxsplit=1) 37 | release_info[key] = val 38 | return dict( 39 | distro_id=release_info.get("ID", ""), 40 | distro_version=release_info.get("VERSION_ID", ""), 41 | aliases=release_info.get("ID_LIKE", "").split() 42 | ) 43 | 44 | def _convert_version(version: str) -> Tuple[str | int, ...]: 45 | version = version.strip() 46 | ver_match = re.match(r"\d+(\.\d+)*((?:-|\.).+)?", version) 47 | if ver_match is not None: 48 | return tuple([ 49 | int(part) if part.isdigit() else part 50 | for part in re.split(r"\.|-", version) 51 | ]) 52 | return (version,) 53 | 54 | class SysDepsParser: 55 | def __init__(self, distro_info: Dict[str, Any] | None = None) -> None: 56 | if distro_info is None: 57 | distro_info = _get_distro_info() 58 | self.distro_id: str = distro_info.get("distro_id", "") 59 | self.aliases: List[str] = distro_info.get("aliases", []) 60 | self.distro_version: Tuple[int | str, ...] = tuple() 61 | version = distro_info.get("distro_version") 62 | if version: 63 | self.distro_version = _convert_version(version) 64 | 65 | def _parse_spec(self, full_spec: str) -> str | None: 66 | parts = full_spec.split(";", maxsplit=1) 67 | if len(parts) == 1: 68 | return full_spec 69 | pkg_name = parts[0].strip() 70 | expressions = re.split(r"( and | or )", parts[1].strip()) 71 | if not len(expressions) & 1: 72 | # There should always be an odd number of expressions. Each 73 | # expression is separated by an "and" or "or" operator 74 | logging.info( 75 | f"Requirement specifier is missing an expression " 76 | f"between logical operators : {full_spec}" 77 | ) 78 | return None 79 | last_result: bool = True 80 | last_logical_op: str | None = "and" 81 | for idx, exp in enumerate(expressions): 82 | if idx & 1: 83 | if last_logical_op is not None: 84 | logging.info( 85 | "Requirement specifier contains sequential logical " 86 | f"operators: {full_spec}" 87 | ) 88 | return None 89 | logical_op = exp.strip() 90 | if logical_op not in ("and", "or"): 91 | logging.info( 92 | f"Invalid logical operator {logical_op} in requirement " 93 | f"specifier: {full_spec}") 94 | return None 95 | last_logical_op = logical_op 96 | continue 97 | elif last_logical_op is None: 98 | logging.info( 99 | f"Requirement specifier contains two seqential expressions " 100 | f"without a logical operator: {full_spec}") 101 | return None 102 | dep_parts = re.split(r"(==|!=|<=|>=|<|>)", exp.strip()) 103 | req_var = dep_parts[0].strip().lower() 104 | if len(dep_parts) != 3: 105 | logging.info(f"Invalid comparison, must be 3 parts: {full_spec}") 106 | return None 107 | elif req_var == "distro_id": 108 | left_op: str | Tuple[int | str, ...] = self.distro_id 109 | right_op = dep_parts[2].strip().strip("\"'") 110 | elif req_var == "distro_version": 111 | if not self.distro_version: 112 | logging.info( 113 | "Distro Version not detected, cannot satisfy requirement: " 114 | f"{full_spec}" 115 | ) 116 | return None 117 | left_op = self.distro_version 118 | right_op = _convert_version(dep_parts[2].strip().strip("\"'")) 119 | else: 120 | logging.info(f"Invalid requirement specifier: {full_spec}") 121 | return None 122 | operator = dep_parts[1].strip() 123 | try: 124 | compfunc = { 125 | "<": lambda x, y: x < y, 126 | ">": lambda x, y: x > y, 127 | "==": lambda x, y: x == y, 128 | "!=": lambda x, y: x != y, 129 | ">=": lambda x, y: x >= y, 130 | "<=": lambda x, y: x <= y 131 | }.get(operator, lambda x, y: False) 132 | result = compfunc(left_op, right_op) 133 | if last_logical_op == "and": 134 | last_result &= result 135 | else: 136 | last_result |= result 137 | last_logical_op = None 138 | except Exception: 139 | logging.exception(f"Error comparing requirements: {full_spec}") 140 | return None 141 | if last_result: 142 | return pkg_name 143 | return None 144 | 145 | def parse_dependencies(self, sys_deps: Dict[str, List[str]]) -> List[str]: 146 | if not self.distro_id: 147 | logging.info( 148 | "Failed to detect current distro ID, cannot parse dependencies" 149 | ) 150 | return [] 151 | all_ids = [self.distro_id] + self.aliases 152 | for distro_id in all_ids: 153 | if distro_id in sys_deps: 154 | if not sys_deps[distro_id]: 155 | logging.info( 156 | f"Dependency data contains an empty package definition " 157 | f"for linux distro '{distro_id}'" 158 | ) 159 | continue 160 | processed_deps: List[str] = [] 161 | for dep in sys_deps[distro_id]: 162 | parsed_dep = self._parse_spec(dep) 163 | if parsed_dep is not None: 164 | processed_deps.append(parsed_dep) 165 | return processed_deps 166 | else: 167 | logging.info( 168 | f"Dependency data has no package definition for linux " 169 | f"distro '{self.distro_id}'" 170 | ) 171 | return [] 172 | -------------------------------------------------------------------------------- /pdm_build.py: -------------------------------------------------------------------------------- 1 | # Wheel Setup Script for generating metadata 2 | # 3 | # Copyright (C) 2023 Eric Callahan 4 | # 5 | # This file may be distributed under the terms of the GNU GPLv3 license 6 | 7 | from __future__ import annotations 8 | import pathlib 9 | import subprocess 10 | import shlex 11 | import json 12 | import shutil 13 | from datetime import datetime, timezone 14 | from typing import Dict, Any, TYPE_CHECKING 15 | 16 | if TYPE_CHECKING: 17 | from pdm.backend.hooks.base import Context 18 | 19 | __package_name__ = "moonraker" 20 | __dependencies__ = "scripts/system-dependencies.json" 21 | 22 | def _run_git_command(cmd: str) -> str: 23 | prog = shlex.split(cmd) 24 | process = subprocess.Popen( 25 | prog, stdout=subprocess.PIPE, stderr=subprocess.PIPE 26 | ) 27 | ret, err = process.communicate() 28 | retcode = process.wait() 29 | if retcode == 0: 30 | return ret.strip().decode() 31 | return "" 32 | 33 | def get_commit_sha(source_path: pathlib.Path) -> str: 34 | cmd = f"git -C {source_path} rev-parse HEAD" 35 | return _run_git_command(cmd) 36 | 37 | def retrieve_git_version(source_path: pathlib.Path) -> str: 38 | cmd = f"git -C {source_path} describe --always --tags --long --dirty" 39 | return _run_git_command(cmd) 40 | 41 | def pdm_build_initialize(context: Context) -> None: 42 | context.ensure_build_dir() 43 | proj_name: str = context.config.metadata['name'] 44 | build_dir = pathlib.Path(context.build_dir) 45 | pkg_path = build_dir.joinpath(__package_name__) 46 | pkg_path.mkdir(parents=True, exist_ok=True) 47 | rinfo_path: pathlib.Path = pkg_path.joinpath("release_info") 48 | rinfo_data: str = "" 49 | if context.root.joinpath(".git").exists(): 50 | build_ver: str = context.config.metadata['version'] 51 | build_time = datetime.now(timezone.utc) 52 | urls: Dict[str, str] = context.config.metadata['urls'] 53 | release_info: Dict[str, Any] = { 54 | "project_name": proj_name, 55 | "package_name": __package_name__, 56 | "urls": {key.lower(): val for key, val in urls.items()}, 57 | "package_version": build_ver, 58 | "git_version": retrieve_git_version(context.root), 59 | "commit_sha": get_commit_sha(context.root), 60 | "build_time": datetime.isoformat(build_time, timespec="seconds") 61 | } 62 | if __dependencies__: 63 | deps = pathlib.Path(context.root).joinpath(__dependencies__) 64 | if deps.is_file(): 65 | dep_info: Dict[str, Any] = json.loads(deps.read_bytes()) 66 | release_info["system_dependencies"] = dep_info 67 | # Write the release info to both the package and the data path 68 | rinfo_data = json.dumps(release_info, indent=4) 69 | rinfo_path.write_text(rinfo_data) 70 | scripts_path: pathlib.Path = context.root.joinpath("scripts") 71 | scripts_dest: pathlib.Path = pkg_path.joinpath("scripts") 72 | scripts_dest.mkdir() 73 | for item in scripts_path.iterdir(): 74 | if item.name in ("__pycache__", "python_wheels"): 75 | continue 76 | if item.is_dir(): 77 | shutil.copytree(str(item), str(scripts_dest.joinpath(item.name))) 78 | else: 79 | shutil.copy2(str(item), str(scripts_dest)) 80 | git_ignore = build_dir.joinpath(".gitignore") 81 | if git_ignore.is_file(): 82 | git_ignore.unlink() 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "moonraker" 3 | dynamic = ["version"] 4 | description = "API Server for Klipper" 5 | authors = [ 6 | {name = "Eric Callahan", email = "arksine.code@gmail.com"}, 7 | ] 8 | dependencies = [ 9 | "tornado>=6.2.0, <=6.4.2", 10 | "pyserial==3.4", 11 | "pillow>=9.5.0, <=11.1.0", 12 | "streaming-form-data>=1.11.0, <=1.19.1", 13 | "distro==1.9.0", 14 | "inotify-simple==1.3.5", 15 | "libnacl==2.1.0", 16 | "paho-mqtt==1.6.1", 17 | "zeroconf==0.131.0", 18 | "preprocess-cancellation==0.2.1", 19 | "jinja2==3.1.5", 20 | "dbus-fast==2.28.0 ; python_version>='3.9'", 21 | "dbus-fast<=2.28.0 ; python_version<'3.9'", 22 | "apprise==1.9.2", 23 | "ldap3==2.9.1", 24 | "python-periphery==2.4.1", 25 | "importlib_metadata==6.7.0 ; python_version=='3.7'", 26 | "importlib_metadata==8.2.0 ; python_version>='3.8'" 27 | ] 28 | requires-python = ">=3.7" 29 | readme = "README.md" 30 | license = {text = "GPL-3.0-only"} 31 | keywords = ["klipper", "3D printing", "server", "moonraker"] 32 | classifiers = [ 33 | "Development Status :: 4 - Beta", 34 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 35 | "Programming Language :: Python :: 3", 36 | "Programming Language :: Python :: 3.7", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | ] 43 | 44 | [project.urls] 45 | homepage = "https://github.com/Arksine/moonraker" 46 | repository = "https://github.com/Arksine/moonraker" 47 | documentation = "https://moonraker.readthedocs.io" 48 | changelog = "https://moonraker.readthedocs.io/en/latest/changelog/" 49 | 50 | [project.optional-dependencies] 51 | msgspec = ["msgspec>=0.18.4 ; python_version>='3.8'"] 52 | uvloop = ["uvloop>=0.17.0"] 53 | speedups = [ 54 | "msgspec>=0.18.4 ; python_version>='3.8'", 55 | "uvloop>=0.17.0" 56 | ] 57 | dev = ["pre-commit"] 58 | 59 | [tool.pdm.version] 60 | source = "scm" 61 | write_to = "moonraker/__version__.py" 62 | write_template = "__version__ = '{}'\n" 63 | 64 | [tool.pdm.build] 65 | excludes = ["./**/.git", "moonraker/moonraker.py"] 66 | includes = ["moonraker"] 67 | editable-backend = "path" 68 | custom-hook = "pdm_build.py" 69 | 70 | [project.scripts] 71 | moonraker = "moonraker.server:main" 72 | 73 | [build-system] 74 | requires = ["pdm-backend<=2.4.4"] 75 | build-backend = "pdm.backend" 76 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 7.0 3 | pythonpath = moonraker scripts 4 | testpaths = tests 5 | required_plugins = 6 | pytest-asyncio>=0.17.2 7 | pytest-timeout>=2.1.0 8 | asyncio_mode = strict 9 | timeout = 60 10 | timeout_method = signal 11 | markers = 12 | run_paths 13 | no_ws_connect -------------------------------------------------------------------------------- /scripts/backup-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # LMDB Database backup utility 3 | 4 | DATABASE_PATH="${HOME}/printer_data/database" 5 | MOONRAKER_ENV="${HOME}/moonraker-env" 6 | OUPUT_FILE="${HOME}/database.backup" 7 | 8 | print_help() 9 | { 10 | echo "Moonraker Database Backup Utility" 11 | echo 12 | echo "usage: backup-database.sh [-h] [-e ] [-d ] [-o ]" 13 | echo 14 | echo "optional arguments:" 15 | echo " -h show this message" 16 | echo " -e Moonraker Python Environment" 17 | echo " -d Moonraker LMDB database to backup" 18 | echo " -o backup file to save to" 19 | exit 0 20 | } 21 | 22 | # Parse command line arguments 23 | while getopts "he:d:o:" arg; do 24 | case $arg in 25 | h) print_help;; 26 | e) MOONRAKER_ENV=$OPTARG;; 27 | d) DATABASE_PATH=$OPTARG;; 28 | o) OUPUT_FILE=$OPTARG;; 29 | esac 30 | done 31 | 32 | PYTHON_BIN="${MOONRAKER_ENV}/bin/python" 33 | DB_TOOL="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/dbtool.py" 34 | 35 | if [ ! -f $PYTHON_BIN ]; then 36 | echo "No Python binary found at '${PYTHON_BIN}'" 37 | exit -1 38 | fi 39 | 40 | if [ ! -f "$DATABASE_PATH/data.mdb" ]; then 41 | echo "No Moonraker database found at '${DATABASE_PATH}'" 42 | exit -1 43 | fi 44 | 45 | if [ ! -f $DB_TOOL ]; then 46 | echo "Unable to locate dbtool.py at '${DB_TOOL}'" 47 | exit -1 48 | fi 49 | 50 | ${PYTHON_BIN} ${DB_TOOL} backup ${DATABASE_PATH} ${OUPUT_FILE} 51 | -------------------------------------------------------------------------------- /scripts/build-zip-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script builds a zipped source release for Moonraker and Klipper. 3 | 4 | install_packages() 5 | { 6 | PKGLIST="python3-dev curl" 7 | 8 | # Update system package info 9 | report_status "Running apt-get update..." 10 | sudo apt-get update 11 | 12 | # Install desired packages 13 | report_status "Installing packages..." 14 | sudo apt-get install --yes $PKGLIST 15 | } 16 | 17 | report_status() 18 | { 19 | echo -e "\n\n###### $1" 20 | } 21 | 22 | verify_ready() 23 | { 24 | if [ "$EUID" -eq 0 ]; then 25 | echo "This script must not run as root" 26 | exit -1 27 | fi 28 | 29 | if [ ! -d "$SRCDIR/.git" ]; then 30 | echo "This script must be run from a git repo" 31 | exit -1 32 | fi 33 | 34 | if [ ! -d "$KLIPPER_DIR/.git" ]; then 35 | echo "This script must be run from a git repo" 36 | exit -1 37 | fi 38 | } 39 | 40 | # Force script to exit if an error occurs 41 | set -e 42 | 43 | SRCDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )" 44 | OUTPUT_DIR="$SRCDIR/.dist" 45 | KLIPPER_DIR="$HOME/klipper" 46 | BETA="" 47 | 48 | # Parse command line arguments 49 | while getopts "o:k:b" arg; do 50 | case $arg in 51 | o) OUTPUT_DIR=$OPTARG;; 52 | k) KLIPPER_DIR=$OPTARG;; 53 | b) BETA="-b";; 54 | esac 55 | done 56 | 57 | [ ! -d $OUTPUT_DIR ] && mkdir $OUTPUT_DIR 58 | verify_ready 59 | if [ "$BETA" = "" ]; then 60 | releaseTag=$( git -C $KLIPPER_DIR describe --tags `git -C $KLIPPER_DIR rev-list --tags --max-count=1` ) 61 | echo "Checking out Klipper release $releaseTag" 62 | git -C $KLIPPER_DIR checkout $releaseTag 63 | fi 64 | python3 "$SRCDIR/scripts/build_release.py" -k $KLIPPER_DIR -o $OUTPUT_DIR $BETA 65 | -------------------------------------------------------------------------------- /scripts/data-path-fix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Data Path Fix for legacy MainsailOS and FluiddPi installations running 3 | # a single instance of Moonraker with a default configuration 4 | 5 | DATA_PATH="${HOME}/printer_data" 6 | DATA_PATH_BKP="${HOME}/.broken_printer_data" 7 | DB_PATH="${HOME}/.moonraker_database" 8 | CONFIG_PATH="${HOME}/klipper_config" 9 | LOG_PATH="${HOME}/klipper_logs" 10 | GCODE_PATH="${HOME}/gcode_files" 11 | MOONRAKER_CONF="${CONFIG_PATH}/moonraker.conf" 12 | MOONRAKER_LOG="${LOG_PATH}/moonraker.log" 13 | ALIAS="moonraker" 14 | 15 | # Parse command line arguments 16 | while getopts "c:l:d:a:m:g:" arg; do 17 | case $arg in 18 | c) 19 | MOONRAKER_CONF=$OPTARG 20 | CONFIG_PATH="$( dirname $OPTARG )" 21 | ;; 22 | l) 23 | MOONRAKER_LOG=$OPTARG 24 | LOG_PATH="$( dirname $OPTARG )" 25 | ;; 26 | d) 27 | DATA_PATH=$OPTARG 28 | dpbase="$( basename $OPTARG )" 29 | DATA_PATH_BKP="${HOME}/.broken_${dpbase}" 30 | ;; 31 | a) 32 | ALIAS=$OPTARG 33 | ;; 34 | m) 35 | DB_PATH=$OPTARG 36 | [ ! -f "${DB_PATH}/data.mdb" ] && echo "No valid database found at ${DB_PATH}" && exit 1 37 | ;; 38 | g) 39 | GCODE_PATH=$OPTARG 40 | [ ! -d "${GCODE_PATH}" ] && echo "No GCode Path found at ${GCODE_PATH}" && exit 1 41 | ;; 42 | esac 43 | done 44 | 45 | [ ! -f "${MOONRAKER_CONF}" ] && echo "Error: unable to find config: ${MOONRAKER_CONF}" && exit 1 46 | [ ! -d "${LOG_PATH}" ] && echo "Error: unable to find log path: ${LOG_PATH}" && exit 1 47 | 48 | sudo systemctl stop ${ALIAS} 49 | 50 | [ -d "${DATA_PATH_BKP}" ] && rm -rf ${DATA_PATH_BKP} 51 | [ -d "${DATA_PATH}" ] && echo "Moving broken datapath to ${DATA_PATH_BKP}" && mv ${DATA_PATH} ${DATA_PATH_BKP} 52 | 53 | mkdir ${DATA_PATH} 54 | 55 | echo "Creating symbolic links..." 56 | [ -f "${DB_PATH}/data.mdb" ] && ln -s ${DB_PATH} "$DATA_PATH/database" 57 | [ -d "${GCODE_PATH}" ] && ln -s ${GCODE_PATH} "$DATA_PATH/gcodes" 58 | ln -s ${LOG_PATH} "$DATA_PATH/logs" 59 | ln -s ${CONFIG_PATH} "$DATA_PATH/config" 60 | 61 | [ -f "${DB_PATH}/data.mdb" ] && ~/moonraker-env/bin/python -mlmdb -e ${DB_PATH} -d moonraker edit --delete=validate_install 62 | 63 | echo "Running Moonraker install script..." 64 | 65 | ~/moonraker/scripts/install-moonraker.sh -f -a ${ALIAS} -d ${DATA_PATH} -c ${MOONRAKER_CONF} -l ${MOONRAKER_LOG} 66 | -------------------------------------------------------------------------------- /scripts/fetch-apikey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Helper Script for fetching the API Key from a moonraker database 3 | DATABASE_PATH="${HOME}/printer_data/database" 4 | MOONRAKER_ENV="${HOME}/moonraker-env" 5 | DB_ARGS="--read=READ --db=authorized_users get _API_KEY_USER_" 6 | API_REGEX='(?<="api_key": ")([^"]+)' 7 | GENERATE_NEW="n" 8 | 9 | print_help() 10 | { 11 | echo "Moonraker API Key Extraction Utility" 12 | echo 13 | echo "usage: fetch-apikey.sh [-h] [-g] [-e ] [-d ]" 14 | echo 15 | echo "optional arguments:" 16 | echo " -h show this message" 17 | echo " -g generate new API Key" 18 | echo " -e path to Moonraker env folder" 19 | echo " -d path to Moonraker database folder" 20 | exit 0 21 | } 22 | 23 | # Parse command line arguments 24 | while getopts "hge:d:" arg; do 25 | case $arg in 26 | h) print_help;; 27 | g) GENERATE_NEW="y";; 28 | e) MOONRAKER_ENV=$OPTARG;; 29 | d) DATABASE_PATH=$OPTARG;; 30 | esac 31 | done 32 | 33 | PYTHON_BIN="${MOONRAKER_ENV}/bin/python" 34 | SQL_DATABASE="${DATABASE_PATH}/moonraker-sql.db" 35 | 36 | SQL_APIKEY_SCRIPT=$(cat << EOF 37 | import sqlite3 38 | import uuid 39 | conn = sqlite3.connect("$SQL_DATABASE") 40 | if "$GENERATE_NEW" == "y": 41 | new_key = uuid.uuid4().hex 42 | with conn: 43 | conn.execute( 44 | "UPDATE authorized_users SET password = ? WHERE username='_API_KEY_USER_'", 45 | (new_key,) 46 | ) 47 | res = conn.execute( 48 | "SELECT password FROM authorized_users WHERE username='_API_KEY_USER_'" 49 | ) 50 | print(res.fetchone()[0]) 51 | conn.close() 52 | EOF 53 | ) 54 | 55 | if [ ! -f $PYTHON_BIN ]; then 56 | # attempt to fall back to system install python 57 | if [ ! -x "$( which python3 || true )" ]; then 58 | echo "No Python binary found at '${PYTHON_BIN}' or on the system" 59 | exit 1 60 | fi 61 | PYTHON_BIN="python3" 62 | fi 63 | 64 | if [ ! -d $DATABASE_PATH ]; then 65 | echo "No Moonraker database found at '${DATABASE_PATH}'" 66 | exit 1 67 | fi 68 | 69 | if [ -f "$SQL_DATABASE" ]; then 70 | echo "Fetching API Key from Moonraker's SQL database..." >&2 71 | $PYTHON_BIN -c "$SQL_APIKEY_SCRIPT" 72 | if [ "${GENERATE_NEW}" = "y" ]; then 73 | echo "New API Key Generated, restart Moonraker to apply" >&2 74 | fi 75 | else 76 | echo "Falling back to legacy lmdb database..." >&2 77 | if [ "${GENERATE_NEW}" = "y" ]; then 78 | echo "The -g option may only be used with SQL database implementations" 79 | exit 1 80 | fi 81 | ${PYTHON_BIN} -mlmdb --env=${DATABASE_PATH} ${DB_ARGS} | grep -Po "${API_REGEX}" 82 | fi 83 | -------------------------------------------------------------------------------- /scripts/finish-upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Helper script for completing service upgrades via ssh 3 | 4 | ADDRESS="localhost" 5 | PORT="7125" 6 | API_KEY="" 7 | 8 | # Python Helper Scripts 9 | check_sudo_request=$( cat << EOF 10 | import sys 11 | import json 12 | try: 13 | ret = json.load(sys.stdin) 14 | except Exception: 15 | exit(0) 16 | entries = ret.get('result', {}).get('entries', []) 17 | for item in entries: 18 | if item['dismissed'] is False and item['title'] == 'Sudo Password Required': 19 | sys.stdout.write('true') 20 | exit(0) 21 | sys.stdout.write('false') 22 | EOF 23 | ) 24 | 25 | check_pw_response=$( cat << EOF 26 | import sys 27 | import json 28 | try: 29 | ret = json.load(sys.stdin) 30 | except Exception: 31 | exit(0) 32 | responses = ret.get('result', {}).get('sudo_responses', []) 33 | if responses: 34 | sys.stdout.write('\n'.join(responses)) 35 | EOF 36 | ) 37 | 38 | print_help_message() 39 | { 40 | echo "Utility to complete privileged upgrades for Moonraker" 41 | echo 42 | echo "usage: finish-upgrade.sh [-h] [-a
] [-p ] [-k ]" 43 | echo 44 | echo "optional arguments:" 45 | echo " -h show this message" 46 | echo " -a
address for Moonraker instance" 47 | echo " -p port for Moonraker instance" 48 | echo " -k API Key for authorization" 49 | } 50 | 51 | while getopts "a:p:k:h" arg; do 52 | case $arg in 53 | a) ADDRESS=${OPTARG};; 54 | b) PORT=${OPTARG};; 55 | k) API_KEY=${OPTARG};; 56 | h) 57 | print_help_message 58 | exit 0 59 | ;; 60 | esac 61 | done 62 | 63 | base_url="http://${ADDRESS}:${PORT}" 64 | 65 | echo "Completing Upgrade for Moonraker at ${base_url}" 66 | echo "Requesting Announcements..." 67 | ann_url="${base_url}/server/announcements/list" 68 | curl_cmd=(curl -f -s -S "${ann_url}") 69 | [ -n "${API_KEY}" ] && curl_cmd+=(-H "X-Api-Key: ${API_KEY}") 70 | result="$( "${curl_cmd[@]}" 2>&1 )" 71 | if [ $? -ne 0 ]; then 72 | echo "Moonraker announcement request failed with error: ${result}" 73 | echo "Make sure the address and port are correct. If authorization" 74 | echo "is required supply the API Key with the -k option." 75 | exit -1 76 | fi 77 | has_req="$( echo "$result" | python3 -c "${check_sudo_request}" )" 78 | if [ "$has_req" != "true" ]; then 79 | echo "No sudo request detected, aborting" 80 | exit -1 81 | fi 82 | 83 | # Request Password, send to Moonraker 84 | echo "Sudo request announcement found, please enter your password" 85 | read -sp "Password: " passvar 86 | echo -e "\n" 87 | sudo_url="${base_url}/machine/sudo/password" 88 | curl_cmd=(curl -f -s -S -X POST "${sudo_url}") 89 | curl_cmd+=(-d "{\"password\": \"${passvar}\"}") 90 | curl_cmd+=(-H "Content-Type: application/json") 91 | [ -n "$API_KEY" ] && curl_cmd+=(-H "X-Api-Key: ${API_KEY}") 92 | 93 | result="$( "${curl_cmd[@]}" 2>&1)" 94 | if [ $? -ne 0 ]; then 95 | echo "Moonraker password request failed with error: ${result}" 96 | echo "Make sure you entered the correct password." 97 | exit -1 98 | fi 99 | response="$( echo "$result" | python3 -c "${check_pw_response}" )" 100 | if [ -n "${response}" ]; then 101 | echo "${response}" 102 | else 103 | echo "Invalid response received from Moonraker. Raw result: ${result}" 104 | fi 105 | -------------------------------------------------------------------------------- /scripts/make_sysdeps.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | # Create system dependencies json file from the install script 3 | # 4 | # Copyright (C) 2023 Eric Callahan 5 | # 6 | # This file may be distributed under the terms of the GNU GPLv3 license 7 | from __future__ import annotations 8 | import argparse 9 | import pathlib 10 | import json 11 | import re 12 | from typing import List, Dict 13 | 14 | def make_sysdeps(input: str, output: str, distro: str, truncate: bool) -> None: 15 | sysdeps: Dict[str, List[str]] = {} 16 | outpath = pathlib.Path(output).expanduser().resolve() 17 | if outpath.is_file() and not truncate: 18 | sysdeps = json.loads(outpath.read_bytes()) 19 | inst_path: pathlib.Path = pathlib.Path(input).expanduser().resolve() 20 | if not inst_path.is_file(): 21 | raise Exception(f"Unable to locate install script: {inst_path}") 22 | data = inst_path.read_text() 23 | plines: List[str] = re.findall(r'PKGLIST="(.*)"', data) 24 | plines = [p.lstrip("${PKGLIST}").strip() for p in plines] 25 | packages: List[str] = [] 26 | for line in plines: 27 | packages.extend(line.split()) 28 | sysdeps[distro] = packages 29 | outpath.write_text(json.dumps(sysdeps, indent=4)) 30 | 31 | 32 | if __name__ == "__main__": 33 | def_path = pathlib.Path(__file__).parent 34 | desc = ( 35 | "make_sysdeps - generate system dependency json file from an install script" 36 | ) 37 | parser = argparse.ArgumentParser(description=desc) 38 | parser.add_argument( 39 | "-i", "--input", metavar="", 40 | help="path of the install script to read", 41 | default=f"{def_path}/install-moonraker.sh" 42 | ) 43 | parser.add_argument( 44 | "-o", "--output", metavar="", 45 | help="path of the system dependency file to write", 46 | default=f"{def_path}/system-dependencies.json" 47 | ) 48 | parser.add_argument( 49 | "-d", "--distro", metavar="", 50 | help="linux distro for dependencies", default="debian" 51 | ) 52 | parser.add_argument( 53 | "-t", "--truncate", action="store_true", 54 | help="truncate output file" 55 | ) 56 | args = parser.parse_args() 57 | make_sysdeps(args.input, args.output, args.distro, args.truncate) 58 | -------------------------------------------------------------------------------- /scripts/moonraker-dev-reqs.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | -------------------------------------------------------------------------------- /scripts/moonraker-requirements.txt: -------------------------------------------------------------------------------- 1 | # Python dependencies for Moonraker 2 | --find-links=python_wheels 3 | tornado>=6.2.0, <=6.4.2 4 | pyserial==3.4 5 | pillow>=9.5.0, <=11.1.0 6 | streaming-form-data>=1.11.0, <=1.19.1 7 | distro==1.9.0 8 | inotify-simple==1.3.5 9 | libnacl==2.1.0 10 | paho-mqtt==1.6.1 11 | zeroconf==0.131.0 12 | preprocess-cancellation==0.2.1 13 | jinja2==3.1.5 14 | dbus-fast==2.28.0 ; python_version>='3.9' 15 | dbus-fast<=2.28.0 ; python_version<'3.9' 16 | apprise==1.9.2 17 | ldap3==2.9.1 18 | python-periphery==2.4.1 19 | importlib_metadata==6.7.0 ; python_version=='3.7' 20 | importlib_metadata==8.2.0 ; python_version>='3.8' 21 | -------------------------------------------------------------------------------- /scripts/moonraker-speedups.txt: -------------------------------------------------------------------------------- 1 | msgspec>=0.18.4 ; python_version>='3.8' 2 | uvloop>=0.17.0 3 | -------------------------------------------------------------------------------- /scripts/python_wheels/zeroconf-0.131.0-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arksine/moonraker/0310d0be9fd207c510554ff64ca418cf4bcdaf9f/scripts/python_wheels/zeroconf-0.131.0-py3-none-any.whl -------------------------------------------------------------------------------- /scripts/restore-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # LMDB Database restore utility 3 | 4 | DATABASE_PATH="${HOME}/printer_data/database" 5 | MOONRAKER_ENV="${HOME}/moonraker-env" 6 | INPUT_FILE="${HOME}/database.backup" 7 | 8 | print_help() 9 | { 10 | echo "Moonraker Database Restore Utility" 11 | echo 12 | echo "usage: restore-database.sh [-h] [-e ] [-d ] [-i ]" 13 | echo 14 | echo "optional arguments:" 15 | echo " -h show this message" 16 | echo " -e Moonraker Python Environment" 17 | echo " -d Moonraker LMDB database path to restore to" 18 | echo " -i backup file to restore from" 19 | exit 0 20 | } 21 | 22 | # Parse command line arguments 23 | while getopts "he:d:i:" arg; do 24 | case $arg in 25 | h) print_help;; 26 | e) MOONRAKER_ENV=$OPTARG;; 27 | d) DATABASE_PATH=$OPTARG;; 28 | i) INPUT_FILE=$OPTARG;; 29 | esac 30 | done 31 | 32 | PYTHON_BIN="${MOONRAKER_ENV}/bin/python" 33 | DB_TOOL="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/dbtool.py" 34 | 35 | if [ ! -f $PYTHON_BIN ]; then 36 | echo "No Python binary found at '${PYTHON_BIN}'" 37 | exit -1 38 | fi 39 | 40 | if [ ! -d $DATABASE_PATH ]; then 41 | echo "No database folder found at '${DATABASE_PATH}'" 42 | exit -1 43 | fi 44 | 45 | if [ ! -f $INPUT_FILE ]; then 46 | echo "No Database Backup File found at '${INPUT_FILE}'" 47 | exit -1 48 | fi 49 | 50 | if [ ! -f $DB_TOOL ]; then 51 | echo "Unable to locate dbtool.py at '${DB_TOOL}'" 52 | exit -1 53 | fi 54 | 55 | ${PYTHON_BIN} ${DB_TOOL} restore ${DATABASE_PATH} ${INPUT_FILE} 56 | -------------------------------------------------------------------------------- /scripts/set-policykit-rules.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script installs Moonraker's PolicyKit Rules used to grant access 3 | 4 | POLKIT_LEGACY_DIR="/etc/polkit-1/localauthority/50-local.d" 5 | POLKIT_DIR="/etc/polkit-1/rules.d" 6 | POLKIT_USR_DIR="/usr/share/polkit-1/rules.d" 7 | MOONRAKER_UNIT="/etc/systemd/system/moonraker.service" 8 | MOONRAKER_GID="-1" 9 | 10 | check_moonraker_service() 11 | { 12 | 13 | # Force Add the moonraker-admin group 14 | sudo groupadd -f moonraker-admin 15 | [ ! -f $MOONRAKER_UNIT ] && return 16 | # Make sure the unit file contains supplementary group 17 | HAS_SUPP="$( grep -cm1 "SupplementaryGroups=moonraker-admin" $MOONRAKER_UNIT || true )" 18 | [ "$HAS_SUPP" -eq 1 ] && return 19 | report_status "Adding moonraker-admin supplementary group to $MOONRAKER_UNIT" 20 | sudo sed -i "/^Type=simple$/a SupplementaryGroups=moonraker-admin" $MOONRAKER_UNIT 21 | sudo systemctl daemon-reload 22 | } 23 | 24 | add_polkit_legacy_rules() 25 | { 26 | RULE_FILE="${POLKIT_LEGACY_DIR}/10-moonraker.pkla" 27 | report_status "Installing Moonraker PolicyKit Rules (Legacy) to ${RULE_FILE}..." 28 | ACTIONS="org.freedesktop.systemd1.manage-units" 29 | ACTIONS="${ACTIONS};org.freedesktop.login1.power-off" 30 | ACTIONS="${ACTIONS};org.freedesktop.login1.power-off-multiple-sessions" 31 | ACTIONS="${ACTIONS};org.freedesktop.login1.reboot" 32 | ACTIONS="${ACTIONS};org.freedesktop.login1.reboot-multiple-sessions" 33 | ACTIONS="${ACTIONS};org.freedesktop.login1.halt" 34 | ACTIONS="${ACTIONS};org.freedesktop.login1.halt-multiple-sessions" 35 | ACTIONS="${ACTIONS};org.freedesktop.packagekit.*" 36 | sudo /bin/sh -c "cat > ${RULE_FILE}" << EOF 37 | [moonraker permissions] 38 | Identity=unix-user:$USER 39 | Action=$ACTIONS 40 | ResultAny=yes 41 | EOF 42 | } 43 | 44 | add_polkit_rules() 45 | { 46 | if [ ! -x "$(command -v pkaction)" ]; then 47 | echo "PolicyKit not installed" 48 | exit 1 49 | fi 50 | POLKIT_VERSION="$( pkaction --version | grep -Po "(\d+\.?\d*)" )" 51 | report_status "PolicyKit Version ${POLKIT_VERSION} Detected" 52 | if [ "$POLKIT_VERSION" = "0.105" ]; then 53 | # install legacy pkla file 54 | add_polkit_legacy_rules 55 | return 56 | fi 57 | RULE_FILE="" 58 | if [ -d $POLKIT_USR_DIR ]; then 59 | RULE_FILE="${POLKIT_USR_DIR}/moonraker.rules" 60 | elif [ -d $POLKIT_DIR ]; then 61 | RULE_FILE="${POLKIT_DIR}/moonraker.rules" 62 | else 63 | echo "PolicyKit rules folder not detected" 64 | exit 1 65 | fi 66 | report_status "Installing PolicyKit Rules to ${RULE_FILE}..." 67 | MOONRAKER_GID=$( getent group moonraker-admin | awk -F: '{printf "%d", $3}' ) 68 | sudo /bin/sh -c "cat > ${RULE_FILE}" << EOF 69 | // Allow Moonraker User to manage systemd units, reboot and shutdown 70 | // the system 71 | polkit.addRule(function(action, subject) { 72 | if ((action.id == "org.freedesktop.systemd1.manage-units" || 73 | action.id == "org.freedesktop.login1.power-off" || 74 | action.id == "org.freedesktop.login1.power-off-multiple-sessions" || 75 | action.id == "org.freedesktop.login1.reboot" || 76 | action.id == "org.freedesktop.login1.reboot-multiple-sessions" || 77 | action.id == "org.freedesktop.login1.halt" || 78 | action.id == "org.freedesktop.login1.halt-multiple-sessions" || 79 | action.id.startsWith("org.freedesktop.packagekit.")) && 80 | subject.user == "$USER") { 81 | // Only allow processes with the "moonraker-admin" supplementary group 82 | // access 83 | var regex = "^Groups:.+?\\\s$MOONRAKER_GID[\\\s\\\0]"; 84 | var cmdpath = "/proc/" + subject.pid.toString() + "/status"; 85 | try { 86 | polkit.spawn(["grep", "-Po", regex, cmdpath]); 87 | return polkit.Result.YES; 88 | } catch (error) { 89 | return polkit.Result.NOT_HANDLED; 90 | } 91 | } 92 | }); 93 | EOF 94 | } 95 | 96 | clear_polkit_rules() 97 | { 98 | report_status "Removing all Moonraker PolicyKit rules" 99 | sudo rm -f "${POLKIT_LEGACY_DIR}/10-moonraker.pkla" 100 | sudo rm -f "${POLKIT_USR_DIR}/moonraker.rules" 101 | sudo rm -f "${POLKIT_DIR}/moonraker.rules" 102 | } 103 | 104 | # Helper functions 105 | report_status() 106 | { 107 | echo -e "\n\n###### $1" 108 | } 109 | 110 | verify_ready() 111 | { 112 | if [ "$EUID" -eq 0 ]; then 113 | echo "This script must not run as root" 114 | exit -1 115 | fi 116 | } 117 | 118 | CLEAR="n" 119 | ROOT="n" 120 | DISABLE_SYSTEMCTL="n" 121 | 122 | # Parse command line arguments 123 | while :; do 124 | case $1 in 125 | -c|--clear) 126 | CLEAR="y" 127 | ;; 128 | -r|--root) 129 | ROOT="y" 130 | ;; 131 | -z|--disable-systemctl) 132 | DISABLE_SYSTEMCTL="y" 133 | ;; 134 | *) 135 | break 136 | esac 137 | 138 | shift 139 | done 140 | 141 | if [ "$ROOT" = "n" ]; then 142 | verify_ready 143 | fi 144 | 145 | if [ "$CLEAR" = "y" ]; then 146 | clear_polkit_rules 147 | else 148 | set -e 149 | check_moonraker_service 150 | add_polkit_rules 151 | if [ $DISABLE_SYSTEMCTL = "n" ]; then 152 | report_status "Restarting Moonraker..." 153 | sudo systemctl restart moonraker 154 | fi 155 | fi 156 | -------------------------------------------------------------------------------- /scripts/sudo_fix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # moonraker-sudo (mnrkrsudo) 4 | # Provides a specified Group that is intended to elevate user privileges 5 | # to help moonraker with sudo actions, if in CustomPIOS Images with 6 | # Module "password-for-sudo". 7 | # 8 | # Partially used functions from Arcsine 9 | # 10 | # Copyright (C) 2020 Stephan Wendel 11 | # 12 | # This file may be distributed under the terms of the GNU GPLv3 license 13 | 14 | ### Exit on Errors 15 | set -e 16 | 17 | ### Configuration 18 | 19 | SUDOERS_DIR='/etc/sudoers.d' 20 | SUDOERS_FILE='020-sudo-for-moonraker' 21 | NEW_GROUP='mnrkrsudo' 22 | 23 | 24 | ### Functions 25 | 26 | verify_ready() 27 | { 28 | if [ "$EUID" -eq 0 ]; then 29 | echo "This script must not run as root" 30 | exit -1 31 | fi 32 | } 33 | 34 | create_sudoers_file() 35 | { 36 | 37 | SCRIPT_TEMP_PATH=/tmp 38 | 39 | report_status "Creating ${SUDOERS_FILE} ..." 40 | sudo rm -f $SCRIPT_TEMP_PATH/$SUDOERS_FILE 41 | sudo sed "s/GROUPNAME/$NEW_GROUP/g" > $SCRIPT_TEMP_PATH/$SUDOERS_FILE << '#EOF' 42 | 43 | ### Elevate moonraker API rights 44 | ### Do NOT allow Command Parts only Full Commands 45 | ### for example 46 | ### 47 | ### /sbin/systemctl "reboot", /sbin/apt "update", ..... 48 | 49 | Defaults!/usr/bin/apt-get env_keep +="DEBIAN_FRONTEND" 50 | 51 | Cmnd_Alias REBOOT = /sbin/shutdown -r now, /bin/systemctl "reboot" 52 | Cmnd_Alias SHUTDOWN = /sbin/shutdown now, /sbin/shutdown -h now, /bin/systemctl "poweroff" 53 | Cmnd_Alias APT = /usr/bin/apt-get 54 | Cmnd_Alias SYSTEMCTL = /bin/systemctl 55 | 56 | 57 | 58 | %GROUPNAME ALL=(ALL) NOPASSWD: REBOOT, SHUTDOWN, APT, SYSTEMCTL 59 | 60 | #EOF 61 | 62 | report_status "\e[1;32m...done\e[0m" 63 | } 64 | 65 | update_env() 66 | { 67 | report_status "Export System Variable: DEBIAN_FRONTEND=noninteractive" 68 | sudo /bin/sh -c 'echo "DEBIAN_FRONTEND=noninteractive" >> /etc/environment' 69 | } 70 | 71 | verify_syntax() 72 | { 73 | if [ -n "$(whereis -b visudo | awk '{print $2}')" ]; then 74 | 75 | report_status "\e[1;33mVerifying Syntax of ${SUDOERS_FILE}\e[0m\n" 76 | 77 | if [ $(LANG=C sudo visudo -cf $SCRIPT_TEMP_PATH/$SUDOERS_FILE | grep -c "OK" ) -eq 1 ]; 78 | then 79 | VERIFY_STATUS=0 80 | report_status "\e[1;32m$(LANG=C sudo visudo -cf $SCRIPT_TEMP_PATH/$SUDOERS_FILE)\e[0m" 81 | else 82 | report_status "\e[1;31mSyntax Error:\e[0m Check File: $SCRIPT_TEMP_PATH/$SUDOERS_FILE" 83 | exit 1 84 | fi 85 | else 86 | VERIFY_STATUS=0 87 | report_status "\e[1;31mCommand 'visudo' not found. Skip verifying sudoers file.\e[0m" 88 | fi 89 | } 90 | 91 | install_sudoers_file() 92 | { 93 | verify_syntax 94 | if [ $VERIFY_STATUS -eq 0 ]; 95 | then 96 | report_status "Copying $SCRIPT_TEMP_PATH/$SUDOERS_FILE to $SUDOERS_DIR/$SUDOERS_FILE" 97 | sudo chmod 0440 $SCRIPT_TEMP_PATH/$SUDOERS_FILE 98 | sudo cp --preserve=mode $SCRIPT_TEMP_PATH/$SUDOERS_FILE $SUDOERS_DIR/$SUDOERS_FILE 99 | else 100 | exit 1 101 | fi 102 | } 103 | 104 | check_update_sudoers_file() 105 | { 106 | if [ -e "$SUDOERS_DIR/$SUDOERS_FILE" ]; 107 | then 108 | create_sudoers_file 109 | if [ -z $(sudo diff $SCRIPT_TEMP_PATH/$SUDOERS_FILE $SUDOERS_DIR/$SUDOERS_FILE) ] 110 | then 111 | report_status "No need to update $SUDOERS_DIR/$SUDOERS_FILE" 112 | else 113 | report_status "$SUDOERS_DIR/$SUDOERS_FILE needs to be updated." 114 | install_sudoers_file 115 | fi 116 | fi 117 | } 118 | 119 | 120 | add_new_group() 121 | { 122 | sudo addgroup --system $NEW_GROUP &> /dev/null 123 | report_status "\e[1;32m...done\e[0m" 124 | } 125 | 126 | add_user_to_group() 127 | { 128 | sudo usermod -aG $NEW_GROUP $USER &> /dev/null 129 | report_status "\e[1;32m...done\e[0m" 130 | } 131 | 132 | adduser_hint() 133 | { 134 | report_status "\e[1;31mYou have to REBOOT to take changes effect!\e[0m" 135 | } 136 | 137 | # Helper functions 138 | report_status() 139 | { 140 | echo -e "\n\n###### $1" 141 | } 142 | 143 | clean_temp() 144 | { 145 | sudo rm -f $SCRIPT_TEMP_PATH/$SUDOERS_FILE 146 | } 147 | ### Main 148 | 149 | verify_ready 150 | 151 | if [ -e "$SUDOERS_DIR/$SUDOERS_FILE" ] && [ $(sudo cat /etc/gshadow | grep -c "${NEW_GROUP}") -eq 1 ] && [ $(groups | grep -c "$NEW_GROUP") -eq 1 ]; 152 | then 153 | check_update_sudoers_file 154 | report_status "\e[1;32mEverything is setup, nothing to do...\e[0m\n" 155 | exit 0 156 | 157 | else 158 | 159 | if [ -e "$SUDOERS_DIR/$SUDOERS_FILE" ]; 160 | then 161 | report_status "\e[1;32mFile exists:\e[0m ${SUDOERS_FILE}" 162 | check_update_sudoers_file 163 | else 164 | report_status "\e[1;31mFile not found:\e[0m ${SUDOERS_FILE}\n" 165 | create_sudoers_file 166 | install_sudoers_file 167 | fi 168 | 169 | if [ $(sudo cat /etc/gshadow | grep -c "${NEW_GROUP}") -eq 1 ]; 170 | then 171 | report_status "Group ${NEW_GROUP} already exists..." 172 | else 173 | report_status "Group ${NEW_GROUP} will be added..." 174 | add_new_group 175 | fi 176 | 177 | if [ $(groups | grep -c "$NEW_GROUP") -eq 1 ]; 178 | then 179 | report_status "User ${USER} is already in $NEW_GROUP..." 180 | else 181 | report_status "Adding User ${USER} to Group $NEW_GROUP..." 182 | add_user_to_group 183 | adduser_hint 184 | fi 185 | fi 186 | 187 | update_env 188 | clean_temp 189 | exit 0 190 | -------------------------------------------------------------------------------- /scripts/sync_dependencies.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | # Script for syncing package dependencies and python reqs 3 | # 4 | # Copyright (C) 2024 Eric Callahan 5 | # 6 | # This file may be distributed under the terms of the GNU GPLv3 license 7 | 8 | from __future__ import annotations 9 | import argparse 10 | import pathlib 11 | import tomllib 12 | import json 13 | import ast 14 | from io import StringIO, TextIOBase 15 | from typing import Dict, List, Iterator 16 | 17 | MAX_LINE_LENGTH = 88 18 | SCRIPTS_PATH = pathlib.Path(__file__).parent 19 | INST_PKG_HEADER = "# *** AUTO GENERATED OS PACKAGE SCRIPT START ***" 20 | INST_PKG_FOOTER = "# *** AUTO GENERATED OS PACKAGE SCRIPT END ***" 21 | DEPS_HEADER = "# *** SYSTEM DEPENDENCIES START ***" 22 | DEPS_FOOTER = "# *** SYSTEM DEPENDENCIES END ***" 23 | 24 | def gen_pkg_list(values: List[str], indent: int = 0) -> Iterator[str]: 25 | idt = " " * indent 26 | if not values: 27 | return 28 | current_line = f"{idt}\"{values.pop(0)}\"," 29 | for val in values: 30 | if len(current_line) + len(val) + 4 > MAX_LINE_LENGTH: 31 | yield current_line + "\n" 32 | current_line = f"{idt}\"{val}\"," 33 | else: 34 | current_line += f" \"{val}\"," 35 | yield current_line.rstrip(",") + "\n" 36 | 37 | def write_parser_script(sys_deps: Dict[str, List[str]], out_hdl: TextIOBase) -> None: 38 | parser_file = SCRIPTS_PATH.parent.joinpath("moonraker/utils/sysdeps_parser.py") 39 | out_hdl.write(" get_pkgs_script=$(cat << EOF\n") 40 | with parser_file.open("r") as f: 41 | for line in f: 42 | if not line.strip().startswith("#"): 43 | out_hdl.write(line) 44 | out_hdl.write(f"{DEPS_HEADER}\n") 45 | out_hdl.write("system_deps = {\n") 46 | for distro, packages in sys_deps.items(): 47 | indent = " " * 4 48 | out_hdl.write(f"{indent}\"{distro}\": [\n") 49 | # Write packages 50 | for line in gen_pkg_list(packages, 8): 51 | out_hdl.write(line) 52 | out_hdl.write(f"{indent}],\n") 53 | out_hdl.write("}\n") 54 | out_hdl.write(f"{DEPS_FOOTER}\n") 55 | out_hdl.writelines(""" 56 | parser = SysDepsParser() 57 | pkgs = parser.parse_dependencies(system_deps) 58 | if pkgs: 59 | print(' '.join(pkgs), end="") 60 | exit(0) 61 | EOF 62 | ) 63 | """.lstrip()) 64 | 65 | def sync_packages() -> int: 66 | inst_script = SCRIPTS_PATH.joinpath("install-moonraker.sh") 67 | sys_deps_file = SCRIPTS_PATH.joinpath("system-dependencies.json") 68 | prev_deps: Dict[str, List[str]] = {} 69 | new_deps: Dict[str, List[str]] = json.loads(sys_deps_file.read_bytes()) 70 | # Copy install script in memory. 71 | install_data = StringIO() 72 | prev_deps_str: str = "" 73 | skip_data = False 74 | collect_deps = False 75 | with inst_script.open("r") as inst_file: 76 | for line in inst_file: 77 | cur_line = line.strip() 78 | if not skip_data: 79 | install_data.write(line) 80 | else: 81 | # parse current dependencies 82 | if collect_deps: 83 | if line.rstrip() == DEPS_FOOTER: 84 | collect_deps = False 85 | else: 86 | prev_deps_str += line 87 | elif line.rstrip() == DEPS_HEADER: 88 | collect_deps = True 89 | if cur_line == INST_PKG_HEADER: 90 | skip_data = True 91 | elif cur_line == INST_PKG_FOOTER: 92 | skip_data = False 93 | install_data.write(line) 94 | if prev_deps_str: 95 | try: 96 | # start at the beginning of the dict literal 97 | idx = prev_deps_str.find("{") 98 | if idx > 0: 99 | prev_deps = ast.literal_eval(prev_deps_str[idx:]) 100 | except Exception: 101 | pass 102 | print(f"Previous Dependencies:\n{prev_deps}") 103 | # Check if an update is necessary 104 | if set(prev_deps.keys()) == set(new_deps.keys()): 105 | for distro, prev_pkgs in prev_deps.items(): 106 | new_pkgs = new_deps[distro] 107 | if set(prev_pkgs) != set(new_pkgs): 108 | break 109 | else: 110 | # Dependencies match, exit 111 | print("System package dependencies match") 112 | return 0 113 | install_data.seek(0) 114 | print("Writing new system dependencies to install script...") 115 | with inst_script.open("w+") as inst_file: 116 | # Find and replace old package defs 117 | for line in install_data: 118 | inst_file.write(line) 119 | if line.strip() == INST_PKG_HEADER: 120 | write_parser_script(new_deps, inst_file) 121 | return 1 122 | 123 | def check_reqs_changed(reqs_file: pathlib.Path, new_reqs: List[str]) -> bool: 124 | req_list = [] 125 | for requirement in reqs_file.read_text().splitlines(): 126 | requirement = requirement.strip() 127 | if not requirement or requirement[0] in ("-", "#"): 128 | continue 129 | req_list.append(requirement) 130 | return set(new_reqs) != set(req_list) 131 | 132 | def sync_requirements() -> int: 133 | ret: int = 0 134 | src_path = SCRIPTS_PATH.parent 135 | proj_file = src_path.joinpath("pyproject.toml") 136 | with proj_file.open("rb") as f: 137 | data = tomllib.load(f) 138 | python_deps = data["project"]["dependencies"] 139 | optional_deps = data["project"]["optional-dependencies"] 140 | reqs_path = SCRIPTS_PATH.joinpath("moonraker-requirements.txt") 141 | if check_reqs_changed(reqs_path, python_deps): 142 | print("Syncing Moonraker Python Requirements...") 143 | ret = 1 144 | with reqs_path.open("w+") as req_file: 145 | req_file.write("# Python dependencies for Moonraker\n") 146 | req_file.write("--find-links=python_wheels\n") 147 | for requirement in python_deps: 148 | req_file.write(f"{requirement}\n") 149 | else: 150 | print("Moonraker Python requirements match") 151 | # sync speedups 152 | speedups_path = SCRIPTS_PATH.joinpath("moonraker-speedups.txt") 153 | speedup_deps = optional_deps["speedups"] 154 | if check_reqs_changed(speedups_path, speedup_deps): 155 | print("Syncing speedup requirements...") 156 | ret = 1 157 | with speedups_path.open("w+") as req_file: 158 | for requirement in speedup_deps: 159 | req_file.write(f"{requirement}\n") 160 | else: 161 | print("Speedup sequirements match") 162 | # sync dev dependencies 163 | dev_reqs_path = SCRIPTS_PATH.joinpath("moonraker-dev-reqs.txt") 164 | dev_deps = optional_deps["dev"] 165 | if check_reqs_changed(dev_reqs_path, dev_deps): 166 | print("Syncing dev requirements") 167 | ret = 1 168 | with dev_reqs_path.open("r+") as req_file: 169 | for requirement in dev_deps: 170 | req_file.write(f"{requirement}\n") 171 | else: 172 | print("Dev requirements match") 173 | return ret 174 | 175 | def main() -> int: 176 | parser = argparse.ArgumentParser() 177 | parser.add_argument( 178 | "filename", default="", nargs="?", 179 | help="The name of the dependency file to sync" 180 | ) 181 | args = parser.parse_args() 182 | fname: str = args.filename 183 | if not fname: 184 | ret = sync_requirements() 185 | ret += sync_packages() 186 | return 1 if ret > 0 else 0 187 | elif fname == "pyproject.toml": 188 | return sync_requirements() 189 | elif fname == "scripts/system-dependencies.json": 190 | return sync_packages() 191 | return 0 192 | 193 | 194 | if __name__ == "__main__": 195 | raise SystemExit(main()) 196 | -------------------------------------------------------------------------------- /scripts/system-dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "debian": [ 3 | "python3-virtualenv", 4 | "python3-dev", 5 | "libopenjp2-7", 6 | "libsodium-dev", 7 | "zlib1g-dev", 8 | "libjpeg-dev", 9 | "packagekit", 10 | "wireless-tools; distro_id != 'ubuntu' or distro_version <= '24.04'", 11 | "iw; distro_id == 'ubuntu' and distro_version >= '24.10'", 12 | "curl", 13 | "build-essential" 14 | ] 15 | } -------------------------------------------------------------------------------- /scripts/tag-release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # Helper Script for Tagging Moonraker Releases 3 | 4 | PRINT_ONLY="n" 5 | KLIPPER_PATH="$HOME/klipper" 6 | REMOTE="" 7 | DESCRIBE="describe --always --tags --long" 8 | 9 | # Get Tag and Klipper Path 10 | TAG=$1 11 | shift 12 | while :; do 13 | case $1 in 14 | -k|--klipper-path) 15 | shift 16 | KLIPPER_PATH=$1 17 | ;; 18 | -p|--print) 19 | PRINT_ONLY="y" 20 | ;; 21 | *) 22 | break 23 | esac 24 | 25 | shift 26 | done 27 | 28 | 29 | if [ ! -d "$KLIPPER_PATH/.git" ]; then 30 | echo "Invalid Klipper Path: $KLIPPER_PATH" 31 | fi 32 | echo "Klipper found at $KLIPPER_PATH" 33 | GIT_CMD="git -C $KLIPPER_PATH" 34 | 35 | ALL_REMOTES="$( $GIT_CMD remote | tr '\n' ' ' | awk '{gsub(/^ +| +$/,"")} {print $0}' )" 36 | echo "Found Klipper Remotes: $ALL_REMOTES" 37 | for val in $ALL_REMOTES; do 38 | REMOTE_URL="$( $GIT_CMD remote get-url $val | awk '{gsub(/^ +| +$/,"")} {print tolower($0)}' )" 39 | match="$( echo $REMOTE_URL | grep -Ecm1 '(klipper3d|kevinoconnor)/klipper'|| true )" 40 | if [ "$match" -eq 1 ]; then 41 | echo "Found Remote $val" 42 | REMOTE="$val" 43 | break 44 | fi 45 | done 46 | 47 | [ "$REMOTE" = "" ] && echo "Unable to find a valid remote" && exit 1 48 | 49 | $GIT_CMD fetch $REMOTE 50 | 51 | DESC="$( $GIT_CMD $DESCRIBE $REMOTE/master | awk '{gsub(/^ +| +$/,"")} {print $0}' )" 52 | HASH="$( $GIT_CMD rev-parse $REMOTE/master | awk '{gsub(/^ +| +$/,"")} {print $0}' )" 53 | 54 | if [ "$PRINT_ONLY" = "y" ]; then 55 | echo " 56 | Tag: $TAG 57 | Repo: Klipper 58 | Branch: Master 59 | Version: $DESC 60 | Commit: $HASH 61 | " 62 | else 63 | echo "Adding Tag $TAG" 64 | git tag -a $TAG -m "Moonraker Version $TAG 65 | Klipper Tag Data 66 | repo: klipper 67 | branch: master 68 | version: $DESC 69 | commit: $HASH 70 | " 71 | fi 72 | -------------------------------------------------------------------------------- /scripts/uninstall-moonraker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Moonraker uninstall script for Raspbian/Raspberry Pi OS 3 | 4 | stop_service() { 5 | # Stop Moonraker Service 6 | echo "#### Stopping Moonraker Service.." 7 | sudo systemctl stop moonraker 8 | } 9 | 10 | remove_service() { 11 | # Remove Moonraker LSB/systemd service 12 | echo 13 | echo "#### Removing Moonraker Service.." 14 | if [ -f "/etc/init.d/moonraker" ]; then 15 | # legacy installation, remove the LSB service 16 | sudo update-rc.d -f moonraker remove 17 | sudo rm -f /etc/init.d/moonraker 18 | sudo rm -f /etc/default/moonraker 19 | else 20 | # Remove systemd installation 21 | sudo systemctl disable moonraker 22 | sudo rm -f /etc/systemd/system/moonraker.service 23 | sudo systemctl daemon-reload 24 | sudo systemctl reset-failed 25 | fi 26 | } 27 | 28 | remove_files() { 29 | # Remove API Key file from older versions 30 | if [ -e ~/.klippy_api_key ]; then 31 | echo "Removing legacy API Key" 32 | rm ~/.klippy_api_key 33 | fi 34 | 35 | # Remove API Key file from recent versions 36 | if [ -e ~/.moonraker_api_key ]; then 37 | echo "Removing API Key" 38 | rm ~/.moonraker_api_key 39 | fi 40 | 41 | # Remove virtualenv 42 | if [ -d ~/moonraker-env ]; then 43 | echo "Removing virtualenv..." 44 | rm -rf ~/moonraker-env 45 | else 46 | echo "No moonraker virtualenv found" 47 | fi 48 | 49 | # Notify user of method to remove Moonraker source code 50 | echo 51 | echo "The Moonraker system files and virtualenv have been removed." 52 | echo 53 | echo "The following command is typically used to remove source files:" 54 | echo " rm -rf ~/moonraker" 55 | echo 56 | echo "You may also wish to uninstall nginx:" 57 | echo " sudo apt-get remove nginx" 58 | } 59 | 60 | verify_ready() 61 | { 62 | if [ "$EUID" -eq 0 ]; then 63 | echo "This script must not run as root" 64 | exit -1 65 | fi 66 | } 67 | 68 | verify_ready 69 | stop_service 70 | remove_service 71 | remove_files 72 | -------------------------------------------------------------------------------- /tests/assets/klipper/klipper.dict: -------------------------------------------------------------------------------- 1 | {"build_versions":"gcc: (GCC) 5.4.0 binutils: (GNU Binutils) 2.26.20160125","commands":{"allocate_oids count=%c":20,"buttons_ack oid=%c count=%c":8,"buttons_add oid=%c pos=%c pin=%u pull_up=%c":32,"buttons_query oid=%c clock=%u rest_ticks=%u retransmit_count=%c invert=%c":21,"clear_shutdown":6,"config_adxl345 oid=%c spi_oid=%c":30,"config_analog_in oid=%c pin=%u":54,"config_buttons oid=%c button_count=%c":5,"config_counter oid=%c pin=%u pull_up=%c":42,"config_digital_out oid=%c pin=%u value=%c default_value=%c max_duration=%u":24,"config_endstop oid=%c pin=%c pull_up=%c":22,"config_hd44780 oid=%c rs_pin=%u e_pin=%u d4_pin=%u d5_pin=%u d6_pin=%u d7_pin=%u delay_ticks=%u":57,"config_i2c oid=%c i2c_bus=%u rate=%u address=%u":68,"config_neopixel oid=%c pin=%u data_size=%hu bit_max_ticks=%u reset_min_ticks=%u":58,"config_pwm_out oid=%c pin=%u cycle_ticks=%u value=%hu default_value=%hu max_duration=%u":15,"config_spi oid=%c pin=%u":12,"config_spi_shutdown oid=%c spi_oid=%c shutdown_msg=%*s":23,"config_spi_without_cs oid=%c":56,"config_st7920 oid=%c cs_pin=%u sclk_pin=%u sid_pin=%u sync_delay_ticks=%u cmd_delay_ticks=%u":38,"config_stepper oid=%c step_pin=%c dir_pin=%c invert_step=%c step_pulse_ticks=%u":45,"config_thermocouple oid=%c spi_oid=%c thermocouple_type=%c":34,"config_tmcuart oid=%c rx_pin=%u pull_up=%c tx_pin=%u bit_time=%u":65,"config_trsync oid=%c":3,"debug_nop":69,"debug_ping data=%*s":17,"debug_read order=%c addr=%u":52,"debug_write order=%c addr=%u val=%u":47,"emergency_stop":39,"endstop_home oid=%c clock=%u sample_ticks=%u sample_count=%c rest_ticks=%u pin_value=%c trsync_oid=%c trigger_reason=%c":14,"endstop_query_state oid=%c":9,"finalize_config crc=%u":2,"get_clock":43,"get_config":63,"get_uptime":13,"hd44780_send_cmds oid=%c cmds=%*s":49,"hd44780_send_data oid=%c data=%*s":31,"i2c_modify_bits oid=%c reg=%*s clear_set_bits=%*s":29,"i2c_read oid=%c reg=%*s read_len=%u":60,"i2c_write oid=%c data=%*s":36,"identify offset=%u count=%c":1,"neopixel_send oid=%c":40,"neopixel_update oid=%c pos=%hu data=%*s":51,"query_adxl345 oid=%c clock=%u rest_ticks=%u":10,"query_adxl345_status oid=%c":55,"query_analog_in oid=%c clock=%u sample_ticks=%u sample_count=%c rest_ticks=%u min_value=%hu max_value=%hu range_check_count=%c":27,"query_counter oid=%c clock=%u poll_ticks=%u sample_ticks=%u":4,"query_thermocouple oid=%c clock=%u rest_ticks=%u min_value=%u max_value=%u":53,"queue_digital_out oid=%c clock=%u on_ticks=%u":67,"queue_pwm_out oid=%c clock=%u value=%hu":48,"queue_step oid=%c interval=%u count=%hu add=%hi":19,"reset":37,"reset_step_clock oid=%c clock=%u":28,"set_digital_out pin=%u value=%c":7,"set_digital_out_pwm_cycle oid=%c cycle_ticks=%u":46,"set_next_step_dir oid=%c dir=%c":64,"set_pwm_out pin=%u cycle_ticks=%u value=%hu":18,"spi_send oid=%c data=%*s":59,"spi_set_bus oid=%c spi_bus=%u mode=%u rate=%u":41,"spi_set_software_bus oid=%c miso_pin=%u mosi_pin=%u sclk_pin=%u mode=%u rate=%u":50,"spi_transfer oid=%c data=%*s":35,"st7920_send_cmds oid=%c cmds=%*s":62,"st7920_send_data oid=%c data=%*s":26,"stepper_get_position oid=%c":25,"stepper_stop_on_trigger oid=%c trsync_oid=%c":16,"tmcuart_send oid=%c write=%*s read=%c":44,"trsync_set_timeout oid=%c clock=%u":11,"trsync_start oid=%c report_clock=%u report_ticks=%u expire_reason=%c":33,"trsync_trigger oid=%c reason=%c":61,"update_digital_out oid=%c value=%c":66},"config":{"ADC_MAX":1023,"BUS_PINS_spi":"PB3,PB2,PB1","BUS_PINS_twi":"PD0,PD1","CLOCK_FREQ":16000000,"MCU":"atmega2560","PWM_MAX":255,"RECEIVE_WINDOW":192,"RESERVE_PINS_serial":"PE0,PE1","SERIAL_BAUD":250000,"STATS_SUMSQ_BASE":256},"enumerations":{"i2c_bus":{"twi":0},"pin":{"PA0":[0,8],"PB0":[8,8],"PC0":[16,8],"PD0":[24,8],"PE0":[32,8],"PF0":[40,8],"PG0":[48,8],"PH0":[56,8],"PJ0":[72,8],"PK0":[80,8],"PL0":[88,8]},"spi_bus":{"spi":0},"static_string_id":{"ADC out of range":26,"Already finalized":13,"Can not set soft pwm cycle ticks while updates pending":20,"Can not use timer1 for PWM; timer1 is used for timers":49,"Can't add signal that is already active":25,"Can't assign oid":11,"Can't reset time when stepper active":22,"Command parser error":7,"Command request":8,"Failed to send i2c start":45,"Invalid buttons retransmit count":34,"Invalid command":5,"Invalid count parameter":23,"Invalid move request size":14,"Invalid neopixel data_size":39,"Invalid neopixel update command":38,"Invalid oid type":12,"Invalid spi config":27,"Invalid spi_setup parameters":44,"Invalid thermocouple chip type":30,"Max of 8 buttons":36,"Message encode error":6,"Missed scheduling of next digital out event":21,"Missed scheduling of next hard pwm event":33,"Move queue overflow":15,"Not a valid ADC pin":43,"Not a valid PWM pin":50,"Not a valid input pin":41,"Not an output pin":42,"PWM already programmed at different speed":48,"Rescheduled timer in the past":40,"Scheduled digital out event will exceed max_duration":19,"Scheduled pwm event will exceed max_duration":32,"Set button past maximum button count":35,"Shutdown cleared when not shutdown":2,"Stepper too far in past":24,"Thermocouple ADC out of range":29,"Thermocouple reader fault":28,"Timer too close":3,"Unsupported i2c bus":47,"Watchdog timer!":51,"alloc_chunk failed":17,"alloc_chunks failed":16,"config_reset only available when shutdown":9,"i2c timeout":46,"i2c_modify_bits: Odd number of bits!":31,"oids already allocated":10,"sentinel timer called":4,"tmcuart data too large":37,"update_digital_out not valid with active queue":18},"thermocouple_type":{"MAX31855":0,"MAX31856":1,"MAX31865":2,"MAX6675":3}},"responses":{"adxl345_data oid=%c sequence=%hu data=%*s":87,"adxl345_status oid=%c clock=%u query_ticks=%u next_sequence=%hu buffered=%c fifo=%c limit_count=%hu":86,"analog_in_state oid=%c next_clock=%u value=%hu":82,"buttons_state oid=%c ack_count=%c state=%*s":88,"clock clock=%u":75,"config is_config=%c crc=%u is_shutdown=%c move_count=%hu":76,"counter_state oid=%c next_clock=%u count=%u count_clock=%u":91,"debug_result val=%u":78,"endstop_state oid=%c homing=%c next_clock=%u pin_value=%c":80,"i2c_read_response oid=%c response=%*s":85,"identify_response offset=%u data=%.*s":0,"is_shutdown static_string_id=%hu":71,"neopixel_result oid=%c success=%c":90,"pong data=%*s":77,"shutdown clock=%u static_string_id=%hu":72,"spi_transfer_response oid=%c response=%*s":83,"starting":70,"stats count=%u sum=%u sumsq=%u":73,"stepper_position oid=%c pos=%i":79,"thermocouple_result oid=%c next_clock=%u value=%u fault=%c":84,"tmcuart_response oid=%c read=%*s":89,"trsync_state oid=%c can_trigger=%c trigger_reason=%c clock=%u":81,"uptime high=%u clock=%u":74},"version":"v0.10.0-250-g01431991"} -------------------------------------------------------------------------------- /tests/assets/moonraker/bare_db.cdb: -------------------------------------------------------------------------------- 1 | +32,24:TU9PTlJBS0VSX0RBVEFCQVNFX1NUQVJU->bmFtZXNwYWNlX2NvdW50PTU= 2 | +36,12:bmFtZXNwYWNlX2F1dGhvcml6ZWRfdXNlcnM=->ZW50cmllcz0x 3 | +20,148:X0FQSV9LRVlfVVNFUl8=->eyJ1c2VybmFtZSI6ICJfQVBJX0tFWV9VU0VSXyIsICJhcGlfa2V5IjogIjg4ZTdlMjA0MDU3YjQzYTdiNTI3ZGEwZDQzNjQ1MDg5IiwgImNyZWF0ZWRfb24iOiAxNjQ1NDkwOTExLjM5NzI1OTd9 4 | +32,12:bmFtZXNwYWNlX2djb2RlX21ldGFkYXRh->ZW50cmllcz0w 5 | +24,12:bmFtZXNwYWNlX2hpc3Rvcnk=->ZW50cmllcz0w 6 | +28,12:bmFtZXNwYWNlX21vb25yYWtlcg==->ZW50cmllcz0z 7 | +12,236:ZGF0YWJhc2U=->eyJkZWJ1Z19jb3VudGVyIjogMiwgInVuc2FmZV9zaHV0ZG93bnMiOiAxLCAicHJvdGVjdGVkX25hbWVzcGFjZXMiOiBbImdjb2RlX21ldGFkYXRhIiwgImhpc3RvcnkiLCAibW9vbnJha2VyIiwgInVwZGF0ZV9tYW5hZ2VyIl0sICJmb3JiaWRkZW5fbmFtZXNwYWNlcyI6IFsiYXV0aG9yaXplZF91c2VycyJdfQ== 8 | +24,12:ZGF0YWJhc2VfdmVyc2lvbg==->cQEAAAAAAAAA 9 | +16,84:ZmlsZV9tYW5hZ2Vy->eyJtZXRhZGF0YV92ZXJzaW9uIjogMywgImdjb2RlX3BhdGgiOiAiL2hvbWUvcGkvZ2NvZGVfZmlsZXMifQ== 10 | +32,12:bmFtZXNwYWNlX3VwZGF0ZV9tYW5hZ2Vy->ZW50cmllcz02 11 | +8,400:Zmx1aWRk->eyJsYXN0X2NvbmZpZ19oYXNoIjogImIyNDE4OTgyZmVhOTg1ZmZlN2ZlODFhOWQ4MWI0MDUwMThmMDFhYjM5MTNmNTk4MmJhMzllZjY4NzFiZjE3NDkiLCAibGFzdF9yZWZyZXNoX3RpbWUiOiAxNjQ1NDkwOTI2LjkwOTYzOTEsICJ2ZXJzaW9uIjogInYxLjE2LjIiLCAicmVtb3RlX3ZlcnNpb24iOiAidjEuMTYuMiIsICJkbF9pbmZvIjogWyJodHRwczovL2dpdGh1Yi5jb20vZmx1aWRkLWNvcmUvZmx1aWRkL3JlbGVhc2VzL2Rvd25sb2FkL3YxLjE2LjIvZmx1aWRkLnppcCIsICJhcHBsaWNhdGlvbi96aXAiLCA5NTE4NjU1XX0= 12 | +12,3072:a2xpcHBlcg==->eyJsYXN0X2NvbmZpZ19oYXNoIjogIjg4OTMzZjgyNTVhMTQyNDI2YjM1ODdhYTY0MDdlNTZmNDllZDlmZWM2MWZhOTViMTVmY2Q2NmQ1ZDE3MGU5MGEiLCAibGFzdF9yZWZyZXNoX3RpbWUiOiAxNjQ1NDkwOTIyLjQxNTY0OTIsICJpc192YWxpZCI6IHRydWUsICJuZWVkX2NoYW5uZWxfdXBkYXRlIjogZmFsc2UsICJyZXBvX3ZhbGlkIjogdHJ1ZSwgImdpdF9vd25lciI6ICJLbGlwcGVyM2QiLCAiZ2l0X3JlcG9fbmFtZSI6ICJrbGlwcGVyIiwgImdpdF9yZW1vdGUiOiAib3JpZ2luIiwgImdpdF9icmFuY2giOiAibWFzdGVyIiwgImN1cnJlbnRfdmVyc2lvbiI6ICJ2MC4xMC4wLTI3MSIsICJ1cHN0cmVhbV92ZXJzaW9uIjogInYwLjEwLjAtMjc2IiwgImN1cnJlbnRfY29tbWl0IjogIjhiMGM2ZmNiMDg5NzY5ZjcwZWNiYjExY2MzNzkzZGNkNjFmNDQ1ZGQiLCAidXBzdHJlYW1fY29tbWl0IjogIjJiMmNhYThmMDUwZDMyZWZlMTY1OWU4ZDdjNzQzMWQwN2U5ZTY3YTAiLCAidXBzdHJlYW1fdXJsIjogImh0dHBzOi8vZ2l0aHViLmNvbS9LbGlwcGVyM2Qva2xpcHBlci5naXQiLCAiZnVsbF92ZXJzaW9uX3N0cmluZyI6ICJ2MC4xMC4wLTI3MS1nOGIwYzZmY2ItZGlydHkiLCAiYnJhbmNoZXMiOiBbImRldi13ZWJob29rcy0yMDIxMTExNCIsICJkZXYtd2ViaG9va3MtZml4IiwgIm1hc3RlciJdLCAiZGlydHkiOiB0cnVlLCAiaGVhZF9kZXRhY2hlZCI6IGZhbHNlLCAiZ2l0X21lc3NhZ2VzIjogW10sICJjb21taXRzX2JlaGluZCI6IFt7InNoYSI6ICIyYjJjYWE4ZjA1MGQzMmVmZTE2NTllOGQ3Yzc0MzFkMDdlOWU2N2EwIiwgImF1dGhvciI6ICJGcmFuayBUYWNraXR0IiwgImRhdGUiOiAiMTY0NTQ2Nzk3OCIsICJzdWJqZWN0IjogImtsaXBweS1yZXF1aXJlbWVudHM6IFBpbiBtYXJrdXBzYWZlPT0xLjEuMSB0byBmaXggcHl0aG9uMyAoIzUyODYpIiwgIm1lc3NhZ2UiOiAiTWFya3Vwc2FmZSB1cGRhdGVkIGFuZCB0aGUgbGF0ZXN0IHZlcnNpb24gbm8gbG9uZ2VyIGluY2x1ZGVzIGBzb2Z0X3VuaWNvZGVgXHJcblxyXG5TaWduZWQtb2ZmLWJ5OiBGcmFua2x5biBUYWNraXR0IDxnaXRAZnJhbmsuYWY+IiwgInRhZyI6IG51bGx9LCB7InNoYSI6ICI5ZTE1MzIxNDE4OWQxMDdmZWRjMTJiODNhZWZkYzQyZWZkOTE5NmY5IiwgImF1dGhvciI6ICJLZXZpbiBPJ0Nvbm5vciIsICJkYXRlIjogIjE2NDU0NjQwMjEiLCAic3ViamVjdCI6ICJkb2NzOiBNaW5vciB3b3JkaW5nIGNoYW5nZSB0byBFeGFtcGxlX0NvbmZpZ3MubWQiLCAibWVzc2FnZSI6ICJTaWduZWQtb2ZmLWJ5OiBLZXZpbiBPJ0Nvbm5vciA8a2V2aW5Aa29jb25ub3IubmV0PiIsICJ0YWciOiBudWxsfSwgeyJzaGEiOiAiNzIwMmE1ZGE4ZTIzZGJkZTI4YTE3Y2I2MDNlZWUzMDM4NWZkZDk1MSIsICJhdXRob3IiOiAiS2V2aW4gTydDb25ub3IiLCAiZGF0ZSI6ICIxNjQ1NDYzODUwIiwgInN1YmplY3QiOiAiZG9jczogTWlub3Igd29yZGluZyBjaGFuZ2UgaW4gRXhhbXBsZV9Db25maWdzLm1kIiwgIm1lc3NhZ2UiOiAiU2lnbmVkLW9mZi1ieTogS2V2aW4gTydDb25ub3IgPGtldmluQGtvY29ubm9yLm5ldD4iLCAidGFnIjogbnVsbH0sIHsic2hhIjogIjc0ZGJkOGE4ZTQxYmM5ZDJiMDk3Yzk5N2Y0ZTk3NWI2MGVmZTY4MTEiLCAiYXV0aG9yIjogIktldmluIE8nQ29ubm9yIiwgImRhdGUiOiAiMTY0NTQ2MzY5OSIsICJzdWJqZWN0IjogImRvY3M6IEZpeCBFeGFtcGxlX0NvbmZpZ3MubWQgbGlzdCByZW5kZXJpbmciLCAibWVzc2FnZSI6ICJNa2RvY3MgZG9lc24ndCBzdXBwb3J0IGEgdGhpcmQgbGV2ZWwgb2YgbGlzdCBuZXN0aW5nLlxuXG5TaWduZWQtb2ZmLWJ5OiBLZXZpbiBPJ0Nvbm5vciA8a2V2aW5Aa29jb25ub3IubmV0PiIsICJ0YWciOiBudWxsfSwgeyJzaGEiOiAiYzNiYWE2NzFhNWY0YjZkNjk5YjdiY2FiNTdkZmEwNWJhZWMwYmNlMCIsICJhdXRob3IiOiAiS2V2aW4gTydDb25ub3IiLCAiZGF0ZSI6ICIxNjQ1NDYzMDg1IiwgInN1YmplY3QiOiAiZG9jczogVXBkYXRlIEV4YW1wbGVfQ29uZmlncy5tZCIsICJtZXNzYWdlIjogIkRvY3VtZW50IHRoYXQgc3BhY2VzIGFuZCBzcGVjaWFsIGNoYXJhY3RlcnMgc2hvdWxkIG5vdCBiZSBpbiB0aGVcbmNvbmZpZyBmaWxlbmFtZS5cblxuUmVtb3ZlIHJlZmVyZW5jZSB0byBzdGVwX2Rpc3RhbmNlIGFuZCBwaW5fbWFwIGRlcHJlY2F0ZWQgZmVhdHVyZXMsIGFzXG50aG9zZSBmZWF0dXJlcyBhcmUgbm93IGZ1bGx5IHJlbW92ZWQuXG5cblNpZ25lZC1vZmYtYnk6IEtldmluIE8nQ29ubm9yIDxrZXZpbkBrb2Nvbm5vci5uZXQ+IiwgInRhZyI6IG51bGx9XX0= 13 | +12,404:bWFpbnNhaWw=->eyJsYXN0X2NvbmZpZ19oYXNoIjogIjFlNDRlOWZkZDQ2YmI1MzYxN2IwZjJkNjg1YmNhODBkM2MzMzUxYTA3YzA5YmM2NzQyMDA0NWFjNTQxMzAyZjQiLCAibGFzdF9yZWZyZXNoX3RpbWUiOiAxNjQ1NDkwOTI2LjQ1ODIzOTMsICJ2ZXJzaW9uIjogInYyLjAuMSIsICJyZW1vdGVfdmVyc2lvbiI6ICJ2Mi4xLjIiLCAiZGxfaW5mbyI6IFsiaHR0cHM6Ly9naXRodWIuY29tL21haW5zYWlsLWNyZXcvbWFpbnNhaWwvcmVsZWFzZXMvZG93bmxvYWQvdjIuMS4yL21haW5zYWlsLnppcCIsICJhcHBsaWNhdGlvbi96aXAiLCAzNTEyNzYxXX0= 14 | +12,1636:bW9vbnJha2Vy->eyJsYXN0X2NvbmZpZ19oYXNoIjogImVjNDEwMWQ4MWIzYzc5MzgzYjIyN2MwOGQwYTg4NDk5Mjg5NDM1ZmFlMGI0MTc5N2U2MWU1NjdjZWEyM2MyNjkiLCAibGFzdF9yZWZyZXNoX3RpbWUiOiAxNjQ1NDkwOTI0LjI4MzY1NTYsICJpc192YWxpZCI6IHRydWUsICJuZWVkX2NoYW5uZWxfdXBkYXRlIjogZmFsc2UsICJyZXBvX3ZhbGlkIjogdHJ1ZSwgImdpdF9vd25lciI6ICI/IiwgImdpdF9yZXBvX25hbWUiOiAibW9vbnJha2VyIiwgImdpdF9yZW1vdGUiOiAiYXJrc2luZSIsICJnaXRfYnJhbmNoIjogImRldi1kYXRhYmFzZS1hc3luYy0yNTAxMjAyMiIsICJjdXJyZW50X3ZlcnNpb24iOiAidjAuNy4xLTQxOCIsICJ1cHN0cmVhbV92ZXJzaW9uIjogInYwLjcuMS00MTUiLCAiY3VycmVudF9jb21taXQiOiAiODRiOGZkNDZmOWEzNjVhYmI5ZWIzY2NiNThjZjdlOWFhZGRmNjJmMiIsICJ1cHN0cmVhbV9jb21taXQiOiAiODA2OTA1MmRmYmU3OTY2ZjljNWY2ZjkwMWU3ZmIyMWFjMmFkMjJjOSIsICJ1cHN0cmVhbV91cmwiOiAiZ2l0Oi8vZXJpYy13b3JrLmhvbWUvbW9vbnJha2VyIiwgImZ1bGxfdmVyc2lvbl9zdHJpbmciOiAidjAuNy4xLTQxOC1nODRiOGZkNCIsICJicmFuY2hlcyI6IFsiZGV2LWNwdV90aHJvdHRsZWRfcGVyZi0yMDIxMTEwMyIsICJkZXYtdXBkYXRlLW1hbmFnZXItbXVsdGljbGllbnQiLCAibWFzdGVyIl0sICJkaXJ0eSI6IGZhbHNlLCAiaGVhZF9kZXRhY2hlZCI6IHRydWUsICJnaXRfbWVzc2FnZXMiOiBbXSwgImNvbW1pdHNfYmVoaW5kIjogW3sic2hhIjogIjgwNjkwNTJkZmJlNzk2NmY5YzVmNmY5MDFlN2ZiMjFhYzJhZDIyYzkiLCAiYXV0aG9yIjogIkVyaWMgQ2FsbGFoYW4iLCAiZGF0ZSI6ICIxNjQ1NDgyNDA5IiwgInN1YmplY3QiOiAic2NyaXB0czogaW50cm9kdWNlIGRidG9vbCIsICJtZXNzYWdlIjogIlRoaXMgdG9vbCBtYXkgYmUgdXNlZCB0byBiYWNrdXAgYW5kIHJlc3RvcmUgTW9vbnJha2VyJ3MgbG1kYlxuZGF0YWJhc2Ugd2l0aG91dCBkZXBlbmRpbmcgb24gdGhlIFwibG1kYi11dGlsc1wiIHBhY2thZ2UuICBUaGVcbmJhY2t1cCBpcyBkb25lIHRvIGEgcGxhaW4gdGV4dCBmaWxlIGluIGNkYiBmb3JtYXQsIHNvIGEgYmFja3VwXG5tYXkgYmUgcmVzdG9yZWQgb24gYW55IHBsYXRmb3JtLlxuXG5TaWduZWQtb2ZmLWJ5OiAgRXJpYyBDYWxsYWhhbiA8YXJrc2luZS5jb2RlQGdtYWlsLmNvbT4iLCAidGFnIjogbnVsbH1dfQ== 15 | +12,928:bW9vbnRlc3Q=->eyJsYXN0X2NvbmZpZ19oYXNoIjogIjgwYzY5NjgwNWU3MTczOWIyNWEzYTFiNjNhMTc1YmM5Y2Q1NGVkM2U5YTBiMzBhNDhhNzAzYWFkZWI2YjNmNmMiLCAibGFzdF9yZWZyZXNoX3RpbWUiOiAxNjQ1NDkwOTI3LjgzMDQ0NzQsICJpc192YWxpZCI6IHRydWUsICJuZWVkX2NoYW5uZWxfdXBkYXRlIjogZmFsc2UsICJyZXBvX3ZhbGlkIjogdHJ1ZSwgImdpdF9vd25lciI6ICJhcmtzaW5lIiwgImdpdF9yZXBvX25hbWUiOiAibW9vbnRlc3QiLCAiZ2l0X3JlbW90ZSI6ICJvcmlnaW4iLCAiZ2l0X2JyYW5jaCI6ICJtYXN0ZXIiLCAiY3VycmVudF92ZXJzaW9uIjogInYwLjAuMS0yIiwgInVwc3RyZWFtX3ZlcnNpb24iOiAidjAuMC4xLTIiLCAiY3VycmVudF9jb21taXQiOiAiNWI0Yjk0ODBkYmQxODZiMTY2ZDM2NTVjMTFiNGY2NDBkYzEzNTA5YiIsICJ1cHN0cmVhbV9jb21taXQiOiAiNWI0Yjk0ODBkYmQxODZiMTY2ZDM2NTVjMTFiNGY2NDBkYzEzNTA5YiIsICJ1cHN0cmVhbV91cmwiOiAiaHR0cHM6Ly9naXRodWIuY29tL2Fya3NpbmUvbW9vbnRlc3QuZ2l0IiwgImZ1bGxfdmVyc2lvbl9zdHJpbmciOiAidjAuMC4xLTItZzViNGI5NDgiLCAiYnJhbmNoZXMiOiBbIm1hc3RlciJdLCAiZGlydHkiOiBmYWxzZSwgImhlYWRfZGV0YWNoZWQiOiBmYWxzZSwgImdpdF9tZXNzYWdlcyI6IFtdLCAiY29tbWl0c19iZWhpbmQiOiBbXX0= 16 | +8,108:c3lzdGVt->eyJsYXN0X2NvbmZpZ19oYXNoIjogIiIsICJsYXN0X3JlZnJlc2hfdGltZSI6IDE2NDU0OTA5MTQuMTU3MzQwOCwgInBhY2thZ2VzIjogW119 17 | -------------------------------------------------------------------------------- /tests/assets/moonraker/base_server.conf: -------------------------------------------------------------------------------- 1 | [server] 2 | host: 0.0.0.0 3 | port: 7010 4 | ssl_port: 7011 5 | klippy_uds_address: ${klippy_uds_path} 6 | 7 | [database] 8 | database_path: ${database_path} 9 | 10 | [machine] 11 | provider: none 12 | 13 | [file_manager] 14 | config_path: ${config_path} 15 | log_path: ${log_path} 16 | 17 | [secrets] 18 | secrets_path: ${secrets_path} 19 | -------------------------------------------------------------------------------- /tests/assets/moonraker/base_server_ssl.conf: -------------------------------------------------------------------------------- 1 | [server] 2 | host: 0.0.0.0 3 | port: 7010 4 | ssl_port: 7011 5 | ssl_certificate_path: ${ssl_certificate_path} 6 | ssl_key_path: ${ssl_key_path} 7 | klippy_uds_address: ${klippy_uds_path} 8 | 9 | [database] 10 | database_path: ${database_path} 11 | 12 | [machine] 13 | provider: none 14 | 15 | [file_manager] 16 | config_path: ${config_path} 17 | log_path: ${log_path} 18 | 19 | [secrets] 20 | secrets_path: ${secrets_path} 21 | -------------------------------------------------------------------------------- /tests/assets/moonraker/invalid_config.conf: -------------------------------------------------------------------------------- 1 | [server] 2 | host: 0.0.0.0 3 | port: 7010 4 | klippy_uds_address: ${klippy_uds_path} 5 | 6 | # Syntax error 7 | database] 8 | database_path: ${database_path} 9 | 10 | [machine] 11 | provider: none 12 | 13 | [file_manager] 14 | config_path: ${config_path} 15 | log_path: ${log_path} 16 | 17 | [secrets] 18 | secrets_path: ${secrets_path} 19 | -------------------------------------------------------------------------------- /tests/assets/moonraker/secrets.ini: -------------------------------------------------------------------------------- 1 | [mqtt_credentials] 2 | username: mqttuser 3 | password: mqttpass 4 | -------------------------------------------------------------------------------- /tests/assets/moonraker/secrets.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_credentials": { 3 | "username": "mqttuser", 4 | "password": "mqttpass" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/assets/moonraker/supplemental.conf: -------------------------------------------------------------------------------- 1 | [prefix_sec one] 2 | 3 | [prefix_sec two] 4 | 5 | [prefix_sec three] 6 | 7 | [test_options] 8 | test_int: 1 9 | test_float: 3.5 10 | test_bool: True 11 | test_string: Hello World 12 | test_list: 13 | one 14 | two 15 | three 16 | test_int_list: 1,2,3 17 | test_float_list: 1.5,2.8,3.2 18 | test_multi_list: 19 | 1,2,3 20 | 4,5,6 21 | test_dict: 22 | one=1 23 | two=2 24 | three=3 25 | test_dict_empty_field: 26 | one=test 27 | two 28 | three 29 | test_template: {secrets.mqtt_credentials.username} 30 | test_gpio: gpiochip0/gpio26 31 | test_gpio_no_chip: gpio26 32 | test_gpio_invert: !gpiochip0/gpio26 33 | test_gpio_no_chip_invert: !gpio26 34 | # The following four options should result in an error, cant 35 | # pullup/pulldown an output pin 36 | test_gpio_pullup: ^gpiochip0/gpio26 37 | test_gpio_pullup_no_chip: ^gpio26 38 | test_gpio_pulldown: ~gpiochip0/gpio26 39 | test_gpio_pulldown_no_chip: ~gpio26 -------------------------------------------------------------------------------- /tests/assets/moonraker/unparsed_server.conf: -------------------------------------------------------------------------------- 1 | [server] 2 | host: 0.0.0.0 3 | port: 7010 4 | klippy_uds_address: ${klippy_uds_path} 5 | # Add an option that is not registered, should 6 | # generate a warning 7 | unknown_option: True 8 | 9 | [machine] 10 | provider: none 11 | 12 | [database] 13 | database_path: ${database_path} 14 | 15 | [file_manager] 16 | config_path: ${config_path} 17 | log_path: ${log_path} 18 | 19 | [secrets] 20 | secrets_path: ${secrets_path} 21 | 22 | [machine unparsed] 23 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from .klippy_process import KlippyProcess 2 | from .http_client import HttpClient 3 | from .websocket_client import WebsocketClient 4 | 5 | __all__ = ("KlippyProcess", "HttpClient", "WebsocketClient") 6 | -------------------------------------------------------------------------------- /tests/fixtures/http_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPError 4 | from tornado.httputil import HTTPHeaders 5 | from tornado.escape import url_escape 6 | from typing import Dict, Any, Optional 7 | 8 | class HttpClient: 9 | error = HTTPError 10 | def __init__(self, 11 | type: str = "http", 12 | port: int = 7010 13 | ) -> None: 14 | self.client = AsyncHTTPClient() 15 | assert type in ["http", "https"] 16 | self.prefix = f"{type}://127.0.0.1:{port}/" 17 | self.last_response_headers: HTTPHeaders = HTTPHeaders() 18 | 19 | def get_response_headers(self) -> HTTPHeaders: 20 | return self.last_response_headers 21 | 22 | async def _do_request(self, 23 | method: str, 24 | endpoint: str, 25 | args: Dict[str, Any] = {}, 26 | headers: Optional[Dict[str, str]] = None 27 | ) -> Dict[str, Any]: 28 | ep = "/".join([url_escape(part, plus=False) for part in 29 | endpoint.lstrip("/").split("/")]) 30 | url = self.prefix + ep 31 | method = method.upper() 32 | body: Optional[str] = "" if method == "POST" else None 33 | if args: 34 | if method in ["GET", "DELETE"]: 35 | parts = [] 36 | for key, val in args.items(): 37 | if isinstance(val, list): 38 | val = ",".join(val) 39 | if val: 40 | parts.append(f"{url_escape(key)}={url_escape(val)}") 41 | else: 42 | parts.append(url_escape(key)) 43 | qs = "&".join(parts) 44 | url += "?" + qs 45 | else: 46 | body = json.dumps(args) 47 | if headers is None: 48 | headers = {} 49 | headers["Content-Type"] = "application/json" 50 | request = HTTPRequest(url, method, headers, body=body, 51 | request_timeout=2., connect_timeout=2.) 52 | ret = await self.client.fetch(request) 53 | self.last_response_headers = HTTPHeaders(ret.headers) 54 | return json.loads(ret.body) 55 | 56 | async def get(self, 57 | endpoint: str, 58 | args: Dict[str, Any] = {}, 59 | headers: Optional[Dict[str, str]] = None 60 | ) -> Dict[str, Any]: 61 | return await self._do_request("GET", endpoint, args, headers) 62 | 63 | async def post(self, 64 | endpoint: str, 65 | args: Dict[str, Any] = {}, 66 | headers: Optional[Dict[str, str]] = None, 67 | ) -> Dict[str, Any]: 68 | return await self._do_request("POST", endpoint, args, headers) 69 | 70 | async def delete(self, 71 | endpoint: str, 72 | args: Dict[str, Any] = {}, 73 | headers: Optional[Dict[str, str]] = None 74 | ) -> Dict[str, Any]: 75 | return await self._do_request("DELETE", endpoint, args, headers) 76 | 77 | def close(self): 78 | self.client.close() 79 | -------------------------------------------------------------------------------- /tests/fixtures/klippy_process.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import pytest 3 | import os 4 | import subprocess 5 | import time 6 | import pathlib 7 | import shlex 8 | 9 | from typing import Dict, Optional 10 | 11 | class KlippyProcess: 12 | def __init__(self, 13 | base_cmd: str, 14 | path_args: Dict[str, pathlib.Path], 15 | ) -> None: 16 | self.base_cmd = base_cmd 17 | self.config_path = path_args['printer.cfg'] 18 | self.orig_config = self.config_path 19 | self.dict_path = path_args["klipper.dict"] 20 | self.pty_path = path_args["klippy_pty_path"] 21 | self.uds_path = path_args["klippy_uds_path"] 22 | self.proc: Optional[subprocess.Popen] = None 23 | self.fd: int = -1 24 | 25 | def start(self): 26 | if self.proc is not None: 27 | return 28 | args = ( 29 | f"{self.config_path} -o /dev/null -d {self.dict_path} " 30 | f"-a {self.uds_path} -I {self.pty_path}" 31 | ) 32 | cmd = f"{self.base_cmd} {args}" 33 | cmd_parts = shlex.split(cmd) 34 | self.proc = subprocess.Popen(cmd_parts) 35 | for _ in range(250): 36 | if self.pty_path.exists(): 37 | try: 38 | self.fd = os.open( 39 | str(self.pty_path), os.O_RDWR | os.O_NONBLOCK) 40 | except Exception: 41 | pass 42 | else: 43 | break 44 | time.sleep(.01) 45 | else: 46 | self.stop() 47 | pytest.fail("Unable to start Klippy process") 48 | return False 49 | return True 50 | 51 | def send_gcode(self, gcode: str) -> None: 52 | if self.fd == -1: 53 | return 54 | try: 55 | os.write(self.fd, f"{gcode}\n".encode()) 56 | except Exception: 57 | pass 58 | 59 | def restart(self): 60 | self.stop() 61 | self.start() 62 | 63 | def stop(self): 64 | if self.fd != -1: 65 | os.close(self.fd) 66 | self.fd = -1 67 | if self.proc is not None: 68 | self.proc.terminate() 69 | try: 70 | self.proc.wait(2.) 71 | except subprocess.TimeoutExpired: 72 | self.proc.kill() 73 | self.proc = None 74 | 75 | def get_paths(self) -> Dict[str, pathlib.Path]: 76 | return { 77 | "printer.cfg": self.config_path, 78 | "klipper.dict": self.dict_path, 79 | "klippy_uds_path": self.uds_path, 80 | "klippy_pty_path": self.pty_path, 81 | } 82 | -------------------------------------------------------------------------------- /tests/fixtures/websocket_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import pytest 3 | import json 4 | import asyncio 5 | import tornado.websocket 6 | 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Union, 10 | Tuple, 11 | Callable, 12 | Dict, 13 | List, 14 | Any, 15 | Optional, 16 | ) 17 | 18 | if TYPE_CHECKING: 19 | from tornado.websocket import WebSocketClientConnection 20 | 21 | class WebsocketError(Exception): 22 | def __init__(self, code, *args: object) -> None: 23 | super().__init__(*args) 24 | self.code = code 25 | 26 | class WebsocketClient: 27 | error = WebsocketError 28 | def __init__(self, 29 | type: str = "ws", 30 | port: int = 7010 31 | ) -> None: 32 | self.ws: Optional[WebSocketClientConnection] = None 33 | self.pending_requests: Dict[int, asyncio.Future] = {} 34 | self.notify_cbs: Dict[str, List[Callable[..., None]]] = {} 35 | assert type in ["ws", "wss"] 36 | self.url = f"{type}://127.0.0.1:{port}/websocket" 37 | 38 | async def connect(self, token: Optional[str] = None) -> None: 39 | url = self.url 40 | if token is not None: 41 | url += f"?token={token}" 42 | self.ws = await tornado.websocket.websocket_connect( 43 | url, connect_timeout=2., 44 | on_message_callback=self._on_message_received) 45 | 46 | async def request(self, 47 | remote_method: str, 48 | args: Dict[str, Any] = {} 49 | ) -> Dict[str, Any]: 50 | if self.ws is None: 51 | pytest.fail("Websocket Not Connected") 52 | loop = asyncio.get_running_loop() 53 | fut = loop.create_future() 54 | req, req_id = self._encode_request(remote_method, args) 55 | self.pending_requests[req_id] = fut 56 | await self.ws.write_message(req) 57 | return await asyncio.wait_for(fut, 2.) 58 | 59 | def _encode_request(self, 60 | method: str, 61 | args: Dict[str, Any] 62 | ) -> Tuple[str, int]: 63 | request: Dict[str, Any] = { 64 | 'jsonrpc': "2.0", 65 | 'method': method, 66 | } 67 | if args: 68 | request['params'] = args 69 | req_id = id(request) 70 | request["id"] = req_id 71 | return json.dumps(request), req_id 72 | 73 | def _on_message_received(self, message: Union[str, bytes, None]) -> None: 74 | if isinstance(message, str): 75 | self._decode_jsonrpc(message) 76 | 77 | def _decode_jsonrpc(self, data: str) -> None: 78 | try: 79 | resp: Dict[str, Any] = json.loads(data) 80 | except json.JSONDecodeError: 81 | pytest.fail(f"Websocket JSON Decode Error: {data}") 82 | header = resp.get('jsonrpc', "") 83 | if header != "2.0": 84 | # Invalid Json, set error if we can get the id 85 | pytest.fail(f"Invalid jsonrpc header: {data}") 86 | req_id: Optional[int] = resp.get("id") 87 | method: Optional[str] = resp.get("method") 88 | if method is not None: 89 | if req_id is None: 90 | params = resp.get("params", []) 91 | if not isinstance(params, list): 92 | pytest.fail("jsonrpc notification params" 93 | f"should always be a list: {data}") 94 | if method in self.notify_cbs: 95 | for func in self.notify_cbs[method]: 96 | func(*params) 97 | else: 98 | # This is a request from the server (should not happen) 99 | pytest.fail(f"Server should not request from client: {data}") 100 | elif req_id is not None: 101 | pending_fut = self.pending_requests.pop(req_id, None) 102 | if pending_fut is None: 103 | # No future pending for this response 104 | return 105 | # This is a response 106 | if "result" in resp: 107 | pending_fut.set_result(resp["result"]) 108 | elif "error" in resp: 109 | err = resp["error"] 110 | try: 111 | code = err["code"] 112 | msg = err["message"] 113 | except Exception: 114 | pytest.fail(f"Invalid jsonrpc error: {data}") 115 | exc = WebsocketError(code, msg) 116 | pending_fut.set_exception(exc) 117 | else: 118 | pytest.fail( 119 | f"Invalid jsonrpc packet, no result or error: {data}") 120 | else: 121 | # Invalid json 122 | pytest.fail(f"Invalid jsonrpc packet, no id: {data}") 123 | 124 | def register_notify_callback(self, name: str, callback) -> None: 125 | if name in self.notify_cbs: 126 | self.notify_cbs[name].append(callback) 127 | else: 128 | self.notify_cbs[name][callback] 129 | 130 | def close(self): 131 | for fut in self.pending_requests.values(): 132 | if not fut.done(): 133 | fut.set_exception(WebsocketError( 134 | 0, "Closing Websocket Client")) 135 | if self.ws is not None: 136 | self.ws.close(1000, "Test Complete") 137 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | from utils import ServerError 4 | from .mock_gpio import MockGpiod 5 | 6 | __all__ = ("MockReader", "MockWriter", "MockComponent", "MockWebsocket", 7 | "MockGpiod") 8 | 9 | class MockWriter: 10 | def __init__(self, wait_drain: bool = False) -> None: 11 | self.wait_drain = wait_drain 12 | 13 | def write(self, data: str) -> None: 14 | pass 15 | 16 | async def drain(self) -> None: 17 | if self.wait_drain: 18 | evt = asyncio.Event() 19 | await evt.wait() 20 | else: 21 | raise ServerError("TestError") 22 | 23 | class MockReader: 24 | def __init__(self, action: str = "") -> None: 25 | self.action = action 26 | self.eof = False 27 | 28 | def at_eof(self) -> bool: 29 | return self.eof 30 | 31 | async def readuntil(self, stop: bytes) -> bytes: 32 | if self.action == "wait": 33 | evt = asyncio.Event() 34 | await evt.wait() 35 | return b"" 36 | elif self.action == "raise_error": 37 | raise ServerError("TestError") 38 | else: 39 | self.eof = True 40 | return b"NotJsonDecodable" 41 | 42 | 43 | class MockComponent: 44 | def __init__(self, 45 | err_init: bool = False, 46 | err_exit: bool = False, 47 | err_close: bool = False 48 | ) -> None: 49 | self.err_init = err_init 50 | self.err_exit = err_exit 51 | self.err_close = err_close 52 | 53 | async def component_init(self): 54 | if self.err_init: 55 | raise ServerError("test") 56 | 57 | async def on_exit(self): 58 | if self.err_exit: 59 | raise ServerError("test") 60 | 61 | async def close(self): 62 | if self.err_close: 63 | raise ServerError("test") 64 | 65 | class MockWebsocket: 66 | def __init__(self, fut: asyncio.Future) -> None: 67 | self.future = fut 68 | 69 | def queue_message(self, data: str): 70 | self.future.set_result(data) 71 | -------------------------------------------------------------------------------- /tests/mocks/mock_gpio.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | import logging 4 | from typing import Dict, Optional, List, Tuple 5 | 6 | class GpioException(Exception): 7 | pass 8 | 9 | class MockGpiod: 10 | LINE_REQ_DIR_OUT = 3 11 | LINE_REQ_EV_BOTH_EDGES = 6 12 | LINE_REQ_FLAG_ACTIVE_LOW = 1 << 2 13 | LINE_REQ_FLAG_BIAS_DISABLE = 1 << 3 14 | LINE_REQ_FLAG_BIAS_PULL_DOWN = 1 << 4 15 | LINE_REQ_FLAG_BIAS_PULL_UP = 1 << 5 16 | 17 | def __init__(self, version: str = "1.2") -> None: 18 | self.version = version 19 | self.Chip = MockChipWrapper(self) 20 | self.LineEvent = MockLineEvent 21 | self.chips: Dict[str, MockChip] = {} 22 | 23 | def version_string(self) -> str: 24 | return self.version 25 | 26 | def version_tuple(self) -> Tuple[int, ...]: 27 | return tuple([int(v) for v in self.version.split(".")]) 28 | 29 | def get_chip(self, chip_name) -> Optional[MockChip]: 30 | return self.chips.get(chip_name, None) 31 | 32 | def add_chip(self, chip: MockChip): 33 | self.chips[chip.name] = chip 34 | 35 | def pop_chip(self, name: str): 36 | self.chips.pop(name, None) 37 | 38 | def find_line(self, chip_id: str, pin_id: str) -> MockLine: 39 | if chip_id not in self.chips: 40 | raise GpioException(f"Unable to find chip {chip_id}") 41 | return self.chips[chip_id].find_line(pin_id) 42 | 43 | class MockChipWrapper: 44 | OPEN_BY_NAME = 2 45 | def __init__(self, gpiod: MockGpiod) -> None: 46 | self.mock_gpiod = gpiod 47 | 48 | def __call__(self, chip_name: str, flags: int) -> MockChip: 49 | if chip_name in self.mock_gpiod.chips: 50 | return self.mock_gpiod.chips[chip_name] 51 | chip = MockChip(chip_name, flags, self.mock_gpiod) 52 | self.mock_gpiod.add_chip(chip) 53 | return chip 54 | 55 | class MockChip: 56 | def __init__(self, 57 | chip_name: str, 58 | flags: int, 59 | mock_gpiod: MockGpiod 60 | ) -> None: 61 | self.name = chip_name 62 | self.flags = flags 63 | self.mock_gpiod = mock_gpiod 64 | self.requested_lines: Dict[str, MockLine] = {} 65 | 66 | def get_line(self, pin_id: str) -> MockLine: 67 | if pin_id in self.requested_lines: 68 | raise GpioException(f"Line {pin_id} already reserved") 69 | line = MockLine(self, pin_id, self.mock_gpiod) 70 | self.requested_lines[pin_id] = line 71 | return line 72 | 73 | def find_line(self, pin_id: str) -> MockLine: 74 | if pin_id not in self.requested_lines: 75 | raise GpioException(f"Unable to find line {pin_id}") 76 | return self.requested_lines[pin_id] 77 | 78 | def pop_line(self, name: str) -> None: 79 | self.requested_lines.pop(name, None) 80 | 81 | def close(self) -> None: 82 | for line in list(self.requested_lines.values()): 83 | line.release() 84 | self.requested_lines = {} 85 | self.mock_gpiod.pop_chip(self.name) 86 | 87 | class MockLine: 88 | def __init__(self, 89 | chip: MockChip, 90 | name: str, 91 | mock_gpiod: MockGpiod 92 | ) -> None: 93 | self.mock_gpiod = mock_gpiod 94 | self.chip = chip 95 | self.name = name 96 | self.consumer_name: str = "" 97 | self.is_event = False 98 | self.invert = False 99 | self.value = 0 100 | self.read_pipe: Optional[int] = None 101 | self.write_pipe: Optional[int] = None 102 | self.bias = "not_configured" 103 | 104 | def request(self, 105 | consumer: str, 106 | type: int, 107 | flags: int = 0, 108 | default_vals: Optional[List[int]] = None, 109 | default_val: Optional[int] = None 110 | ) -> None: 111 | self.consumer_name = consumer 112 | version = self.mock_gpiod.version_tuple() 113 | if type == MockGpiod.LINE_REQ_DIR_OUT: 114 | self.is_event = False 115 | if default_vals is not None: 116 | if version > (1, 2): 117 | logging.warn("default_vals is deprecated in gpiod 1.3+") 118 | self.value = default_vals[0] 119 | elif default_val is not None: 120 | if version < (1, 3): 121 | raise GpioException( 122 | "default_val not available in gpiod < 1.3") 123 | self.value = default_val 124 | elif type == MockGpiod.LINE_REQ_EV_BOTH_EDGES: 125 | self.is_event = True 126 | if version >= (1, 5): 127 | if flags & MockGpiod.LINE_REQ_FLAG_BIAS_DISABLE: 128 | self.bias = "disabled" 129 | elif flags & MockGpiod.LINE_REQ_FLAG_BIAS_PULL_DOWN: 130 | self.bias = "pulldown" 131 | elif flags & MockGpiod.LINE_REQ_FLAG_BIAS_PULL_UP: 132 | self.bias = "pullup" 133 | self.read_pipe, self.write_pipe = os.pipe2(os.O_NONBLOCK) 134 | else: 135 | raise GpioException("Unsupported GPIO Type") 136 | if flags & MockGpiod.LINE_REQ_FLAG_ACTIVE_LOW: 137 | self.invert = True 138 | 139 | def release(self) -> None: 140 | if self.read_pipe is not None: 141 | try: 142 | os.close(self.read_pipe) 143 | except Exception: 144 | pass 145 | if self.write_pipe is not None: 146 | try: 147 | os.close(self.write_pipe) 148 | except Exception: 149 | pass 150 | self.chip.pop_line(self.name) 151 | 152 | def set_value(self, value: int) -> None: 153 | if self.is_event: 154 | raise GpioException("Cannot set the value for an input pin") 155 | self.value = int(not not value) 156 | 157 | def get_value(self) -> int: 158 | return self.value 159 | 160 | def event_read(self) -> MockLineEvent: 161 | if self.read_pipe is None: 162 | raise GpioException 163 | try: 164 | data = os.read(self.read_pipe, 64) 165 | except Exception: 166 | pass 167 | else: 168 | value = int(not not data[-1]) 169 | self.value = value 170 | return MockLineEvent(self.value) 171 | 172 | def event_get_fd(self) -> int: 173 | if self.read_pipe is None: 174 | raise GpioException("Event not configured") 175 | return self.read_pipe 176 | 177 | def simulate_line_event(self, value: int) -> None: 178 | if self.write_pipe is None: 179 | raise GpioException("Event not configured") 180 | val = bytes([int(not not value)]) 181 | try: 182 | os.write(self.write_pipe, val) 183 | except Exception: 184 | pass 185 | 186 | class MockLineEvent: 187 | RISING_EDGE = 1 188 | FALLING_EDGE = 2 189 | def __init__(self, value: int) -> None: 190 | if value == 1: 191 | self.type = self.RISING_EDGE 192 | else: 193 | self.type = self.FALLING_EDGE 194 | --------------------------------------------------------------------------------