Compare commits
	
		
			13 Commits
		
	
	
		
			0.0.23
			...
			698f8f4151
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 698f8f4151 | |||
| 7a0293bac7 | |||
|  | d0fd4a5434 | ||
|  | 3c218a548d | ||
|  | 03af51608d | ||
|  | c00285b1b2 | ||
|  | 7f1ae5a24b | ||
|  | 5754e81b72 | ||
|  | cd4103cc71 | ||
|  | 01c6cacf15 | ||
|  | fda4b86cbc | ||
|  | ad862d5ebd | ||
|  | b04202463d | 
							
								
								
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | github: [jneilliii] | ||||||
|  | patreon: jneilliii | ||||||
|  | custom: ['https://www.paypal.me/jneilliii'] | ||||||
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | --- | ||||||
|  | name: Bug report | ||||||
|  | about: Please make sure to check other issues, including closed ones, prior to submitting a bug report. Debug logs are required and any bug report submitted without them will be ignored and closed.  | ||||||
|  | title: "[BUG]: " | ||||||
|  | labels: '' | ||||||
|  | assignees: '' | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | **Describe the Bug** | ||||||
|  | <!-- A clear and concise description of what the bug is. --> | ||||||
|  |  | ||||||
|  | **Expected Behavior** | ||||||
|  | <!-- A clear and concise description of what you expected to happen. --> | ||||||
|  |  | ||||||
|  | **Debug Logs** | ||||||
|  | <!-- If logs are not included in your bug report it will be closed. Enable debug logging for octoprint.plugins.bambu_printer in OctoPrint's logging section of settings and recreate the issue then attach octoprint.log and plugin_bambu_printer_serial.log to this bug report. --> | ||||||
|  |  | ||||||
|  | **Screenshots** | ||||||
|  | <!-- Please share any relevant screenshots related to the issue. --> | ||||||
|  |  | ||||||
|  | **Printer and Plugin Setting Details** | ||||||
|  |  | ||||||
|  | * Printer model? | ||||||
|  | * Is your printer connected to Bambu Cloud?  | ||||||
|  | * Is the  plugin configured for local access only? | ||||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | --- | ||||||
|  | name: Feature request | ||||||
|  | about: Create a feature request for an improvement or change you'd like implemented. | ||||||
|  | title: "[FR]: " | ||||||
|  | labels: '' | ||||||
|  | assignees: '' | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | **Is your feature request related to a problem? Please describe.** | ||||||
|  | <!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] --> | ||||||
|  |  | ||||||
|  | **Describe the solution you'd like** | ||||||
|  | <!-- A clear and concise description of what you want to happen. --> | ||||||
|  |  | ||||||
|  | **Describe alternatives you've considered** | ||||||
|  | <!-- A clear and concise description of any alternative solutions or features you've considered. --> | ||||||
|  |  | ||||||
|  | **Additional context** | ||||||
|  | <!-- Add any other context or screenshots about the feature request here. --> | ||||||
							
								
								
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | # Number of days of inactivity before an issue becomes stale | ||||||
|  | daysUntilStale: 14 | ||||||
|  | # Number of days of inactivity before a stale issue is closed | ||||||
|  | daysUntilClose: 7 | ||||||
|  | # Issues with these labels will never be considered stale | ||||||
|  | exemptLabels: | ||||||
|  |   - enhancement | ||||||
|  |   - bug | ||||||
|  | # Label to use when marking an issue as stale | ||||||
|  | staleLabel: stale | ||||||
|  | # Comment to post when marking an issue as stale. Set to `false` to disable | ||||||
|  | markComment: > | ||||||
|  |   This issue has been automatically marked as stale because it has not had | ||||||
|  |   activity in 14 days. It will be closed if no further activity occurs in 7 days. | ||||||
|  | # Comment to post when closing a stale issue. Set to `false` to disable | ||||||
|  | closeComment: false | ||||||
							
								
								
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | name: Mark Stale Issues | ||||||
|  | on: | ||||||
|  |   workflow_dispatch: | ||||||
|  |   schedule: | ||||||
|  |   - cron: "0 0 * * *" | ||||||
|  | permissions: | ||||||
|  |   actions: write | ||||||
|  | jobs: | ||||||
|  |   stale: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |     - uses: actions/stale@v9 | ||||||
|  |       with: | ||||||
|  |         repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |         stale-issue-message: 'This issue has been automatically marked as stale because it has not had activity in 14 days. It will be closed if no further activity occurs in 7 days' | ||||||
|  |         days-before-stale: 14 | ||||||
|  |         days-before-close: 7 | ||||||
|  |         stale-issue-label: 'stale' | ||||||
|  |         days-before-issue-stale: 14 | ||||||
|  |         days-before-pr-stale: -1 | ||||||
|  |         days-before-issue-close: 7 | ||||||
|  |         days-before-pr-close: -1 | ||||||
|  |         exempt-issue-labels: 'bug,enhancement' | ||||||
|  |     - uses: actions/checkout@v4 | ||||||
|  |     - uses: gautamkrishnar/keepalive-workflow@v2 | ||||||
|  |       with: | ||||||
|  |         use_api: true | ||||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,3 +8,5 @@ dist | |||||||
| .DS_Store | .DS_Store | ||||||
| *.zip | *.zip | ||||||
| extras | extras | ||||||
|  |  | ||||||
|  | test/test_output | ||||||
							
								
								
									
										661
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										661
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,661 @@ | |||||||
|  |                     GNU AFFERO GENERAL PUBLIC LICENSE | ||||||
|  |                        Version 3, 19 November 2007 | ||||||
|  |  | ||||||
|  |  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | ||||||
|  |  Everyone is permitted to copy and distribute verbatim copies | ||||||
|  |  of this license document, but changing it is not allowed. | ||||||
|  |  | ||||||
|  |                             Preamble | ||||||
|  |  | ||||||
|  |   The GNU Affero General Public License is a free, copyleft license for | ||||||
|  | software and other kinds of works, specifically designed to ensure | ||||||
|  | cooperation with the community in the case of network server software. | ||||||
|  |  | ||||||
|  |   The licenses for most software and other practical works are designed | ||||||
|  | to take away your freedom to share and change the works.  By contrast, | ||||||
|  | our General Public Licenses are intended to guarantee your freedom to | ||||||
|  | share and change all versions of a program--to make sure it remains free | ||||||
|  | software for all its users. | ||||||
|  |  | ||||||
|  |   When we speak of free software, we are referring to freedom, not | ||||||
|  | price.  Our General Public Licenses are designed to make sure that you | ||||||
|  | have the freedom to distribute copies of free software (and charge for | ||||||
|  | them if you wish), that you receive source code or can get it if you | ||||||
|  | want it, that you can change the software or use pieces of it in new | ||||||
|  | free programs, and that you know you can do these things. | ||||||
|  |  | ||||||
|  |   Developers that use our General Public Licenses protect your rights | ||||||
|  | with two steps: (1) assert copyright on the software, and (2) offer | ||||||
|  | you this License which gives you legal permission to copy, distribute | ||||||
|  | and/or modify the software. | ||||||
|  |  | ||||||
|  |   A secondary benefit of defending all users' freedom is that | ||||||
|  | improvements made in alternate versions of the program, if they | ||||||
|  | receive widespread use, become available for other developers to | ||||||
|  | incorporate.  Many developers of free software are heartened and | ||||||
|  | encouraged by the resulting cooperation.  However, in the case of | ||||||
|  | software used on network servers, this result may fail to come about. | ||||||
|  | The GNU General Public License permits making a modified version and | ||||||
|  | letting the public access it on a server without ever releasing its | ||||||
|  | source code to the public. | ||||||
|  |  | ||||||
|  |   The GNU Affero General Public License is designed specifically to | ||||||
|  | ensure that, in such cases, the modified source code becomes available | ||||||
|  | to the community.  It requires the operator of a network server to | ||||||
|  | provide the source code of the modified version running there to the | ||||||
|  | users of that server.  Therefore, public use of a modified version, on | ||||||
|  | a publicly accessible server, gives the public access to the source | ||||||
|  | code of the modified version. | ||||||
|  |  | ||||||
|  |   An older license, called the Affero General Public License and | ||||||
|  | published by Affero, was designed to accomplish similar goals.  This is | ||||||
|  | a different license, not a version of the Affero GPL, but Affero has | ||||||
|  | released a new version of the Affero GPL which permits relicensing under | ||||||
|  | this license. | ||||||
|  |  | ||||||
|  |   The precise terms and conditions for copying, distribution and | ||||||
|  | modification follow. | ||||||
|  |  | ||||||
|  |                        TERMS AND CONDITIONS | ||||||
|  |  | ||||||
|  |   0. Definitions. | ||||||
|  |  | ||||||
|  |   "This License" refers to version 3 of the GNU Affero General Public License. | ||||||
|  |  | ||||||
|  |   "Copyright" also means copyright-like laws that apply to other kinds of | ||||||
|  | works, such as semiconductor masks. | ||||||
|  |  | ||||||
|  |   "The Program" refers to any copyrightable work licensed under this | ||||||
|  | License.  Each licensee is addressed as "you".  "Licensees" and | ||||||
|  | "recipients" may be individuals or organizations. | ||||||
|  |  | ||||||
|  |   To "modify" a work means to copy from or adapt all or part of the work | ||||||
|  | in a fashion requiring copyright permission, other than the making of an | ||||||
|  | exact copy.  The resulting work is called a "modified version" of the | ||||||
|  | earlier work or a work "based on" the earlier work. | ||||||
|  |  | ||||||
|  |   A "covered work" means either the unmodified Program or a work based | ||||||
|  | on the Program. | ||||||
|  |  | ||||||
|  |   To "propagate" a work means to do anything with it that, without | ||||||
|  | permission, would make you directly or secondarily liable for | ||||||
|  | infringement under applicable copyright law, except executing it on a | ||||||
|  | computer or modifying a private copy.  Propagation includes copying, | ||||||
|  | distribution (with or without modification), making available to the | ||||||
|  | public, and in some countries other activities as well. | ||||||
|  |  | ||||||
|  |   To "convey" a work means any kind of propagation that enables other | ||||||
|  | parties to make or receive copies.  Mere interaction with a user through | ||||||
|  | a computer network, with no transfer of a copy, is not conveying. | ||||||
|  |  | ||||||
|  |   An interactive user interface displays "Appropriate Legal Notices" | ||||||
|  | to the extent that it includes a convenient and prominently visible | ||||||
|  | feature that (1) displays an appropriate copyright notice, and (2) | ||||||
|  | tells the user that there is no warranty for the work (except to the | ||||||
|  | extent that warranties are provided), that licensees may convey the | ||||||
|  | work under this License, and how to view a copy of this License.  If | ||||||
|  | the interface presents a list of user commands or options, such as a | ||||||
|  | menu, a prominent item in the list meets this criterion. | ||||||
|  |  | ||||||
|  |   1. Source Code. | ||||||
|  |  | ||||||
|  |   The "source code" for a work means the preferred form of the work | ||||||
|  | for making modifications to it.  "Object code" means any non-source | ||||||
|  | form of a work. | ||||||
|  |  | ||||||
|  |   A "Standard Interface" means an interface that either is an official | ||||||
|  | standard defined by a recognized standards body, or, in the case of | ||||||
|  | interfaces specified for a particular programming language, one that | ||||||
|  | is widely used among developers working in that language. | ||||||
|  |  | ||||||
|  |   The "System Libraries" of an executable work include anything, other | ||||||
|  | than the work as a whole, that (a) is included in the normal form of | ||||||
|  | packaging a Major Component, but which is not part of that Major | ||||||
|  | Component, and (b) serves only to enable use of the work with that | ||||||
|  | Major Component, or to implement a Standard Interface for which an | ||||||
|  | implementation is available to the public in source code form.  A | ||||||
|  | "Major Component", in this context, means a major essential component | ||||||
|  | (kernel, window system, and so on) of the specific operating system | ||||||
|  | (if any) on which the executable work runs, or a compiler used to | ||||||
|  | produce the work, or an object code interpreter used to run it. | ||||||
|  |  | ||||||
|  |   The "Corresponding Source" for a work in object code form means all | ||||||
|  | the source code needed to generate, install, and (for an executable | ||||||
|  | work) run the object code and to modify the work, including scripts to | ||||||
|  | control those activities.  However, it does not include the work's | ||||||
|  | System Libraries, or general-purpose tools or generally available free | ||||||
|  | programs which are used unmodified in performing those activities but | ||||||
|  | which are not part of the work.  For example, Corresponding Source | ||||||
|  | includes interface definition files associated with source files for | ||||||
|  | the work, and the source code for shared libraries and dynamically | ||||||
|  | linked subprograms that the work is specifically designed to require, | ||||||
|  | such as by intimate data communication or control flow between those | ||||||
|  | subprograms and other parts of the work. | ||||||
|  |  | ||||||
|  |   The Corresponding Source need not include anything that users | ||||||
|  | can regenerate automatically from other parts of the Corresponding | ||||||
|  | Source. | ||||||
|  |  | ||||||
|  |   The Corresponding Source for a work in source code form is that | ||||||
|  | same work. | ||||||
|  |  | ||||||
|  |   2. Basic Permissions. | ||||||
|  |  | ||||||
|  |   All rights granted under this License are granted for the term of | ||||||
|  | copyright on the Program, and are irrevocable provided the stated | ||||||
|  | conditions are met.  This License explicitly affirms your unlimited | ||||||
|  | permission to run the unmodified Program.  The output from running a | ||||||
|  | covered work is covered by this License only if the output, given its | ||||||
|  | content, constitutes a covered work.  This License acknowledges your | ||||||
|  | rights of fair use or other equivalent, as provided by copyright law. | ||||||
|  |  | ||||||
|  |   You may make, run and propagate covered works that you do not | ||||||
|  | convey, without conditions so long as your license otherwise remains | ||||||
|  | in force.  You may convey covered works to others for the sole purpose | ||||||
|  | of having them make modifications exclusively for you, or provide you | ||||||
|  | with facilities for running those works, provided that you comply with | ||||||
|  | the terms of this License in conveying all material for which you do | ||||||
|  | not control copyright.  Those thus making or running the covered works | ||||||
|  | for you must do so exclusively on your behalf, under your direction | ||||||
|  | and control, on terms that prohibit them from making any copies of | ||||||
|  | your copyrighted material outside their relationship with you. | ||||||
|  |  | ||||||
|  |   Conveying under any other circumstances is permitted solely under | ||||||
|  | the conditions stated below.  Sublicensing is not allowed; section 10 | ||||||
|  | makes it unnecessary. | ||||||
|  |  | ||||||
|  |   3. Protecting Users' Legal Rights From Anti-Circumvention Law. | ||||||
|  |  | ||||||
|  |   No covered work shall be deemed part of an effective technological | ||||||
|  | measure under any applicable law fulfilling obligations under article | ||||||
|  | 11 of the WIPO copyright treaty adopted on 20 December 1996, or | ||||||
|  | similar laws prohibiting or restricting circumvention of such | ||||||
|  | measures. | ||||||
|  |  | ||||||
|  |   When you convey a covered work, you waive any legal power to forbid | ||||||
|  | circumvention of technological measures to the extent such circumvention | ||||||
|  | is effected by exercising rights under this License with respect to | ||||||
|  | the covered work, and you disclaim any intention to limit operation or | ||||||
|  | modification of the work as a means of enforcing, against the work's | ||||||
|  | users, your or third parties' legal rights to forbid circumvention of | ||||||
|  | technological measures. | ||||||
|  |  | ||||||
|  |   4. Conveying Verbatim Copies. | ||||||
|  |  | ||||||
|  |   You may convey verbatim copies of the Program's source code as you | ||||||
|  | receive it, in any medium, provided that you conspicuously and | ||||||
|  | appropriately publish on each copy an appropriate copyright notice; | ||||||
|  | keep intact all notices stating that this License and any | ||||||
|  | non-permissive terms added in accord with section 7 apply to the code; | ||||||
|  | keep intact all notices of the absence of any warranty; and give all | ||||||
|  | recipients a copy of this License along with the Program. | ||||||
|  |  | ||||||
|  |   You may charge any price or no price for each copy that you convey, | ||||||
|  | and you may offer support or warranty protection for a fee. | ||||||
|  |  | ||||||
|  |   5. Conveying Modified Source Versions. | ||||||
|  |  | ||||||
|  |   You may convey a work based on the Program, or the modifications to | ||||||
|  | produce it from the Program, in the form of source code under the | ||||||
|  | terms of section 4, provided that you also meet all of these conditions: | ||||||
|  |  | ||||||
|  |     a) The work must carry prominent notices stating that you modified | ||||||
|  |     it, and giving a relevant date. | ||||||
|  |  | ||||||
|  |     b) The work must carry prominent notices stating that it is | ||||||
|  |     released under this License and any conditions added under section | ||||||
|  |     7.  This requirement modifies the requirement in section 4 to | ||||||
|  |     "keep intact all notices". | ||||||
|  |  | ||||||
|  |     c) You must license the entire work, as a whole, under this | ||||||
|  |     License to anyone who comes into possession of a copy.  This | ||||||
|  |     License will therefore apply, along with any applicable section 7 | ||||||
|  |     additional terms, to the whole of the work, and all its parts, | ||||||
|  |     regardless of how they are packaged.  This License gives no | ||||||
|  |     permission to license the work in any other way, but it does not | ||||||
|  |     invalidate such permission if you have separately received it. | ||||||
|  |  | ||||||
|  |     d) If the work has interactive user interfaces, each must display | ||||||
|  |     Appropriate Legal Notices; however, if the Program has interactive | ||||||
|  |     interfaces that do not display Appropriate Legal Notices, your | ||||||
|  |     work need not make them do so. | ||||||
|  |  | ||||||
|  |   A compilation of a covered work with other separate and independent | ||||||
|  | works, which are not by their nature extensions of the covered work, | ||||||
|  | and which are not combined with it such as to form a larger program, | ||||||
|  | in or on a volume of a storage or distribution medium, is called an | ||||||
|  | "aggregate" if the compilation and its resulting copyright are not | ||||||
|  | used to limit the access or legal rights of the compilation's users | ||||||
|  | beyond what the individual works permit.  Inclusion of a covered work | ||||||
|  | in an aggregate does not cause this License to apply to the other | ||||||
|  | parts of the aggregate. | ||||||
|  |  | ||||||
|  |   6. Conveying Non-Source Forms. | ||||||
|  |  | ||||||
|  |   You may convey a covered work in object code form under the terms | ||||||
|  | of sections 4 and 5, provided that you also convey the | ||||||
|  | machine-readable Corresponding Source under the terms of this License, | ||||||
|  | in one of these ways: | ||||||
|  |  | ||||||
|  |     a) Convey the object code in, or embodied in, a physical product | ||||||
|  |     (including a physical distribution medium), accompanied by the | ||||||
|  |     Corresponding Source fixed on a durable physical medium | ||||||
|  |     customarily used for software interchange. | ||||||
|  |  | ||||||
|  |     b) Convey the object code in, or embodied in, a physical product | ||||||
|  |     (including a physical distribution medium), accompanied by a | ||||||
|  |     written offer, valid for at least three years and valid for as | ||||||
|  |     long as you offer spare parts or customer support for that product | ||||||
|  |     model, to give anyone who possesses the object code either (1) a | ||||||
|  |     copy of the Corresponding Source for all the software in the | ||||||
|  |     product that is covered by this License, on a durable physical | ||||||
|  |     medium customarily used for software interchange, for a price no | ||||||
|  |     more than your reasonable cost of physically performing this | ||||||
|  |     conveying of source, or (2) access to copy the | ||||||
|  |     Corresponding Source from a network server at no charge. | ||||||
|  |  | ||||||
|  |     c) Convey individual copies of the object code with a copy of the | ||||||
|  |     written offer to provide the Corresponding Source.  This | ||||||
|  |     alternative is allowed only occasionally and noncommercially, and | ||||||
|  |     only if you received the object code with such an offer, in accord | ||||||
|  |     with subsection 6b. | ||||||
|  |  | ||||||
|  |     d) Convey the object code by offering access from a designated | ||||||
|  |     place (gratis or for a charge), and offer equivalent access to the | ||||||
|  |     Corresponding Source in the same way through the same place at no | ||||||
|  |     further charge.  You need not require recipients to copy the | ||||||
|  |     Corresponding Source along with the object code.  If the place to | ||||||
|  |     copy the object code is a network server, the Corresponding Source | ||||||
|  |     may be on a different server (operated by you or a third party) | ||||||
|  |     that supports equivalent copying facilities, provided you maintain | ||||||
|  |     clear directions next to the object code saying where to find the | ||||||
|  |     Corresponding Source.  Regardless of what server hosts the | ||||||
|  |     Corresponding Source, you remain obligated to ensure that it is | ||||||
|  |     available for as long as needed to satisfy these requirements. | ||||||
|  |  | ||||||
|  |     e) Convey the object code using peer-to-peer transmission, provided | ||||||
|  |     you inform other peers where the object code and Corresponding | ||||||
|  |     Source of the work are being offered to the general public at no | ||||||
|  |     charge under subsection 6d. | ||||||
|  |  | ||||||
|  |   A separable portion of the object code, whose source code is excluded | ||||||
|  | from the Corresponding Source as a System Library, need not be | ||||||
|  | included in conveying the object code work. | ||||||
|  |  | ||||||
|  |   A "User Product" is either (1) a "consumer product", which means any | ||||||
|  | tangible personal property which is normally used for personal, family, | ||||||
|  | or household purposes, or (2) anything designed or sold for incorporation | ||||||
|  | into a dwelling.  In determining whether a product is a consumer product, | ||||||
|  | doubtful cases shall be resolved in favor of coverage.  For a particular | ||||||
|  | product received by a particular user, "normally used" refers to a | ||||||
|  | typical or common use of that class of product, regardless of the status | ||||||
|  | of the particular user or of the way in which the particular user | ||||||
|  | actually uses, or expects or is expected to use, the product.  A product | ||||||
|  | is a consumer product regardless of whether the product has substantial | ||||||
|  | commercial, industrial or non-consumer uses, unless such uses represent | ||||||
|  | the only significant mode of use of the product. | ||||||
|  |  | ||||||
|  |   "Installation Information" for a User Product means any methods, | ||||||
|  | procedures, authorization keys, or other information required to install | ||||||
|  | and execute modified versions of a covered work in that User Product from | ||||||
|  | a modified version of its Corresponding Source.  The information must | ||||||
|  | suffice to ensure that the continued functioning of the modified object | ||||||
|  | code is in no case prevented or interfered with solely because | ||||||
|  | modification has been made. | ||||||
|  |  | ||||||
|  |   If you convey an object code work under this section in, or with, or | ||||||
|  | specifically for use in, a User Product, and the conveying occurs as | ||||||
|  | part of a transaction in which the right of possession and use of the | ||||||
|  | User Product is transferred to the recipient in perpetuity or for a | ||||||
|  | fixed term (regardless of how the transaction is characterized), the | ||||||
|  | Corresponding Source conveyed under this section must be accompanied | ||||||
|  | by the Installation Information.  But this requirement does not apply | ||||||
|  | if neither you nor any third party retains the ability to install | ||||||
|  | modified object code on the User Product (for example, the work has | ||||||
|  | been installed in ROM). | ||||||
|  |  | ||||||
|  |   The requirement to provide Installation Information does not include a | ||||||
|  | requirement to continue to provide support service, warranty, or updates | ||||||
|  | for a work that has been modified or installed by the recipient, or for | ||||||
|  | the User Product in which it has been modified or installed.  Access to a | ||||||
|  | network may be denied when the modification itself materially and | ||||||
|  | adversely affects the operation of the network or violates the rules and | ||||||
|  | protocols for communication across the network. | ||||||
|  |  | ||||||
|  |   Corresponding Source conveyed, and Installation Information provided, | ||||||
|  | in accord with this section must be in a format that is publicly | ||||||
|  | documented (and with an implementation available to the public in | ||||||
|  | source code form), and must require no special password or key for | ||||||
|  | unpacking, reading or copying. | ||||||
|  |  | ||||||
|  |   7. Additional Terms. | ||||||
|  |  | ||||||
|  |   "Additional permissions" are terms that supplement the terms of this | ||||||
|  | License by making exceptions from one or more of its conditions. | ||||||
|  | Additional permissions that are applicable to the entire Program shall | ||||||
|  | be treated as though they were included in this License, to the extent | ||||||
|  | that they are valid under applicable law.  If additional permissions | ||||||
|  | apply only to part of the Program, that part may be used separately | ||||||
|  | under those permissions, but the entire Program remains governed by | ||||||
|  | this License without regard to the additional permissions. | ||||||
|  |  | ||||||
|  |   When you convey a copy of a covered work, you may at your option | ||||||
|  | remove any additional permissions from that copy, or from any part of | ||||||
|  | it.  (Additional permissions may be written to require their own | ||||||
|  | removal in certain cases when you modify the work.)  You may place | ||||||
|  | additional permissions on material, added by you to a covered work, | ||||||
|  | for which you have or can give appropriate copyright permission. | ||||||
|  |  | ||||||
|  |   Notwithstanding any other provision of this License, for material you | ||||||
|  | add to a covered work, you may (if authorized by the copyright holders of | ||||||
|  | that material) supplement the terms of this License with terms: | ||||||
|  |  | ||||||
|  |     a) Disclaiming warranty or limiting liability differently from the | ||||||
|  |     terms of sections 15 and 16 of this License; or | ||||||
|  |  | ||||||
|  |     b) Requiring preservation of specified reasonable legal notices or | ||||||
|  |     author attributions in that material or in the Appropriate Legal | ||||||
|  |     Notices displayed by works containing it; or | ||||||
|  |  | ||||||
|  |     c) Prohibiting misrepresentation of the origin of that material, or | ||||||
|  |     requiring that modified versions of such material be marked in | ||||||
|  |     reasonable ways as different from the original version; or | ||||||
|  |  | ||||||
|  |     d) Limiting the use for publicity purposes of names of licensors or | ||||||
|  |     authors of the material; or | ||||||
|  |  | ||||||
|  |     e) Declining to grant rights under trademark law for use of some | ||||||
|  |     trade names, trademarks, or service marks; or | ||||||
|  |  | ||||||
|  |     f) Requiring indemnification of licensors and authors of that | ||||||
|  |     material by anyone who conveys the material (or modified versions of | ||||||
|  |     it) with contractual assumptions of liability to the recipient, for | ||||||
|  |     any liability that these contractual assumptions directly impose on | ||||||
|  |     those licensors and authors. | ||||||
|  |  | ||||||
|  |   All other non-permissive additional terms are considered "further | ||||||
|  | restrictions" within the meaning of section 10.  If the Program as you | ||||||
|  | received it, or any part of it, contains a notice stating that it is | ||||||
|  | governed by this License along with a term that is a further | ||||||
|  | restriction, you may remove that term.  If a license document contains | ||||||
|  | a further restriction but permits relicensing or conveying under this | ||||||
|  | License, you may add to a covered work material governed by the terms | ||||||
|  | of that license document, provided that the further restriction does | ||||||
|  | not survive such relicensing or conveying. | ||||||
|  |  | ||||||
|  |   If you add terms to a covered work in accord with this section, you | ||||||
|  | must place, in the relevant source files, a statement of the | ||||||
|  | additional terms that apply to those files, or a notice indicating | ||||||
|  | where to find the applicable terms. | ||||||
|  |  | ||||||
|  |   Additional terms, permissive or non-permissive, may be stated in the | ||||||
|  | form of a separately written license, or stated as exceptions; | ||||||
|  | the above requirements apply either way. | ||||||
|  |  | ||||||
|  |   8. Termination. | ||||||
|  |  | ||||||
|  |   You may not propagate or modify a covered work except as expressly | ||||||
|  | provided under this License.  Any attempt otherwise to propagate or | ||||||
|  | modify it is void, and will automatically terminate your rights under | ||||||
|  | this License (including any patent licenses granted under the third | ||||||
|  | paragraph of section 11). | ||||||
|  |  | ||||||
|  |   However, if you cease all violation of this License, then your | ||||||
|  | license from a particular copyright holder is reinstated (a) | ||||||
|  | provisionally, unless and until the copyright holder explicitly and | ||||||
|  | finally terminates your license, and (b) permanently, if the copyright | ||||||
|  | holder fails to notify you of the violation by some reasonable means | ||||||
|  | prior to 60 days after the cessation. | ||||||
|  |  | ||||||
|  |   Moreover, your license from a particular copyright holder is | ||||||
|  | reinstated permanently if the copyright holder notifies you of the | ||||||
|  | violation by some reasonable means, this is the first time you have | ||||||
|  | received notice of violation of this License (for any work) from that | ||||||
|  | copyright holder, and you cure the violation prior to 30 days after | ||||||
|  | your receipt of the notice. | ||||||
|  |  | ||||||
|  |   Termination of your rights under this section does not terminate the | ||||||
|  | licenses of parties who have received copies or rights from you under | ||||||
|  | this License.  If your rights have been terminated and not permanently | ||||||
|  | reinstated, you do not qualify to receive new licenses for the same | ||||||
|  | material under section 10. | ||||||
|  |  | ||||||
|  |   9. Acceptance Not Required for Having Copies. | ||||||
|  |  | ||||||
|  |   You are not required to accept this License in order to receive or | ||||||
|  | run a copy of the Program.  Ancillary propagation of a covered work | ||||||
|  | occurring solely as a consequence of using peer-to-peer transmission | ||||||
|  | to receive a copy likewise does not require acceptance.  However, | ||||||
|  | nothing other than this License grants you permission to propagate or | ||||||
|  | modify any covered work.  These actions infringe copyright if you do | ||||||
|  | not accept this License.  Therefore, by modifying or propagating a | ||||||
|  | covered work, you indicate your acceptance of this License to do so. | ||||||
|  |  | ||||||
|  |   10. Automatic Licensing of Downstream Recipients. | ||||||
|  |  | ||||||
|  |   Each time you convey a covered work, the recipient automatically | ||||||
|  | receives a license from the original licensors, to run, modify and | ||||||
|  | propagate that work, subject to this License.  You are not responsible | ||||||
|  | for enforcing compliance by third parties with this License. | ||||||
|  |  | ||||||
|  |   An "entity transaction" is a transaction transferring control of an | ||||||
|  | organization, or substantially all assets of one, or subdividing an | ||||||
|  | organization, or merging organizations.  If propagation of a covered | ||||||
|  | work results from an entity transaction, each party to that | ||||||
|  | transaction who receives a copy of the work also receives whatever | ||||||
|  | licenses to the work the party's predecessor in interest had or could | ||||||
|  | give under the previous paragraph, plus a right to possession of the | ||||||
|  | Corresponding Source of the work from the predecessor in interest, if | ||||||
|  | the predecessor has it or can get it with reasonable efforts. | ||||||
|  |  | ||||||
|  |   You may not impose any further restrictions on the exercise of the | ||||||
|  | rights granted or affirmed under this License.  For example, you may | ||||||
|  | not impose a license fee, royalty, or other charge for exercise of | ||||||
|  | rights granted under this License, and you may not initiate litigation | ||||||
|  | (including a cross-claim or counterclaim in a lawsuit) alleging that | ||||||
|  | any patent claim is infringed by making, using, selling, offering for | ||||||
|  | sale, or importing the Program or any portion of it. | ||||||
|  |  | ||||||
|  |   11. Patents. | ||||||
|  |  | ||||||
|  |   A "contributor" is a copyright holder who authorizes use under this | ||||||
|  | License of the Program or a work on which the Program is based.  The | ||||||
|  | work thus licensed is called the contributor's "contributor version". | ||||||
|  |  | ||||||
|  |   A contributor's "essential patent claims" are all patent claims | ||||||
|  | owned or controlled by the contributor, whether already acquired or | ||||||
|  | hereafter acquired, that would be infringed by some manner, permitted | ||||||
|  | by this License, of making, using, or selling its contributor version, | ||||||
|  | but do not include claims that would be infringed only as a | ||||||
|  | consequence of further modification of the contributor version.  For | ||||||
|  | purposes of this definition, "control" includes the right to grant | ||||||
|  | patent sublicenses in a manner consistent with the requirements of | ||||||
|  | this License. | ||||||
|  |  | ||||||
|  |   Each contributor grants you a non-exclusive, worldwide, royalty-free | ||||||
|  | patent license under the contributor's essential patent claims, to | ||||||
|  | make, use, sell, offer for sale, import and otherwise run, modify and | ||||||
|  | propagate the contents of its contributor version. | ||||||
|  |  | ||||||
|  |   In the following three paragraphs, a "patent license" is any express | ||||||
|  | agreement or commitment, however denominated, not to enforce a patent | ||||||
|  | (such as an express permission to practice a patent or covenant not to | ||||||
|  | sue for patent infringement).  To "grant" such a patent license to a | ||||||
|  | party means to make such an agreement or commitment not to enforce a | ||||||
|  | patent against the party. | ||||||
|  |  | ||||||
|  |   If you convey a covered work, knowingly relying on a patent license, | ||||||
|  | and the Corresponding Source of the work is not available for anyone | ||||||
|  | to copy, free of charge and under the terms of this License, through a | ||||||
|  | publicly available network server or other readily accessible means, | ||||||
|  | then you must either (1) cause the Corresponding Source to be so | ||||||
|  | available, or (2) arrange to deprive yourself of the benefit of the | ||||||
|  | patent license for this particular work, or (3) arrange, in a manner | ||||||
|  | consistent with the requirements of this License, to extend the patent | ||||||
|  | license to downstream recipients.  "Knowingly relying" means you have | ||||||
|  | actual knowledge that, but for the patent license, your conveying the | ||||||
|  | covered work in a country, or your recipient's use of the covered work | ||||||
|  | in a country, would infringe one or more identifiable patents in that | ||||||
|  | country that you have reason to believe are valid. | ||||||
|  |  | ||||||
|  |   If, pursuant to or in connection with a single transaction or | ||||||
|  | arrangement, you convey, or propagate by procuring conveyance of, a | ||||||
|  | covered work, and grant a patent license to some of the parties | ||||||
|  | receiving the covered work authorizing them to use, propagate, modify | ||||||
|  | or convey a specific copy of the covered work, then the patent license | ||||||
|  | you grant is automatically extended to all recipients of the covered | ||||||
|  | work and works based on it. | ||||||
|  |  | ||||||
|  |   A patent license is "discriminatory" if it does not include within | ||||||
|  | the scope of its coverage, prohibits the exercise of, or is | ||||||
|  | conditioned on the non-exercise of one or more of the rights that are | ||||||
|  | specifically granted under this License.  You may not convey a covered | ||||||
|  | work if you are a party to an arrangement with a third party that is | ||||||
|  | in the business of distributing software, under which you make payment | ||||||
|  | to the third party based on the extent of your activity of conveying | ||||||
|  | the work, and under which the third party grants, to any of the | ||||||
|  | parties who would receive the covered work from you, a discriminatory | ||||||
|  | patent license (a) in connection with copies of the covered work | ||||||
|  | conveyed by you (or copies made from those copies), or (b) primarily | ||||||
|  | for and in connection with specific products or compilations that | ||||||
|  | contain the covered work, unless you entered into that arrangement, | ||||||
|  | or that patent license was granted, prior to 28 March 2007. | ||||||
|  |  | ||||||
|  |   Nothing in this License shall be construed as excluding or limiting | ||||||
|  | any implied license or other defenses to infringement that may | ||||||
|  | otherwise be available to you under applicable patent law. | ||||||
|  |  | ||||||
|  |   12. No Surrender of Others' Freedom. | ||||||
|  |  | ||||||
|  |   If conditions are imposed on you (whether by court order, agreement or | ||||||
|  | otherwise) that contradict the conditions of this License, they do not | ||||||
|  | excuse you from the conditions of this License.  If you cannot convey a | ||||||
|  | covered work so as to satisfy simultaneously your obligations under this | ||||||
|  | License and any other pertinent obligations, then as a consequence you may | ||||||
|  | not convey it at all.  For example, if you agree to terms that obligate you | ||||||
|  | to collect a royalty for further conveying from those to whom you convey | ||||||
|  | the Program, the only way you could satisfy both those terms and this | ||||||
|  | License would be to refrain entirely from conveying the Program. | ||||||
|  |  | ||||||
|  |   13. Remote Network Interaction; Use with the GNU General Public License. | ||||||
|  |  | ||||||
|  |   Notwithstanding any other provision of this License, if you modify the | ||||||
|  | Program, your modified version must prominently offer all users | ||||||
|  | interacting with it remotely through a computer network (if your version | ||||||
|  | supports such interaction) an opportunity to receive the Corresponding | ||||||
|  | Source of your version by providing access to the Corresponding Source | ||||||
|  | from a network server at no charge, through some standard or customary | ||||||
|  | means of facilitating copying of software.  This Corresponding Source | ||||||
|  | shall include the Corresponding Source for any work covered by version 3 | ||||||
|  | of the GNU General Public License that is incorporated pursuant to the | ||||||
|  | following paragraph. | ||||||
|  |  | ||||||
|  |   Notwithstanding any other provision of this License, you have | ||||||
|  | permission to link or combine any covered work with a work licensed | ||||||
|  | under version 3 of the GNU General Public License into a single | ||||||
|  | combined work, and to convey the resulting work.  The terms of this | ||||||
|  | License will continue to apply to the part which is the covered work, | ||||||
|  | but the work with which it is combined will remain governed by version | ||||||
|  | 3 of the GNU General Public License. | ||||||
|  |  | ||||||
|  |   14. Revised Versions of this License. | ||||||
|  |  | ||||||
|  |   The Free Software Foundation may publish revised and/or new versions of | ||||||
|  | the GNU Affero General Public License from time to time.  Such new versions | ||||||
|  | will be similar in spirit to the present version, but may differ in detail to | ||||||
|  | address new problems or concerns. | ||||||
|  |  | ||||||
|  |   Each version is given a distinguishing version number.  If the | ||||||
|  | Program specifies that a certain numbered version of the GNU Affero General | ||||||
|  | Public License "or any later version" applies to it, you have the | ||||||
|  | option of following the terms and conditions either of that numbered | ||||||
|  | version or of any later version published by the Free Software | ||||||
|  | Foundation.  If the Program does not specify a version number of the | ||||||
|  | GNU Affero General Public License, you may choose any version ever published | ||||||
|  | by the Free Software Foundation. | ||||||
|  |  | ||||||
|  |   If the Program specifies that a proxy can decide which future | ||||||
|  | versions of the GNU Affero General Public License can be used, that proxy's | ||||||
|  | public statement of acceptance of a version permanently authorizes you | ||||||
|  | to choose that version for the Program. | ||||||
|  |  | ||||||
|  |   Later license versions may give you additional or different | ||||||
|  | permissions.  However, no additional obligations are imposed on any | ||||||
|  | author or copyright holder as a result of your choosing to follow a | ||||||
|  | later version. | ||||||
|  |  | ||||||
|  |   15. Disclaimer of Warranty. | ||||||
|  |  | ||||||
|  |   THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | ||||||
|  | APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | ||||||
|  | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY | ||||||
|  | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, | ||||||
|  | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | ||||||
|  | PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM | ||||||
|  | IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF | ||||||
|  | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. | ||||||
|  |  | ||||||
|  |   16. Limitation of Liability. | ||||||
|  |  | ||||||
|  |   IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING | ||||||
|  | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS | ||||||
|  | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY | ||||||
|  | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE | ||||||
|  | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF | ||||||
|  | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD | ||||||
|  | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), | ||||||
|  | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF | ||||||
|  | SUCH DAMAGES. | ||||||
|  |  | ||||||
|  |   17. Interpretation of Sections 15 and 16. | ||||||
|  |  | ||||||
|  |   If the disclaimer of warranty and limitation of liability provided | ||||||
|  | above cannot be given local legal effect according to their terms, | ||||||
|  | reviewing courts shall apply local law that most closely approximates | ||||||
|  | an absolute waiver of all civil liability in connection with the | ||||||
|  | Program, unless a warranty or assumption of liability accompanies a | ||||||
|  | copy of the Program in return for a fee. | ||||||
|  |  | ||||||
|  |                      END OF TERMS AND CONDITIONS | ||||||
|  |  | ||||||
|  |             How to Apply These Terms to Your New Programs | ||||||
|  |  | ||||||
|  |   If you develop a new program, and you want it to be of the greatest | ||||||
|  | possible use to the public, the best way to achieve this is to make it | ||||||
|  | free software which everyone can redistribute and change under these terms. | ||||||
|  |  | ||||||
|  |   To do so, attach the following notices to the program.  It is safest | ||||||
|  | to attach them to the start of each source file to most effectively | ||||||
|  | state the exclusion of warranty; and each file should have at least | ||||||
|  | the "copyright" line and a pointer to where the full notice is found. | ||||||
|  |  | ||||||
|  |     <one line to give the program's name and a brief idea of what it does.> | ||||||
|  |     Copyright (C) <year>  <name of author> | ||||||
|  |  | ||||||
|  |     This program is free software: you can redistribute it and/or modify | ||||||
|  |     it under the terms of the GNU Affero General Public License as published | ||||||
|  |     by the Free Software Foundation, either version 3 of the License, or | ||||||
|  |     (at your option) any later version. | ||||||
|  |  | ||||||
|  |     This program is distributed in the hope that it will be useful, | ||||||
|  |     but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |     GNU Affero General Public License for more details. | ||||||
|  |  | ||||||
|  |     You should have received a copy of the GNU Affero General Public License | ||||||
|  |     along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  |  | ||||||
|  | Also add information on how to contact you by electronic and paper mail. | ||||||
|  |  | ||||||
|  |   If your software can interact with users remotely through a computer | ||||||
|  | network, you should also make sure that it provides a way for users to | ||||||
|  | get its source.  For example, if your program is a web application, its | ||||||
|  | interface could display a "Source" link that leads users to an archive | ||||||
|  | of the code.  There are many ways you could offer source, and different | ||||||
|  | solutions will be better for different programs; see section 13 for the | ||||||
|  | specific requirements. | ||||||
|  |  | ||||||
|  |   You should also get your employer (if you work as a programmer) or school, | ||||||
|  | if any, to sign a "copyright disclaimer" for the program, if necessary. | ||||||
|  | For more information on this, and how to apply and follow the GNU AGPL, see | ||||||
|  | <https://www.gnu.org/licenses/>. | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| # OctoPrint-BambuPrinter | # OctoPrint-BambuPrinter | ||||||
|  |  | ||||||
|  | This plugin is an attempt to connect BambuLab printers to OctoPrint. It's still a work in progress, and there may be bugs/quirks that you will have to work around while using the plugin and during development.  | ||||||
|  |  | ||||||
| ## System Requirements | ## System Requirements | ||||||
|  |  | ||||||
| * Python 3.9 or higher (OctoPi 1.0.0) | * Python 3.9 or higher (OctoPi 1.0.0) | ||||||
|   | |||||||
| @@ -1,260 +1,10 @@ | |||||||
| # coding=utf-8 | # coding=utf-8 | ||||||
| from __future__ import absolute_import |  | ||||||
|  |  | ||||||
| import os |  | ||||||
| import threading |  | ||||||
| import time |  | ||||||
| import flask |  | ||||||
| import datetime |  | ||||||
|  |  | ||||||
| import octoprint.plugin |  | ||||||
| from octoprint.events import Events |  | ||||||
| from octoprint.util import get_formatted_size, get_formatted_datetime, is_hidden_path |  | ||||||
| from octoprint.server.util.flask import no_firstrun_access |  | ||||||
| from octoprint.server.util.tornado import LargeResponseHandler, UrlProxyHandler, path_validation_factory |  | ||||||
| from octoprint.access.permissions import Permissions |  | ||||||
| from urllib.parse import quote as urlquote |  | ||||||
| from .ftpsclient import IoTFTPSClient |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BambuPrintPlugin(octoprint.plugin.SettingsPlugin, |  | ||||||
|                        octoprint.plugin.TemplatePlugin, |  | ||||||
|                        octoprint.plugin.AssetPlugin, |  | ||||||
|                        octoprint.plugin.EventHandlerPlugin, |  | ||||||
|                        octoprint.plugin.SimpleApiPlugin, |  | ||||||
|                        octoprint.plugin.BlueprintPlugin): |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     def get_assets(self): |  | ||||||
|         return {'js': ["js/bambu_printer.js"]} |  | ||||||
|     def get_template_configs(self): |  | ||||||
|         return [{"type": "settings", "custom_bindings": True}, |  | ||||||
|                 {"type": "generic", "custom_bindings": True, "template": "bambu_timelapse.jinja2"}] #, {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] |  | ||||||
|  |  | ||||||
|     def get_settings_defaults(self): |  | ||||||
|         return {"device_type": "X1C", |  | ||||||
|                 "serial": "", |  | ||||||
|                 "host": "", |  | ||||||
|                 "access_code": "", |  | ||||||
|                 "username": "bblp", |  | ||||||
|                 "timelapse": False, |  | ||||||
|                 "bed_leveling": True, |  | ||||||
|                 "flow_cali": False, |  | ||||||
|                 "vibration_cali": True, |  | ||||||
|                 "layer_inspect": True, |  | ||||||
|                 "use_ams": False, |  | ||||||
|                 "local_mqtt": True, |  | ||||||
|                 "region": "", |  | ||||||
|                 "email": "", |  | ||||||
|                 "auth_token": "", |  | ||||||
|                 "always_use_default_options": False |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|     def is_api_adminonly(self): |  | ||||||
|         return True |  | ||||||
|  |  | ||||||
|     def get_api_commands(self): |  | ||||||
|         return {"register": ["email", "password", "region", "auth_token"]} |  | ||||||
|     def on_api_command(self, command, data): |  | ||||||
|         if command == "register": |  | ||||||
|             if "email" in data and "password" in data and "region" in data and "auth_token" in data: |  | ||||||
|                 self._logger.info(f"Registering user {data['email']}") |  | ||||||
|                 from pybambu import BambuCloud |  | ||||||
|                 bambu_cloud = BambuCloud(data["region"], data["email"], data["password"], data["auth_token"]) |  | ||||||
|                 bambu_cloud.login(data["region"], data["email"], data["password"]) |  | ||||||
|                 return flask.jsonify({"auth_token": bambu_cloud.auth_token, "username": bambu_cloud.username}) |  | ||||||
|     def on_event(self, event, payload): |  | ||||||
|         if event == Events.TRANSFER_DONE: |  | ||||||
|             self._printer.commands("M20 L T", force=True) |  | ||||||
|     def support_3mf_files(self): |  | ||||||
|         return {'machinecode': {'3mf': ["3mf"]}} |  | ||||||
|  |  | ||||||
|     def upload_to_sd(self, printer, filename, path, sd_upload_started, sd_upload_succeeded, sd_upload_failed, *args, **kwargs): |  | ||||||
|         self._logger.debug(f"Starting upload from {filename} to {filename}") |  | ||||||
|         sd_upload_started(filename, filename) |  | ||||||
|         def process(): |  | ||||||
|             host = self._settings.get(["host"]) |  | ||||||
|             access_code = self._settings.get(["access_code"]) |  | ||||||
|             elapsed = time.monotonic() |  | ||||||
|  |  | ||||||
|             try: |  | ||||||
|                 ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) |  | ||||||
|                 if ftp.upload_file(path, f"{filename}"): |  | ||||||
|                     elapsed = time.monotonic() - elapsed |  | ||||||
|                     sd_upload_succeeded(filename, filename, elapsed) |  | ||||||
|                     # remove local file after successful upload to Bambu |  | ||||||
|                     # self._file_manager.remove_file("local", filename) |  | ||||||
|                 else: |  | ||||||
|                     raise Exception("upload failed") |  | ||||||
|             except Exception as e: |  | ||||||
|                 elapsed = time.monotonic() - elapsed |  | ||||||
|                 sd_upload_failed(filename, filename, elapsed) |  | ||||||
|                 self._logger.debug(f"Error uploading file {filename}") |  | ||||||
|  |  | ||||||
|         thread = threading.Thread(target=process) |  | ||||||
|         thread.daemon = True |  | ||||||
|         thread.start() |  | ||||||
|  |  | ||||||
|         return filename |  | ||||||
|  |  | ||||||
|     def get_template_vars(self): |  | ||||||
|         return {"plugin_version": self._plugin_version} |  | ||||||
|  |  | ||||||
|     def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): |  | ||||||
|         if not port == "BAMBU": |  | ||||||
|             return None |  | ||||||
|  |  | ||||||
|         if self._settings.get(["serial"]) == "" or self._settings.get(["host"]) == "" or self._settings.get(["access_code"]) == "": |  | ||||||
|             return None |  | ||||||
|  |  | ||||||
|         import logging.handlers |  | ||||||
|  |  | ||||||
|         from octoprint.logging.handlers import CleaningTimedRotatingFileHandler |  | ||||||
|  |  | ||||||
|         seriallog_handler = CleaningTimedRotatingFileHandler( |  | ||||||
|             self._settings.get_plugin_logfile_path(postfix="serial"), |  | ||||||
|             when="D", |  | ||||||
|             backupCount=3, |  | ||||||
|         ) |  | ||||||
|         seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) |  | ||||||
|         seriallog_handler.setLevel(logging.DEBUG) |  | ||||||
|  |  | ||||||
|         from . import virtual |  | ||||||
|  |  | ||||||
|         serial_obj = virtual.BambuPrinter( |  | ||||||
|             self._settings, |  | ||||||
|             self._printer_profile_manager, |  | ||||||
|             data_folder=self.get_plugin_data_folder(), |  | ||||||
|             seriallog_handler=seriallog_handler, |  | ||||||
|             read_timeout=float(read_timeout), |  | ||||||
|             faked_baudrate=baudrate, |  | ||||||
|         ) |  | ||||||
|         return serial_obj |  | ||||||
|  |  | ||||||
|     def get_additional_port_names(self, *args, **kwargs): |  | ||||||
|         if self._settings.get(["serial"]) != "" and self._settings.get(["host"]) != "" and self._settings.get(["access_code"]) != "": |  | ||||||
|             return ["BAMBU"] |  | ||||||
|         else: |  | ||||||
|             return [] |  | ||||||
|  |  | ||||||
|     def get_timelapse_file_list(self): |  | ||||||
|         if flask.request.path.startswith('/api/timelapse'): |  | ||||||
|             def process(): |  | ||||||
|                 host = self._settings.get(["host"]) |  | ||||||
|                 access_code = self._settings.get(["access_code"]) |  | ||||||
|                 return_file_list = [] |  | ||||||
|  |  | ||||||
|                 try: |  | ||||||
|                     ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) |  | ||||||
|                     if self._settings.get(["device_type"]) in ["X1", "X1C"]: |  | ||||||
|                         timelapse_file_list = ftp.list_files("timelapse/", ".mp4") or [] |  | ||||||
|                     else: |  | ||||||
|                         timelapse_file_list = ftp.list_files("timelapse/", ".avi") or [] |  | ||||||
|  |  | ||||||
|                     for entry in timelapse_file_list: |  | ||||||
|                         if entry.startswith("/"): |  | ||||||
|                             filename = entry[1:].replace("timelapse/", "") |  | ||||||
|                         else: |  | ||||||
|                             filename = entry.replace("timelapse/", "") |  | ||||||
|  |  | ||||||
|                         filesize = ftp.ftps_session.size(f"timelapse/{filename}") |  | ||||||
|                         date_str = ftp.ftps_session.sendcmd(f"MDTM timelapse/{filename}").replace("213 ", "") |  | ||||||
|                         filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp() |  | ||||||
|  |  | ||||||
|                         return_file_list.append( |  | ||||||
|                             { |  | ||||||
|                                 "bytes": filesize, |  | ||||||
|                                 "date": get_formatted_datetime(datetime.datetime.fromtimestamp(filedate)), |  | ||||||
|                                 "name": filename, |  | ||||||
|                                 "size": get_formatted_size(filesize), |  | ||||||
|                                 "thumbnail": "/plugin/bambu_printer/thumbnail/" + filename.replace(".mp4", ".jpg").replace(".avi", ".jpg"), |  | ||||||
|                                 "timestamp": filedate, |  | ||||||
|                                 "url": f"/plugin/bambu_printer/timelapse/{filename}" |  | ||||||
|                             }) |  | ||||||
|  |  | ||||||
|                     self._plugin_manager.send_plugin_message(self._identifier, {'files': return_file_list}) |  | ||||||
|  |  | ||||||
|                 except Exception as e: |  | ||||||
|                     self._logger.debug(f"Error getting timelapse files: {e}") |  | ||||||
|  |  | ||||||
|             thread = threading.Thread(target=process) |  | ||||||
|             thread.daemon = True |  | ||||||
|             thread.start() |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     def _hook_octoprint_server_api_before_request(self, *args, **kwargs): |  | ||||||
|         return [self.get_timelapse_file_list] |  | ||||||
|  |  | ||||||
|     @octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"]) |  | ||||||
|     @octoprint.server.util.flask.restricted_access |  | ||||||
|     @no_firstrun_access |  | ||||||
|     @Permissions.TIMELAPSE_DOWNLOAD.require(403) |  | ||||||
|     def downloadTimelapse(self, filename): |  | ||||||
|         dest_filename = os.path.join(self.get_plugin_data_folder(), filename) |  | ||||||
|         host = self._settings.get(["host"]) |  | ||||||
|         access_code = self._settings.get(["access_code"]) |  | ||||||
|  |  | ||||||
|         if not os.path.exists(dest_filename): |  | ||||||
|             ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) |  | ||||||
|             download_result = ftp.download_file( |  | ||||||
|                 source=f"timelapse/{filename}", |  | ||||||
|                 dest=dest_filename, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         return flask.redirect("/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302) |  | ||||||
|  |  | ||||||
|     @octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"]) |  | ||||||
|     @octoprint.server.util.flask.restricted_access |  | ||||||
|     @no_firstrun_access |  | ||||||
|     @Permissions.TIMELAPSE_DOWNLOAD.require(403) |  | ||||||
|     def downloadThumbnail(self, filename): |  | ||||||
|         dest_filename = os.path.join(self.get_plugin_data_folder(), filename) |  | ||||||
|         host = self._settings.get(["host"]) |  | ||||||
|         access_code = self._settings.get(["access_code"]) |  | ||||||
|  |  | ||||||
|         if not os.path.exists(dest_filename): |  | ||||||
|             ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) |  | ||||||
|             download_result = ftp.download_file( |  | ||||||
|                 source=f"timelapse/thumbnail/{filename}", |  | ||||||
|                 dest=dest_filename, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         return flask.redirect("/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302) |  | ||||||
|  |  | ||||||
|     def is_blueprint_csrf_protected(self): |  | ||||||
|         return True |  | ||||||
|  |  | ||||||
|     def route_hook(self, server_routes, *args, **kwargs): |  | ||||||
|         return [ |  | ||||||
|             (r"/download/timelapse/(.*)", LargeResponseHandler, |  | ||||||
|              {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( |  | ||||||
|                  lambda path: not is_hidden_path(path), status_code=404)}), |  | ||||||
|             (r"/download/thumbnail/(.*)", LargeResponseHandler, |  | ||||||
|              {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( |  | ||||||
|                  lambda path: not is_hidden_path(path), status_code=404)}) |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|     def get_update_information(self): |  | ||||||
|         return {'bambu_printer': {'displayName': "Bambu Printer", |  | ||||||
|                                   'displayVersion': self._plugin_version, |  | ||||||
|                                   'type': "github_release", |  | ||||||
|                                   'user': "jneilliii", |  | ||||||
|                                   'repo': "OctoPrint-BambuPrinter", |  | ||||||
|                                   'current': self._plugin_version, |  | ||||||
|                                   'stable_branch': {'name': "Stable", |  | ||||||
|                                                     'branch': "master", |  | ||||||
|                                                     'comittish': ["master"]}, |  | ||||||
|                                   'prerelease_branches': [ |  | ||||||
|                                       {'name': "Release Candidate", |  | ||||||
|                                        'branch': "rc", |  | ||||||
|                                        'comittish': ["rc", "master"]} |  | ||||||
|                                   ], |  | ||||||
|                                   'pip': "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip"}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| __plugin_name__ = "Bambu Printer" | __plugin_name__ = "Bambu Printer" | ||||||
| __plugin_pythoncompat__ = ">=3.7,<4" | __plugin_pythoncompat__ = ">=3.7,<4" | ||||||
|  |  | ||||||
|  | from .bambu_print_plugin import BambuPrintPlugin | ||||||
|  |  | ||||||
|  |  | ||||||
| def __plugin_load__(): | def __plugin_load__(): | ||||||
|     plugin = BambuPrintPlugin() |     plugin = BambuPrintPlugin() | ||||||
| @@ -270,5 +20,5 @@ def __plugin_load__(): | |||||||
|         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, |         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, | ||||||
|         "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, |         "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, | ||||||
|         "octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request, |         "octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request, | ||||||
|         "octoprint.server.http.routes": __plugin_implementation__.route_hook |         "octoprint.server.http.routes": __plugin_implementation__.route_hook, | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | |||||||
|  | from __future__ import absolute_import, annotations | ||||||
|  | from pathlib import Path | ||||||
|  | import threading | ||||||
|  | from time import perf_counter | ||||||
|  | from contextlib import contextmanager | ||||||
|  | import flask | ||||||
|  | import logging.handlers | ||||||
|  | from urllib.parse import quote as urlquote | ||||||
|  |  | ||||||
|  | import octoprint.printer | ||||||
|  | import octoprint.server | ||||||
|  | import octoprint.plugin | ||||||
|  | from octoprint.events import Events | ||||||
|  | import octoprint.settings | ||||||
|  | from octoprint.util import is_hidden_path | ||||||
|  | from octoprint.server.util.flask import no_firstrun_access | ||||||
|  | from octoprint.server.util.tornado import ( | ||||||
|  |     LargeResponseHandler, | ||||||
|  |     path_validation_factory, | ||||||
|  | ) | ||||||
|  | from octoprint.access.permissions import Permissions | ||||||
|  | from octoprint.logging.handlers import CleaningTimedRotatingFileHandler | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||||
|  | from pybambu import BambuCloud | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||||
|  |     RemoteSDCardFileList, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | from .printer.file_system.bambu_timelapse_file_info import ( | ||||||
|  |     BambuTimelapseFileInfo, | ||||||
|  | ) | ||||||
|  | from .printer.bambu_virtual_printer import BambuVirtualPrinter | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @contextmanager | ||||||
|  | def measure_elapsed(): | ||||||
|  |     start = perf_counter() | ||||||
|  |  | ||||||
|  |     def _get_elapsed(): | ||||||
|  |         return perf_counter() - start | ||||||
|  |  | ||||||
|  |     yield _get_elapsed | ||||||
|  |     print(f"Total elapsed: {_get_elapsed()}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BambuPrintPlugin( | ||||||
|  |     octoprint.plugin.SettingsPlugin, | ||||||
|  |     octoprint.plugin.TemplatePlugin, | ||||||
|  |     octoprint.plugin.AssetPlugin, | ||||||
|  |     octoprint.plugin.EventHandlerPlugin, | ||||||
|  |     octoprint.plugin.SimpleApiPlugin, | ||||||
|  |     octoprint.plugin.BlueprintPlugin, | ||||||
|  | ): | ||||||
|  |     _logger: logging.Logger | ||||||
|  |     _plugin_manager: octoprint.plugin.PluginManager | ||||||
|  |     _bambu_file_system: RemoteSDCardFileList | ||||||
|  |     _timelapse_files_view: CachedFileView | ||||||
|  |  | ||||||
|  |     def on_settings_initialized(self): | ||||||
|  |         self._bambu_file_system = RemoteSDCardFileList(self._settings) | ||||||
|  |         self._timelapse_files_view = CachedFileView(self._bambu_file_system) | ||||||
|  |         if self._settings.get(["device_type"]) in ["X1", "X1C"]: | ||||||
|  |             self._timelapse_files_view.with_filter("timelapse/", ".mp4") | ||||||
|  |         else: | ||||||
|  |             self._timelapse_files_view.with_filter("timelapse/", ".avi") | ||||||
|  |  | ||||||
|  |     def get_assets(self): | ||||||
|  |         return {"js": ["js/bambu_printer.js"]} | ||||||
|  |  | ||||||
|  |     def get_template_configs(self): | ||||||
|  |         return [ | ||||||
|  |             {"type": "settings", "custom_bindings": True}, | ||||||
|  |             { | ||||||
|  |                 "type": "generic", | ||||||
|  |                 "custom_bindings": True, | ||||||
|  |                 "template": "bambu_timelapse.jinja2", | ||||||
|  |             }, | ||||||
|  |         ]  # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] | ||||||
|  |  | ||||||
|  |     def get_settings_defaults(self): | ||||||
|  |         return { | ||||||
|  |             "device_type": "X1C", | ||||||
|  |             "serial": "", | ||||||
|  |             "host": "", | ||||||
|  |             "access_code": "", | ||||||
|  |             "username": "octobambu", | ||||||
|  |             "timelapse": False, | ||||||
|  |             "bed_leveling": True, | ||||||
|  |             "flow_cali": False, | ||||||
|  |             "vibration_cali": True, | ||||||
|  |             "layer_inspect": False, | ||||||
|  |             "use_ams": False, | ||||||
|  |             "local_mqtt": True, | ||||||
|  |             "region": "", | ||||||
|  |             "email": "", | ||||||
|  |             "auth_token": "", | ||||||
|  |             "always_use_default_options": False, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     def is_api_adminonly(self): | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def get_api_commands(self): | ||||||
|  |         return {"register": ["email", "password", "region", "auth_token"]} | ||||||
|  |  | ||||||
|  |     def on_api_command(self, command, data): | ||||||
|  |         if command == "register": | ||||||
|  |             if ( | ||||||
|  |                 "email" in data | ||||||
|  |                 and "password" in data | ||||||
|  |                 and "region" in data | ||||||
|  |                 and "auth_token" in data | ||||||
|  |             ): | ||||||
|  |                 self._logger.info(f"Registering user {data['email']}") | ||||||
|  |                 bambu_cloud = BambuCloud( | ||||||
|  |                     data["region"], data["email"], data["password"], data["auth_token"] | ||||||
|  |                 ) | ||||||
|  |                 bambu_cloud.login(data["region"], data["email"], data["password"]) | ||||||
|  |                 return flask.jsonify( | ||||||
|  |                     { | ||||||
|  |                         "auth_token": bambu_cloud.auth_token, | ||||||
|  |                         "username": bambu_cloud.username, | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     def on_event(self, event, payload): | ||||||
|  |         if event == Events.TRANSFER_DONE: | ||||||
|  |             self._printer.commands("M20 L T", force=True) | ||||||
|  |  | ||||||
|  |     def support_3mf_files(self): | ||||||
|  |         return {"machinecode": {"3mf": ["3mf"]}} | ||||||
|  |  | ||||||
|  |     def upload_to_sd( | ||||||
|  |         self, | ||||||
|  |         printer, | ||||||
|  |         filename, | ||||||
|  |         path, | ||||||
|  |         sd_upload_started, | ||||||
|  |         sd_upload_succeeded, | ||||||
|  |         sd_upload_failed, | ||||||
|  |         *args, | ||||||
|  |         **kwargs, | ||||||
|  |     ): | ||||||
|  |         self._logger.debug(f"Starting upload from {filename} to {filename}") | ||||||
|  |         sd_upload_started(filename, filename) | ||||||
|  |  | ||||||
|  |         def process(): | ||||||
|  |             with measure_elapsed() as get_elapsed: | ||||||
|  |                 try: | ||||||
|  |                     with self._bambu_file_system.get_ftps_client() as ftp: | ||||||
|  |                         if ftp.upload_file(path, f"{filename}"): | ||||||
|  |                             sd_upload_succeeded(filename, filename, get_elapsed()) | ||||||
|  |                         else: | ||||||
|  |                             raise Exception("upload failed") | ||||||
|  |                 except Exception as e: | ||||||
|  |                     sd_upload_failed(filename, filename, get_elapsed()) | ||||||
|  |                     self._logger.exception(e) | ||||||
|  |  | ||||||
|  |         thread = threading.Thread(target=process) | ||||||
|  |         thread.daemon = True | ||||||
|  |         thread.start() | ||||||
|  |         return filename | ||||||
|  |  | ||||||
|  |     def get_template_vars(self): | ||||||
|  |         return {"plugin_version": self._plugin_version} | ||||||
|  |  | ||||||
|  |     def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): | ||||||
|  |         if not port == "BAMBU": | ||||||
|  |             return None | ||||||
|  |         if ( | ||||||
|  |             self._settings.get(["serial"]) == "" | ||||||
|  |             or self._settings.get(["host"]) == "" | ||||||
|  |             or self._settings.get(["access_code"]) == "" | ||||||
|  |         ): | ||||||
|  |             return None | ||||||
|  |         seriallog_handler = CleaningTimedRotatingFileHandler( | ||||||
|  |             self._settings.get_plugin_logfile_path(postfix="serial"), | ||||||
|  |             when="D", | ||||||
|  |             backupCount=3, | ||||||
|  |         ) | ||||||
|  |         seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) | ||||||
|  |         seriallog_handler.setLevel(logging.DEBUG) | ||||||
|  |  | ||||||
|  |         serial_obj = BambuVirtualPrinter( | ||||||
|  |             self._settings, | ||||||
|  |             self._printer_profile_manager, | ||||||
|  |             data_folder=self.get_plugin_data_folder(), | ||||||
|  |             serial_log_handler=seriallog_handler, | ||||||
|  |             read_timeout=float(read_timeout), | ||||||
|  |             faked_baudrate=baudrate, | ||||||
|  |         ) | ||||||
|  |         return serial_obj | ||||||
|  |  | ||||||
|  |     def get_additional_port_names(self, *args, **kwargs): | ||||||
|  |         if ( | ||||||
|  |             self._settings.get(["serial"]) != "" | ||||||
|  |             and self._settings.get(["host"]) != "" | ||||||
|  |             and self._settings.get(["access_code"]) != "" | ||||||
|  |         ): | ||||||
|  |             return ["BAMBU"] | ||||||
|  |         else: | ||||||
|  |             return [] | ||||||
|  |  | ||||||
|  |     def get_timelapse_file_list(self): | ||||||
|  |         if flask.request.path.startswith("/api/timelapse"): | ||||||
|  |  | ||||||
|  |             def process(): | ||||||
|  |                 return_file_list = [] | ||||||
|  |                 for file_info in self._timelapse_files_view.get_all_info(): | ||||||
|  |                     timelapse_info = BambuTimelapseFileInfo.from_file_info(file_info) | ||||||
|  |                     return_file_list.append(timelapse_info.to_dict()) | ||||||
|  |                 self._plugin_manager.send_plugin_message( | ||||||
|  |                     self._identifier, {"files": return_file_list} | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             thread = threading.Thread(target=process) | ||||||
|  |             thread.daemon = True | ||||||
|  |             thread.start() | ||||||
|  |  | ||||||
|  |     def _hook_octoprint_server_api_before_request(self, *args, **kwargs): | ||||||
|  |         return [self.get_timelapse_file_list] | ||||||
|  |  | ||||||
|  |     def _download_file(self, file_name: str, source_path: str): | ||||||
|  |         destination = Path(self.get_plugin_data_folder()) / file_name | ||||||
|  |         if destination.exists(): | ||||||
|  |             return destination | ||||||
|  |  | ||||||
|  |         with self._bambu_file_system.get_ftps_client() as ftp: | ||||||
|  |             ftp.download_file( | ||||||
|  |                 source=(Path(source_path) / file_name).as_posix(), | ||||||
|  |                 dest=destination.as_posix(), | ||||||
|  |             ) | ||||||
|  |         return destination | ||||||
|  |  | ||||||
|  |     @octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"]) | ||||||
|  |     @octoprint.server.util.flask.restricted_access | ||||||
|  |     @no_firstrun_access | ||||||
|  |     @Permissions.TIMELAPSE_DOWNLOAD.require(403) | ||||||
|  |     def downloadTimelapse(self, filename): | ||||||
|  |         self._download_file(filename, "timelapse/") | ||||||
|  |         return flask.redirect( | ||||||
|  |             "/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"]) | ||||||
|  |     @octoprint.server.util.flask.restricted_access | ||||||
|  |     @no_firstrun_access | ||||||
|  |     @Permissions.TIMELAPSE_DOWNLOAD.require(403) | ||||||
|  |     def downloadThumbnail(self, filename): | ||||||
|  |         self._download_file(filename, "timelapse/thumbnail/") | ||||||
|  |         return flask.redirect( | ||||||
|  |             "/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def is_blueprint_csrf_protected(self): | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def route_hook(self, server_routes, *args, **kwargs): | ||||||
|  |         return [ | ||||||
|  |             ( | ||||||
|  |                 r"/download/timelapse/(.*)", | ||||||
|  |                 LargeResponseHandler, | ||||||
|  |                 { | ||||||
|  |                     "path": self.get_plugin_data_folder(), | ||||||
|  |                     "as_attachment": True, | ||||||
|  |                     "path_validation": path_validation_factory( | ||||||
|  |                         lambda path: not is_hidden_path(path), status_code=404 | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             ( | ||||||
|  |                 r"/download/thumbnail/(.*)", | ||||||
|  |                 LargeResponseHandler, | ||||||
|  |                 { | ||||||
|  |                     "path": self.get_plugin_data_folder(), | ||||||
|  |                     "as_attachment": True, | ||||||
|  |                     "path_validation": path_validation_factory( | ||||||
|  |                         lambda path: not is_hidden_path(path), status_code=404 | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |     def get_update_information(self): | ||||||
|  |         return { | ||||||
|  |             "bambu_printer": { | ||||||
|  |                 "displayName": "Manus Bambu Printer", | ||||||
|  |                 "displayVersion": self._plugin_version, | ||||||
|  |                 "type": "github_release", | ||||||
|  |                 "user": "ManuelW", | ||||||
|  |                 "repo": "OctoPrint-BambuPrinter", | ||||||
|  |                 "current": self._plugin_version, | ||||||
|  |                 "stable_branch": { | ||||||
|  |                     "name": "Stable", | ||||||
|  |                     "branch": "master", | ||||||
|  |                     "comittish": ["master"], | ||||||
|  |                 }, | ||||||
|  |                 "prerelease_branches": [ | ||||||
|  |                     { | ||||||
|  |                         "name": "Release Candidate", | ||||||
|  |                         "branch": "rc", | ||||||
|  |                         "comittish": ["rc", "master"], | ||||||
|  |                     } | ||||||
|  |                 ], | ||||||
|  |                 "pip": "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter/archive/{target_version}.zip", | ||||||
|  |             } | ||||||
|  |         } | ||||||
| @@ -1 +0,0 @@ | |||||||
| from .ftpsclient import IoTFTPSClient |  | ||||||
							
								
								
									
										2
									
								
								octoprint_bambu_printer/printer/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								octoprint_bambu_printer/printer/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | __author__ = "Gina Häußge <osd@foosel.net>" | ||||||
|  | __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" | ||||||
							
								
								
									
										680
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										680
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,680 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import collections | ||||||
|  | from dataclasses import dataclass, field | ||||||
|  | import math | ||||||
|  | from pathlib import Path | ||||||
|  | import queue | ||||||
|  | import re | ||||||
|  | import threading | ||||||
|  | import time | ||||||
|  | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  | from octoprint_bambu_printer.printer.print_job import PrintJob | ||||||
|  | from pybambu import BambuClient, commands | ||||||
|  | import logging | ||||||
|  | import logging.handlers | ||||||
|  |  | ||||||
|  | from octoprint.util import RepeatedTimer | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||||
|  | from octoprint_bambu_printer.printer.states.idle_state import IdleState | ||||||
|  |  | ||||||
|  | from .printer_serial_io import PrinterSerialIO | ||||||
|  | from .states.paused_state import PausedState | ||||||
|  | from .states.printing_state import PrintingState | ||||||
|  |  | ||||||
|  | from .gcode_executor import GCodeExecutor | ||||||
|  | from .file_system.remote_sd_card_file_list import RemoteSDCardFileList | ||||||
|  |  | ||||||
|  |  | ||||||
|  | AMBIENT_TEMPERATURE: float = 21.3 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class BambuPrinterTelemetry: | ||||||
|  |     temp: list[float] = field(default_factory=lambda: [AMBIENT_TEMPERATURE]) | ||||||
|  |     targetTemp: list[float] = field(default_factory=lambda: [0.0]) | ||||||
|  |     bedTemp: float = AMBIENT_TEMPERATURE | ||||||
|  |     bedTargetTemp = 0.0 | ||||||
|  |     hasChamber: bool = False | ||||||
|  |     chamberTemp: float = AMBIENT_TEMPERATURE | ||||||
|  |     chamberTargetTemp: float = 0.0 | ||||||
|  |     lastTempAt: float = time.monotonic() | ||||||
|  |     firmwareName: str = "Bambu" | ||||||
|  |     extruderCount: int = 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # noinspection PyBroadException | ||||||
|  | class BambuVirtualPrinter: | ||||||
|  |     gcode_executor = GCodeExecutor() | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         settings, | ||||||
|  |         printer_profile_manager, | ||||||
|  |         data_folder, | ||||||
|  |         serial_log_handler=None, | ||||||
|  |         read_timeout=5.0, | ||||||
|  |         faked_baudrate=115200, | ||||||
|  |     ): | ||||||
|  |         self._settings = settings | ||||||
|  |         self._printer_profile_manager = printer_profile_manager | ||||||
|  |         self._faked_baudrate = faked_baudrate | ||||||
|  |         self._data_folder = data_folder | ||||||
|  |         self._last_hms_errors = None | ||||||
|  |         self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") | ||||||
|  |  | ||||||
|  |         self._state_idle = IdleState(self) | ||||||
|  |         self._state_printing = PrintingState(self) | ||||||
|  |         self._state_paused = PausedState(self) | ||||||
|  |         self._current_state = self._state_idle | ||||||
|  |  | ||||||
|  |         self._running = True | ||||||
|  |         self._print_status_reporter = None | ||||||
|  |         self._print_temp_reporter = None | ||||||
|  |         self._printer_thread = threading.Thread( | ||||||
|  |             target=self._printer_worker, | ||||||
|  |             name="octoprint.plugins.bambu_printer.printer_state", | ||||||
|  |         ) | ||||||
|  |         self._state_change_queue = queue.Queue() | ||||||
|  |  | ||||||
|  |         self._current_print_job: PrintJob | None = None | ||||||
|  |  | ||||||
|  |         self._serial_io = PrinterSerialIO( | ||||||
|  |             handle_command_callback=self._process_gcode_serial_command, | ||||||
|  |             settings=settings, | ||||||
|  |             serial_log_handler=serial_log_handler, | ||||||
|  |             read_timeout=read_timeout, | ||||||
|  |             write_timeout=10.0, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self._telemetry = BambuPrinterTelemetry() | ||||||
|  |         self._telemetry.hasChamber = printer_profile_manager.get_current().get( | ||||||
|  |             "heatedChamber" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.file_system = RemoteSDCardFileList(settings) | ||||||
|  |         self._selected_project_file: FileInfo | None = None | ||||||
|  |         self._project_files_view = ( | ||||||
|  |             CachedFileView(self.file_system, on_update=self._list_cached_project_files) | ||||||
|  |             .with_filter("", ".3mf") | ||||||
|  |             .with_filter("cache/", ".3mf") | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self._serial_io.start() | ||||||
|  |         self._printer_thread.start() | ||||||
|  |  | ||||||
|  |         self._bambu_client: BambuClient = self._create_client_connection_async() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def bambu_client(self): | ||||||
|  |         return self._bambu_client | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_running(self): | ||||||
|  |         return self._running | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def current_state(self): | ||||||
|  |         return self._current_state | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def current_print_job(self): | ||||||
|  |         return self._current_print_job | ||||||
|  |  | ||||||
|  |     @current_print_job.setter | ||||||
|  |     def current_print_job(self, value): | ||||||
|  |         self._current_print_job = value | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def selected_file(self): | ||||||
|  |         return self._selected_project_file | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_selected_file(self): | ||||||
|  |         return self._selected_project_file is not None | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def timeout(self): | ||||||
|  |         return self._serial_io._read_timeout | ||||||
|  |  | ||||||
|  |     @timeout.setter | ||||||
|  |     def timeout(self, value): | ||||||
|  |         self._log.debug(f"Setting read timeout to {value}s") | ||||||
|  |         self._serial_io._read_timeout = value | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def write_timeout(self): | ||||||
|  |         return self._serial_io._write_timeout | ||||||
|  |  | ||||||
|  |     @write_timeout.setter | ||||||
|  |     def write_timeout(self, value): | ||||||
|  |         self._log.debug(f"Setting write timeout to {value}s") | ||||||
|  |         self._serial_io._write_timeout = value | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def port(self): | ||||||
|  |         return "BAMBU" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def baudrate(self): | ||||||
|  |         return self._faked_baudrate | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def project_files(self): | ||||||
|  |         return self._project_files_view | ||||||
|  |  | ||||||
|  |     def change_state(self, new_state: APrinterState): | ||||||
|  |         self._state_change_queue.put(new_state) | ||||||
|  |  | ||||||
|  |     def new_update(self, event_type): | ||||||
|  |         if event_type == "event_hms_errors": | ||||||
|  |             self._update_hms_errors() | ||||||
|  |         elif event_type == "event_printer_data_update": | ||||||
|  |             self._update_printer_info() | ||||||
|  |  | ||||||
|  |     def _update_printer_info(self): | ||||||
|  |         device_data = self.bambu_client.get_device() | ||||||
|  |         print_job_state = device_data.print_job.gcode_state | ||||||
|  |         temperatures = device_data.temperature | ||||||
|  |  | ||||||
|  |         self.lastTempAt = time.monotonic() | ||||||
|  |         self._telemetry.temp[0] = temperatures.nozzle_temp | ||||||
|  |         self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp | ||||||
|  |         self._telemetry.bedTemp = temperatures.bed_temp | ||||||
|  |         self._telemetry.bedTargetTemp = temperatures.target_bed_temp | ||||||
|  |         self._telemetry.chamberTemp = temperatures.chamber_temp | ||||||
|  |  | ||||||
|  |         self._log.debug(f"Received printer state update: {print_job_state}") | ||||||
|  |         if ( | ||||||
|  |             print_job_state == "IDLE" | ||||||
|  |             or print_job_state == "FINISH" | ||||||
|  |             or print_job_state == "FAILED" | ||||||
|  |         ): | ||||||
|  |             self.change_state(self._state_idle) | ||||||
|  |         elif print_job_state == "RUNNING" or print_job_state == "PREPARE": | ||||||
|  |             self.change_state(self._state_printing) | ||||||
|  |         elif print_job_state == "PAUSE": | ||||||
|  |             self.change_state(self._state_paused) | ||||||
|  |         else: | ||||||
|  |             self._log.warn(f"Unknown print job state: {print_job_state}") | ||||||
|  |  | ||||||
|  |     def _update_hms_errors(self): | ||||||
|  |         bambu_printer = self.bambu_client.get_device() | ||||||
|  |         if ( | ||||||
|  |             bambu_printer.hms.errors != self._last_hms_errors | ||||||
|  |             and bambu_printer.hms.errors["Count"] > 0 | ||||||
|  |         ): | ||||||
|  |             self._log.debug(f"HMS Error: {bambu_printer.hms.errors}") | ||||||
|  |             for n in range(1, bambu_printer.hms.errors["Count"] + 1): | ||||||
|  |                 error = bambu_printer.hms.errors[f"{n}-Error"].strip() | ||||||
|  |                 self.sendIO(f"// action:notification {error}") | ||||||
|  |             self._last_hms_errors = bambu_printer.hms.errors | ||||||
|  |  | ||||||
|  |     def on_disconnect(self, on_disconnect): | ||||||
|  |         self._log.debug(f"on disconnect called") | ||||||
|  |         return on_disconnect | ||||||
|  |  | ||||||
|  |     def on_connect(self, on_connect): | ||||||
|  |         self._log.debug(f"on connect called") | ||||||
|  |         return on_connect | ||||||
|  |  | ||||||
|  |     def _create_client_connection_async(self): | ||||||
|  |         self._create_client_connection() | ||||||
|  |         if self._bambu_client is None: | ||||||
|  |             raise RuntimeError("Connection with Bambu Client not established") | ||||||
|  |         return self._bambu_client | ||||||
|  |  | ||||||
|  |     def _create_client_connection(self): | ||||||
|  |         if ( | ||||||
|  |             self._settings.get(["device_type"]) == "" | ||||||
|  |             or self._settings.get(["serial"]) == "" | ||||||
|  |             or self._settings.get(["username"]) == "" | ||||||
|  |             or self._settings.get(["access_code"]) == "" | ||||||
|  |         ): | ||||||
|  |             msg = "invalid settings to start connection with Bambu Printer" | ||||||
|  |             self._log.debug(msg) | ||||||
|  |             raise ValueError(msg) | ||||||
|  |  | ||||||
|  |         self._log.debug( | ||||||
|  |             f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" | ||||||
|  |         ) | ||||||
|  |         bambu_client = BambuClient( | ||||||
|  |             device_type=self._settings.get(["device_type"]), | ||||||
|  |             serial=self._settings.get(["serial"]), | ||||||
|  |             host=self._settings.get(["host"]), | ||||||
|  |             username=("bambuocto"), | ||||||
|  |             access_code=self._settings.get(["access_code"]), | ||||||
|  |             local_mqtt=self._settings.get_boolean(["local_mqtt"]), | ||||||
|  |             region=self._settings.get(["region"]), | ||||||
|  |             email=self._settings.get(["email"]), | ||||||
|  |             auth_token=self._settings.get(["auth_token"]), | ||||||
|  |         ) | ||||||
|  |         bambu_client.on_disconnect = self.on_disconnect(bambu_client.on_disconnect) | ||||||
|  |         bambu_client.on_connect = self.on_connect(bambu_client.on_connect) | ||||||
|  |         bambu_client.connect(callback=self.new_update) | ||||||
|  |         self._log.info(f"bambu connection status: {bambu_client.connected}") | ||||||
|  |         self.sendOk() | ||||||
|  |         self._bambu_client = bambu_client | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( | ||||||
|  |             read_timeout=self.timeout, | ||||||
|  |             write_timeout=self.write_timeout, | ||||||
|  |             options={ | ||||||
|  |                 "device_type": self._settings.get(["device_type"]), | ||||||
|  |                 "host": self._settings.get(["host"]), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def _reset(self): | ||||||
|  |         with self._serial_io.incoming_lock: | ||||||
|  |             self.lastN = 0 | ||||||
|  |             self._running = False | ||||||
|  |  | ||||||
|  |             if self._print_status_reporter is not None: | ||||||
|  |                 self._print_status_reporter.cancel() | ||||||
|  |                 self._print_status_reporter = None | ||||||
|  |  | ||||||
|  |             if self._settings.get_boolean(["simulateReset"]): | ||||||
|  |                 for item in self._settings.get(["resetLines"]): | ||||||
|  |                     self.sendIO(item + "\n") | ||||||
|  |  | ||||||
|  |             self._serial_io.reset() | ||||||
|  |  | ||||||
|  |     def write(self, data: bytes) -> int: | ||||||
|  |         return self._serial_io.write(data) | ||||||
|  |  | ||||||
|  |     def readline(self) -> bytes: | ||||||
|  |         return self._serial_io.readline() | ||||||
|  |  | ||||||
|  |     def readlines(self) -> list[bytes]: | ||||||
|  |         return self._serial_io.readlines() | ||||||
|  |  | ||||||
|  |     def sendIO(self, line: str): | ||||||
|  |         self._serial_io.send(line) | ||||||
|  |  | ||||||
|  |     def sendOk(self): | ||||||
|  |         self._serial_io.sendOk() | ||||||
|  |  | ||||||
|  |     def flush(self): | ||||||
|  |         self._serial_io.flush() | ||||||
|  |         self._wait_for_state_change() | ||||||
|  |  | ||||||
|  |     ##~~ project file functions | ||||||
|  |  | ||||||
|  |     def remove_project_selection(self): | ||||||
|  |         self._selected_project_file = None | ||||||
|  |  | ||||||
|  |     def select_project_file(self, file_path: str) -> bool: | ||||||
|  |         self._log.debug(f"Select project file: {file_path}") | ||||||
|  |         file_info = self._project_files_view.get_file_by_stem( | ||||||
|  |             file_path, [".gcode", ".3mf"] | ||||||
|  |         ) | ||||||
|  |         if ( | ||||||
|  |             self._selected_project_file is not None | ||||||
|  |             and file_info is not None | ||||||
|  |             and self._selected_project_file.path == file_info.path | ||||||
|  |         ): | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |         if file_info is None: | ||||||
|  |             self._log.error(f"Cannot select not existing file: {file_path}") | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         self._selected_project_file = file_info | ||||||
|  |         self._send_file_selected_message() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     ##~~ command implementations | ||||||
|  |  | ||||||
|  |     @gcode_executor.register_no_data("M21") | ||||||
|  |     def _sd_status(self) -> None: | ||||||
|  |         self.sendIO("SD card ok") | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M23") | ||||||
|  |     def _select_sd_file(self, data: str) -> bool: | ||||||
|  |         filename = data.split(maxsplit=1)[1].strip() | ||||||
|  |         return self.select_project_file(filename) | ||||||
|  |  | ||||||
|  |     def _send_file_selected_message(self): | ||||||
|  |         if self.selected_file is None: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         self.sendIO( | ||||||
|  |             f"File opened: {self.selected_file.file_name}  " | ||||||
|  |             f"Size: {self.selected_file.size}" | ||||||
|  |         ) | ||||||
|  |         self.sendIO("File selected") | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M26") | ||||||
|  |     def _set_sd_position(self, data: str) -> bool: | ||||||
|  |         if data == "M26 S0": | ||||||
|  |             return self._cancel_print() | ||||||
|  |         else: | ||||||
|  |             self._log.debug("ignoring M26 command.") | ||||||
|  |             self.sendIO("M26 disabled for Bambu") | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M27") | ||||||
|  |     def _report_sd_print_status(self, data: str) -> bool: | ||||||
|  |         matchS = re.search(r"S([0-9]+)", data) | ||||||
|  |         if matchS: | ||||||
|  |             interval = int(matchS.group(1)) | ||||||
|  |             if interval > 0: | ||||||
|  |                 self.start_continuous_status_report(interval) | ||||||
|  |                 return False | ||||||
|  |             else: | ||||||
|  |                 self.stop_continuous_status_report() | ||||||
|  |                 return False | ||||||
|  |  | ||||||
|  |         self.report_print_job_status() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def start_continuous_status_report(self, interval: int): | ||||||
|  |         if self._print_status_reporter is not None: | ||||||
|  |             self._print_status_reporter.cancel() | ||||||
|  |  | ||||||
|  |         self._print_status_reporter = RepeatedTimer( | ||||||
|  |             interval, self.report_print_job_status | ||||||
|  |         ) | ||||||
|  |         self._print_status_reporter.start() | ||||||
|  |  | ||||||
|  |     def stop_continuous_status_report(self): | ||||||
|  |         if self._print_status_reporter is not None: | ||||||
|  |             self._print_status_reporter.cancel() | ||||||
|  |             self._print_status_reporter = None | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M30") | ||||||
|  |     def _delete_project_file(self, data: str) -> bool: | ||||||
|  |         file_path = data.split(maxsplit=1)[1].strip() | ||||||
|  |         file_info = self.project_files.get_file_data(file_path) | ||||||
|  |         if file_info is not None: | ||||||
|  |             self.file_system.delete_file(file_info.path) | ||||||
|  |             self._update_project_file_list() | ||||||
|  |         else: | ||||||
|  |             self._log.error(f"File not found to delete {file_path}") | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M105") | ||||||
|  |     def _report_temperatures(self, data: str) -> bool: | ||||||
|  |         self._processTemperatureQuery() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M155") | ||||||
|  |     def _auto_report_temperatures(self, data: str) -> bool: | ||||||
|  |         matchS = re.search(r"S([0-9]+)", data) | ||||||
|  |         if matchS: | ||||||
|  |             interval = int(matchS.group(1)) | ||||||
|  |             if interval > 0: | ||||||
|  |                 self.start_continuous_temp_report(interval) | ||||||
|  |             else: | ||||||
|  |                 self.stop_continuous_temp_report() | ||||||
|  |  | ||||||
|  |         self.report_print_job_status() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def start_continuous_temp_report(self, interval: int): | ||||||
|  |         if self._print_temp_reporter is not None: | ||||||
|  |             self._print_temp_reporter.cancel() | ||||||
|  |  | ||||||
|  |         self._print_temp_reporter = RepeatedTimer( | ||||||
|  |             interval, self._processTemperatureQuery | ||||||
|  |         ) | ||||||
|  |         self._print_temp_reporter.start() | ||||||
|  |  | ||||||
|  |     def stop_continuous_temp_report(self): | ||||||
|  |         if self._print_temp_reporter is not None: | ||||||
|  |             self._print_temp_reporter.cancel() | ||||||
|  |             self._print_temp_reporter = None | ||||||
|  |  | ||||||
|  |     # noinspection PyUnusedLocal | ||||||
|  |     @gcode_executor.register_no_data("M115") | ||||||
|  |     def _report_firmware_info(self) -> bool: | ||||||
|  |         self.sendIO("Bambu Printer Integration") | ||||||
|  |         self.sendIO("Cap:AUTOREPORT_SD_STATUS:1") | ||||||
|  |         self.sendIO("Cap:AUTOREPORT_TEMP:1") | ||||||
|  |         self.sendIO("Cap:EXTENDED_M20:1") | ||||||
|  |         self.sendIO("Cap:LFN_WRITE:1") | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M117") | ||||||
|  |     def _get_lcd_message(self, data: str) -> bool: | ||||||
|  |         result = re.search(r"M117\s+(.*)", data).group(1) | ||||||
|  |         self.sendIO(f"echo:{result}") | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M118") | ||||||
|  |     def _serial_print(self, data: str) -> bool: | ||||||
|  |         match = re.search(r"M118 (?:(?P<parameter>A1|E1|Pn[012])\s)?(?P<text>.*)", data) | ||||||
|  |         if not match: | ||||||
|  |             self.sendIO("Unrecognized command parameters for M118") | ||||||
|  |         else: | ||||||
|  |             result = match.groupdict() | ||||||
|  |             text = result["text"] | ||||||
|  |             parameter = result["parameter"] | ||||||
|  |  | ||||||
|  |             if parameter == "A1": | ||||||
|  |                 self.sendIO(f"//{text}") | ||||||
|  |             elif parameter == "E1": | ||||||
|  |                 self.sendIO(f"echo:{text}") | ||||||
|  |             else: | ||||||
|  |                 self.sendIO(text) | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     # noinspection PyUnusedLocal | ||||||
|  |     @gcode_executor.register("M220") | ||||||
|  |     def _set_feedrate_percent(self, data: str) -> bool: | ||||||
|  |         if self.bambu_client.connected: | ||||||
|  |             gcode_command = commands.SEND_GCODE_TEMPLATE | ||||||
|  |             percent = int(data.replace("M220 S", "")) | ||||||
|  |  | ||||||
|  |             def speed_fraction(speed_percent): | ||||||
|  |                 return math.floor(10000 / speed_percent) / 100 | ||||||
|  |  | ||||||
|  |             def acceleration_magnitude(speed_percent): | ||||||
|  |                 return math.exp((speed_fraction(speed_percent) - 1.0191) / -0.8139) | ||||||
|  |  | ||||||
|  |             def feed_rate(speed_percent): | ||||||
|  |                 return 6.426e-5 * speed_percent ** 2 - 2.484e-3 * speed_percent + 0.654 | ||||||
|  |  | ||||||
|  |             def linear_interpolate(x, x_points, y_points): | ||||||
|  |                 if x <= x_points[0]: return y_points[0] | ||||||
|  |                 if x >= x_points[-1]: return y_points[-1] | ||||||
|  |                 for i in range(len(x_points) - 1): | ||||||
|  |                     if x_points[i] <= x < x_points[i + 1]: | ||||||
|  |                         t = (x - x_points[i]) / (x_points[i + 1] - x_points[i]) | ||||||
|  |                         return y_points[i] * (1 - t) + y_points[i + 1] * t | ||||||
|  |  | ||||||
|  |             def scale_to_data_points(func, data_points): | ||||||
|  |                 data_points.sort(key=lambda x: x[0]) | ||||||
|  |                 speeds, values = zip(*data_points) | ||||||
|  |                 scaling_factors = [v / func(s) for s, v in zip(speeds, values)] | ||||||
|  |                 return lambda x: func(x) * linear_interpolate(x, speeds, scaling_factors) | ||||||
|  |  | ||||||
|  |             def speed_adjust(speed_percentage): | ||||||
|  |                 if not 30 <= speed_percentage <= 180: | ||||||
|  |                     speed_percentage = 100 | ||||||
|  |  | ||||||
|  |                 bambu_params = { | ||||||
|  |                     "speed": [50, 100, 124, 166], | ||||||
|  |                     "acceleration": [0.3, 1.0, 1.4, 1.6], | ||||||
|  |                     "feed_rate": [0.7, 1.0, 1.4, 2.0] | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 acc_mag_scaled = scale_to_data_points(acceleration_magnitude, | ||||||
|  |                                                       list(zip(bambu_params["speed"], bambu_params["acceleration"]))) | ||||||
|  |                 feed_rate_scaled = scale_to_data_points(feed_rate, | ||||||
|  |                                                         list(zip(bambu_params["speed"], bambu_params["feed_rate"]))) | ||||||
|  |  | ||||||
|  |                 speed_frac = speed_fraction(speed_percentage) | ||||||
|  |                 acc_mag = acc_mag_scaled(speed_percentage) | ||||||
|  |                 feed = feed_rate_scaled(speed_percentage) | ||||||
|  |                 # speed_level = 1.539 * (acc_mag**2) - 0.7032 * acc_mag + 4.0834 | ||||||
|  |                 return f"M204.2 K{acc_mag:.2f}\nM220 K{feed:.2f}\nM73.2 R{speed_frac:.2f}\n" # M1002 set_gcode_claim_speed_level ${speed_level:.0f}\n | ||||||
|  |  | ||||||
|  |             speed_command = speed_adjust(percent) | ||||||
|  |  | ||||||
|  |             gcode_command["print"]["param"] = speed_command | ||||||
|  |             if self.bambu_client.publish(gcode_command): | ||||||
|  |                 self._log.info(f"{percent}% speed adjustment command sent successfully") | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def _process_gcode_serial_command(self, gcode: str, full_command: str): | ||||||
|  |         self._log.debug(f"processing gcode {gcode} command = {full_command}") | ||||||
|  |         handled = self.gcode_executor.execute(self, gcode, full_command) | ||||||
|  |         if handled: | ||||||
|  |             self.sendOk() | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # post gcode to printer otherwise | ||||||
|  |         if self.bambu_client.connected: | ||||||
|  |             GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE | ||||||
|  |             GCODE_COMMAND["print"]["param"] = full_command + "\n" | ||||||
|  |             if self.bambu_client.publish(GCODE_COMMAND): | ||||||
|  |                 self._log.info("command sent successfully") | ||||||
|  |                 self.sendOk() | ||||||
|  |  | ||||||
|  |     @gcode_executor.register_no_data("M112") | ||||||
|  |     def _shutdown(self): | ||||||
|  |         self._running = True | ||||||
|  |         if self.bambu_client.connected: | ||||||
|  |             self.bambu_client.disconnect() | ||||||
|  |         self.sendIO("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") | ||||||
|  |         self._serial_io.close() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M20") | ||||||
|  |     def _update_project_file_list(self, data: str = ""): | ||||||
|  |         self._project_files_view.update()  # internally sends list to serial io | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def _list_cached_project_files(self): | ||||||
|  |         self.sendIO("Begin file list") | ||||||
|  |         for item in map( | ||||||
|  |             FileInfo.get_gcode_info, self._project_files_view.get_all_cached_info() | ||||||
|  |         ): | ||||||
|  |             self.sendIO(item) | ||||||
|  |         self.sendIO("End file list") | ||||||
|  |         self.sendOk() | ||||||
|  |  | ||||||
|  |     @gcode_executor.register_no_data("M24") | ||||||
|  |     def _start_resume_sd_print(self): | ||||||
|  |         self._current_state.start_new_print() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register_no_data("M25") | ||||||
|  |     def _pause_print(self): | ||||||
|  |         self._current_state.pause_print() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M524") | ||||||
|  |     def _cancel_print(self): | ||||||
|  |         self._current_state.cancel_print() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def report_print_job_status(self): | ||||||
|  |         if self.current_print_job is not None: | ||||||
|  |             file_position = 1 if self.current_print_job.file_position == 0 else self.current_print_job.file_position | ||||||
|  |             self.sendIO( | ||||||
|  |                 f"SD printing byte {file_position}" | ||||||
|  |                 f"/{self.current_print_job.file_info.size}" | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             self.sendIO("Not SD printing") | ||||||
|  |  | ||||||
|  |     def report_print_finished(self): | ||||||
|  |         if self.current_print_job is None: | ||||||
|  |             return | ||||||
|  |         self._log.debug( | ||||||
|  |             f"SD File Print finishing: {self.current_print_job.file_info.file_name}" | ||||||
|  |         ) | ||||||
|  |         self.sendIO("Done printing file") | ||||||
|  |  | ||||||
|  |     def finalize_print_job(self): | ||||||
|  |         if self.current_print_job is not None: | ||||||
|  |             self.report_print_job_status() | ||||||
|  |             self.report_print_finished() | ||||||
|  |             self.current_print_job = None | ||||||
|  |             self.report_print_job_status() | ||||||
|  |         self.change_state(self._state_idle) | ||||||
|  |  | ||||||
|  |     def _create_temperature_message(self) -> str: | ||||||
|  |         template = "{heater}:{actual:.2f}/ {target:.2f}" | ||||||
|  |         temps = collections.OrderedDict() | ||||||
|  |         temps["T"] = (self._telemetry.temp[0], self._telemetry.targetTemp[0]) | ||||||
|  |         temps["B"] = (self._telemetry.bedTemp, self._telemetry.bedTargetTemp) | ||||||
|  |         if self._telemetry.hasChamber: | ||||||
|  |             temps["C"] = ( | ||||||
|  |                 self._telemetry.chamberTemp, | ||||||
|  |                 self._telemetry.chamberTargetTemp, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         output = " ".join( | ||||||
|  |             map( | ||||||
|  |                 lambda x: template.format(heater=x[0], actual=x[1][0], target=x[1][1]), | ||||||
|  |                 temps.items(), | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         output += " @:64\n" | ||||||
|  |         return output | ||||||
|  |  | ||||||
|  |     def _processTemperatureQuery(self) -> bool: | ||||||
|  |         # includeOk = not self._okBeforeCommandOutput | ||||||
|  |         if self.bambu_client.connected: | ||||||
|  |             output = self._create_temperature_message() | ||||||
|  |             self.sendIO(output) | ||||||
|  |             return True | ||||||
|  |         else: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |     def close(self): | ||||||
|  |         if self.bambu_client.connected: | ||||||
|  |             self.bambu_client.disconnect() | ||||||
|  |         self.change_state(self._state_idle) | ||||||
|  |         self._serial_io.close() | ||||||
|  |         self.stop() | ||||||
|  |  | ||||||
|  |     def stop(self): | ||||||
|  |         self._running = False | ||||||
|  |         self._printer_thread.join() | ||||||
|  |  | ||||||
|  |     def _wait_for_state_change(self): | ||||||
|  |         self._state_change_queue.join() | ||||||
|  |  | ||||||
|  |     def _printer_worker(self): | ||||||
|  |         self._create_client_connection_async() | ||||||
|  |         self.sendIO("Printer connection complete") | ||||||
|  |         while self._running: | ||||||
|  |             try: | ||||||
|  |                 next_state = self._state_change_queue.get(timeout=0.01) | ||||||
|  |                 self._trigger_change_state(next_state) | ||||||
|  |                 self._state_change_queue.task_done() | ||||||
|  |             except queue.Empty: | ||||||
|  |                 continue | ||||||
|  |             except Exception as e: | ||||||
|  |                 self._state_change_queue.task_done() | ||||||
|  |                 raise e | ||||||
|  |         self._current_state.finalize() | ||||||
|  |  | ||||||
|  |     def _trigger_change_state(self, new_state: APrinterState): | ||||||
|  |         if self._current_state == new_state: | ||||||
|  |             return | ||||||
|  |         self._log.debug( | ||||||
|  |             f"Changing state from {self._current_state.__class__.__name__} to {new_state.__class__.__name__}" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self._current_state.finalize() | ||||||
|  |         self._current_state = new_state | ||||||
|  |         self._current_state.init() | ||||||
|  |  | ||||||
|  |     def _showPrompt(self, text, choices): | ||||||
|  |         self._hidePrompt() | ||||||
|  |         self.sendIO(f"//action:prompt_begin {text}") | ||||||
|  |         for choice in choices: | ||||||
|  |             self.sendIO(f"//action:prompt_button {choice}") | ||||||
|  |         self.sendIO("//action:prompt_show") | ||||||
|  |  | ||||||
|  |     def _hidePrompt(self): | ||||||
|  |         self.sendIO("//action:prompt_end") | ||||||
| @@ -0,0 +1,34 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from dataclasses import asdict, dataclass | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from .file_info import FileInfo | ||||||
|  |  | ||||||
|  | from octoprint.util import get_formatted_size, get_formatted_datetime | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass(frozen=True) | ||||||
|  | class BambuTimelapseFileInfo: | ||||||
|  |     bytes: int | ||||||
|  |     date: str | None | ||||||
|  |     name: str | ||||||
|  |     size: str | ||||||
|  |     thumbnail: str | ||||||
|  |     timestamp: float | ||||||
|  |     url: str | ||||||
|  |  | ||||||
|  |     def to_dict(self): | ||||||
|  |         return asdict(self) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def from_file_info(file_info: FileInfo): | ||||||
|  |         return BambuTimelapseFileInfo( | ||||||
|  |             bytes=file_info.size, | ||||||
|  |             date=get_formatted_datetime(file_info.date), | ||||||
|  |             name=file_info.file_name, | ||||||
|  |             size=get_formatted_size(file_info.size), | ||||||
|  |             thumbnail=f"/plugin/bambu_printer/thumbnail/{file_info.path.stem}.jpg", | ||||||
|  |             timestamp=file_info.timestamp, | ||||||
|  |             url=f"/plugin/bambu_printer/timelapse/{file_info.file_name}", | ||||||
|  |         ) | ||||||
| @@ -0,0 +1,94 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import TYPE_CHECKING, Callable | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||||
|  |         RemoteSDCardFileList, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | from dataclasses import dataclass, field | ||||||
|  | from pathlib import Path | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class CachedFileView: | ||||||
|  |     file_system: RemoteSDCardFileList | ||||||
|  |     folder_view: dict[tuple[str, str | list[str] | None], None] = field( | ||||||
|  |         default_factory=dict | ||||||
|  |     )  # dict preserves order, but set does not. We use only dict keys as storage | ||||||
|  |     on_update: Callable[[], None] | None = None | ||||||
|  |  | ||||||
|  |     def __post_init__(self): | ||||||
|  |         self._file_alias_cache: dict[str, str] = {} | ||||||
|  |         self._file_data_cache: dict[str, FileInfo] = {} | ||||||
|  |  | ||||||
|  |     def with_filter( | ||||||
|  |         self, folder: str, extensions: str | list[str] | None = None | ||||||
|  |     ) -> "CachedFileView": | ||||||
|  |         self.folder_view[(folder, extensions)] = None | ||||||
|  |         return self | ||||||
|  |  | ||||||
|  |     def list_all_views(self): | ||||||
|  |         existing_files: list[str] = [] | ||||||
|  |         result: list[FileInfo] = [] | ||||||
|  |  | ||||||
|  |         with self.file_system.get_ftps_client() as ftp: | ||||||
|  |             for filter in self.folder_view.keys(): | ||||||
|  |                 result.extend(self.file_system.list_files(*filter, ftp, existing_files)) | ||||||
|  |         return result | ||||||
|  |  | ||||||
|  |     def update(self): | ||||||
|  |         file_info_list = self.list_all_views() | ||||||
|  |         self._update_file_list_cache(file_info_list) | ||||||
|  |         if self.on_update: | ||||||
|  |             self.on_update() | ||||||
|  |  | ||||||
|  |     def _update_file_list_cache(self, files: list[FileInfo]): | ||||||
|  |         self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files} | ||||||
|  |         self._file_data_cache = {info.path.as_posix(): info for info in files} | ||||||
|  |  | ||||||
|  |     def get_all_info(self): | ||||||
|  |         self.update() | ||||||
|  |         return self.get_all_cached_info() | ||||||
|  |  | ||||||
|  |     def get_all_cached_info(self): | ||||||
|  |         return list(self._file_data_cache.values()) | ||||||
|  |  | ||||||
|  |     def get_file_data(self, file_path: str | Path) -> FileInfo | None: | ||||||
|  |         file_data = self.get_file_data_cached(file_path) | ||||||
|  |         if file_data is None: | ||||||
|  |             self.update() | ||||||
|  |             file_data = self.get_file_data_cached(file_path) | ||||||
|  |         return file_data | ||||||
|  |  | ||||||
|  |     def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None: | ||||||
|  |         if isinstance(file_path, str): | ||||||
|  |             file_path = Path(file_path).as_posix().strip("/") | ||||||
|  |         else: | ||||||
|  |             file_path = file_path.as_posix().strip("/") | ||||||
|  |  | ||||||
|  |         if file_path not in self._file_data_cache: | ||||||
|  |             file_path = self._file_alias_cache.get(file_path, file_path) | ||||||
|  |         return self._file_data_cache.get(file_path, None) | ||||||
|  |  | ||||||
|  |     def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]): | ||||||
|  |         if file_stem == "": | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         file_stem = Path(file_stem).with_suffix("").stem | ||||||
|  |         file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) | ||||||
|  |         if file_data is None: | ||||||
|  |             self.update() | ||||||
|  |             file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) | ||||||
|  |         return file_data | ||||||
|  |  | ||||||
|  |     def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]): | ||||||
|  |         for file_path_str in list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys()): | ||||||
|  |             file_path = Path(file_path_str) | ||||||
|  |             if file_stem == file_path.with_suffix("").stem and all( | ||||||
|  |                 suffix in allowed_suffixes for suffix in file_path.suffixes | ||||||
|  |             ): | ||||||
|  |                 return self.get_file_data_cached(file_path) | ||||||
|  |         return None | ||||||
							
								
								
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from dataclasses import asdict, dataclass | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from octoprint.util.files import unix_timestamp_to_m20_timestamp | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass(frozen=True) | ||||||
|  | class FileInfo: | ||||||
|  |     dosname: str | ||||||
|  |     path: Path | ||||||
|  |     size: int | ||||||
|  |     date: datetime | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_name(self): | ||||||
|  |         return self.path.name | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def timestamp(self) -> float: | ||||||
|  |         return self.date.timestamp() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def timestamp_m20(self) -> str: | ||||||
|  |         return unix_timestamp_to_m20_timestamp(int(self.timestamp)) | ||||||
|  |  | ||||||
|  |     def get_gcode_info(self) -> str: | ||||||
|  |         return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"' | ||||||
|  |  | ||||||
|  |     def to_dict(self): | ||||||
|  |         return asdict(self) | ||||||
| @@ -24,16 +24,21 @@ SOFTWARE. | |||||||
| wrapper for FTPS server interactions | wrapper for FTPS server interactions | ||||||
| """ | """ | ||||||
| 
 | 
 | ||||||
|  | from __future__ import annotations | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from datetime import datetime, timezone | ||||||
| import ftplib | import ftplib | ||||||
| import os | import os | ||||||
|  | from pathlib import Path | ||||||
| import socket | import socket | ||||||
| import ssl | import ssl | ||||||
| from typing import Optional, Union, List | from typing import Generator, Union | ||||||
| 
 | 
 | ||||||
| from contextlib import redirect_stdout | from contextlib import redirect_stdout | ||||||
| import io | import io | ||||||
| import re | import re | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| class ImplicitTLS(ftplib.FTP_TLS): | class ImplicitTLS(ftplib.FTP_TLS): | ||||||
|     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" |     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" | ||||||
| 
 | 
 | ||||||
| @@ -57,67 +62,20 @@ class ImplicitTLS(ftplib.FTP_TLS): | |||||||
|         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) |         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) | ||||||
| 
 | 
 | ||||||
|         if self._prot_p: |         if self._prot_p: | ||||||
|             conn = self.context.wrap_socket(conn, |             conn = self.context.wrap_socket( | ||||||
|                                             server_hostname=self.host, |                 conn, server_hostname=self.host, session=self.sock.session | ||||||
|                                             session=self.sock.session)  # this is the fix |             )  # this is the fix | ||||||
|         return conn, size |         return conn, size | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class IoTFTPSClient: | @dataclass | ||||||
|  | class IoTFTPSConnection: | ||||||
|     """iot ftps ftpsclient""" |     """iot ftps ftpsclient""" | ||||||
| 
 | 
 | ||||||
|     ftps_host: str |     ftps_session: ftplib.FTP | ImplicitTLS | ||||||
|     ftps_port: int |  | ||||||
|     ftps_user: str |  | ||||||
|     ftps_pass: str |  | ||||||
|     ssl_implicit: bool |  | ||||||
|     ftps_session: Union[ftplib.FTP, ImplicitTLS] |  | ||||||
|     last_error: Optional[str] = None |  | ||||||
|     welcome: str |  | ||||||
| 
 | 
 | ||||||
|     def __init__( |     def close(self) -> None: | ||||||
|             self, |         """close the current session from the ftps server""" | ||||||
|             ftps_host: str, |  | ||||||
|             ftps_port: Optional[int] = 21, |  | ||||||
|             ftps_user: Optional[str] = "", |  | ||||||
|             ftps_pass: Optional[str] = "", |  | ||||||
|             ssl_implicit: Optional[bool] = False, |  | ||||||
|     ) -> None: |  | ||||||
|         self.ftps_host = ftps_host |  | ||||||
|         self.ftps_port = ftps_port |  | ||||||
|         self.ftps_user = ftps_user |  | ||||||
|         self.ftps_pass = ftps_pass |  | ||||||
|         self.ssl_implicit = ssl_implicit |  | ||||||
|         self.instantiate_ftps_session() |  | ||||||
| 
 |  | ||||||
|     def __repr__(self) -> str: |  | ||||||
|         return ( |  | ||||||
|             "IoT FTPS Client\n" |  | ||||||
|             "--------------------\n" |  | ||||||
|             f"host: {self.ftps_host}\n" |  | ||||||
|             f"port: {self.ftps_port}\n" |  | ||||||
|             f"user: {self.ftps_user}\n" |  | ||||||
|             f"ssl: {self.ssl_implicit}" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     def instantiate_ftps_session(self) -> None: |  | ||||||
|         """init ftps_session based on input params""" |  | ||||||
|         self.ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() |  | ||||||
|         self.ftps_session.set_debuglevel(0) |  | ||||||
| 
 |  | ||||||
|         self.welcome = self.ftps_session.connect( |  | ||||||
|             host=self.ftps_host, port=self.ftps_port) |  | ||||||
| 
 |  | ||||||
|         if self.ftps_user and self.ftps_pass: |  | ||||||
|             self.ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) |  | ||||||
|         else: |  | ||||||
|             self.ftps_session.login() |  | ||||||
| 
 |  | ||||||
|         if self.ssl_implicit: |  | ||||||
|             self.ftps_session.prot_p() |  | ||||||
| 
 |  | ||||||
|     def disconnect(self) -> None: |  | ||||||
|         """disconnect the current session from the ftps server""" |  | ||||||
|         self.ftps_session.close() |         self.ftps_session.close() | ||||||
| 
 | 
 | ||||||
|     def download_file(self, source: str, dest: str): |     def download_file(self, source: str, dest: str): | ||||||
| @@ -137,7 +95,7 @@ class IoTFTPSClient: | |||||||
|             # Taken from ftplib.storbinary but with custom ssl handling |             # Taken from ftplib.storbinary but with custom ssl handling | ||||||
|             # due to the shitty bambu p1p ftps server TODO fix properly. |             # due to the shitty bambu p1p ftps server TODO fix properly. | ||||||
|             with open(source, "rb") as fp: |             with open(source, "rb") as fp: | ||||||
|                 self.ftps_session.voidcmd('TYPE I') |                 self.ftps_session.voidcmd("TYPE I") | ||||||
| 
 | 
 | ||||||
|                 with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: |                 with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: | ||||||
|                     while 1: |                     while 1: | ||||||
| @@ -152,12 +110,14 @@ class IoTFTPSClient: | |||||||
|                             callback(buf) |                             callback(buf) | ||||||
| 
 | 
 | ||||||
|                     # shutdown ssl layer |                     # shutdown ssl layer | ||||||
|                     if ftplib._SSLSocket is not None and isinstance(conn, ftplib._SSLSocket): |                     if ftplib._SSLSocket is not None and isinstance( | ||||||
|  |                         conn, ftplib._SSLSocket | ||||||
|  |                     ): | ||||||
|                         # Yeah this is suposed to be conn.unwrap |                         # Yeah this is suposed to be conn.unwrap | ||||||
|                         # But since we operate in prot p mode |                         # But since we operate in prot p mode | ||||||
|                         # we can close the connection always. |                         # we can close the connection always. | ||||||
|                         # This is cursed but it works. |                         # This is cursed but it works. | ||||||
|                         if "vsFTPd" in self.welcome: |                         if "vsFTPd" in self.ftps_session.welcome: | ||||||
|                             conn.unwrap() |                             conn.unwrap() | ||||||
|                         else: |                         else: | ||||||
|                             conn.shutdown(socket.SHUT_RDWR) |                             conn.shutdown(socket.SHUT_RDWR) | ||||||
| @@ -185,19 +145,26 @@ class IoTFTPSClient: | |||||||
|     def mkdir(self, path: str) -> str: |     def mkdir(self, path: str) -> str: | ||||||
|         return self.ftps_session.mkd(path) |         return self.ftps_session.mkd(path) | ||||||
| 
 | 
 | ||||||
|     def list_files(self, path: str, file_pattern: Optional[str] = None) -> Union[List[str], None]: |     def list_files( | ||||||
|  |         self, list_path: str, extensions: str | list[str] | None = None | ||||||
|  |     ) -> Generator[Path]: | ||||||
|         """list files under a path inside the FTPS server""" |         """list files under a path inside the FTPS server""" | ||||||
|  | 
 | ||||||
|  |         if extensions is None: | ||||||
|  |             _extension_acceptable = lambda p: True | ||||||
|  |         else: | ||||||
|  |             if isinstance(extensions, str): | ||||||
|  |                 extensions = [extensions] | ||||||
|  |             _extension_acceptable = lambda p: any(s in p.suffixes for s in extensions) | ||||||
|  | 
 | ||||||
|         try: |         try: | ||||||
|             files = self.ftps_session.nlst(path) |             list_result = self.ftps_session.nlst(list_path) or [] | ||||||
|             if not files: |             for file_list_entry in list_result: | ||||||
|                 return |                 path = Path(list_path) / Path(file_list_entry).name | ||||||
|             if file_pattern: |                 if _extension_acceptable(path): | ||||||
|                 return [f for f in files if file_pattern in f] |                     yield path | ||||||
|             return files |  | ||||||
|         except Exception as ex: |         except Exception as ex: | ||||||
|             print(f"unexpected exception occurred: {ex}") |             print(f"unexpected exception occurred: {ex}") | ||||||
|             pass |  | ||||||
|         return |  | ||||||
| 
 | 
 | ||||||
|     def list_files_ex(self, path: str) -> Union[list[str], None]: |     def list_files_ex(self, path: str) -> Union[list[str], None]: | ||||||
|         """list files under a path inside the FTPS server""" |         """list files under a path inside the FTPS server""" | ||||||
| @@ -208,7 +175,8 @@ class IoTFTPSClient: | |||||||
|             s = f.getvalue() |             s = f.getvalue() | ||||||
|             files = [] |             files = [] | ||||||
|             for row in s.split("\n"): |             for row in s.split("\n"): | ||||||
|                 if len(row) <= 0: continue |                 if len(row) <= 0: | ||||||
|  |                     continue | ||||||
| 
 | 
 | ||||||
|                 attribs = row.split(" ") |                 attribs = row.split(" ") | ||||||
| 
 | 
 | ||||||
| @@ -219,10 +187,70 @@ class IoTFTPSClient: | |||||||
|                 else: |                 else: | ||||||
|                     name = attribs[len(attribs) - 1] |                     name = attribs[len(attribs) - 1] | ||||||
| 
 | 
 | ||||||
|                 file = ( attribs[0], name ) |                 file = (attribs[0], name) | ||||||
|                 files.append(file) |                 files.append(file) | ||||||
|             return files |             return files | ||||||
|         except Exception as ex: |         except Exception as ex: | ||||||
|             print(f"unexpected exception occurred: [{ex}]") |             print(f"unexpected exception occurred: [{ex}]") | ||||||
|             pass |             pass | ||||||
|         return |         return | ||||||
|  | 
 | ||||||
|  |     def get_file_size(self, file_path: str): | ||||||
|  |         try: | ||||||
|  |             return self.ftps_session.size(file_path) | ||||||
|  |         except Exception as e: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'Cannot get file size for "{file_path}" due to error: {str(e)}' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     def get_file_date(self, file_path: str) -> datetime: | ||||||
|  |         try: | ||||||
|  |             date_response = self.ftps_session.sendcmd(f"MDTM {file_path}").replace( | ||||||
|  |                 "213 ", "" | ||||||
|  |             ) | ||||||
|  |             date = datetime.strptime(date_response, "%Y%m%d%H%M%S").replace( | ||||||
|  |                 tzinfo=timezone.utc | ||||||
|  |             ) | ||||||
|  |             return date | ||||||
|  |         except Exception as e: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'Cannot get file date for "{file_path}" due to error: {str(e)}' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass | ||||||
|  | class IoTFTPSClient: | ||||||
|  |     ftps_host: str | ||||||
|  |     ftps_port: int = 21 | ||||||
|  |     ftps_user: str = "" | ||||||
|  |     ftps_pass: str = "" | ||||||
|  |     ssl_implicit: bool = False | ||||||
|  |     welcome: str = "" | ||||||
|  |     _connection: IoTFTPSConnection | None = None | ||||||
|  | 
 | ||||||
|  |     def __enter__(self): | ||||||
|  |         session = self.open_ftps_session() | ||||||
|  |         self._connection = IoTFTPSConnection(session) | ||||||
|  |         return self._connection | ||||||
|  | 
 | ||||||
|  |     def __exit__(self, type, value, traceback): | ||||||
|  |         if self._connection is not None: | ||||||
|  |             self._connection.close() | ||||||
|  |             self._connection = None | ||||||
|  | 
 | ||||||
|  |     def open_ftps_session(self) -> ftplib.FTP | ImplicitTLS: | ||||||
|  |         """init ftps_session based on input params""" | ||||||
|  |         ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() | ||||||
|  |         ftps_session.set_debuglevel(0) | ||||||
|  | 
 | ||||||
|  |         self.welcome = ftps_session.connect(host=self.ftps_host, port=self.ftps_port) | ||||||
|  | 
 | ||||||
|  |         if self.ftps_user and self.ftps_pass: | ||||||
|  |             ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) | ||||||
|  |         else: | ||||||
|  |             ftps_session.login() | ||||||
|  | 
 | ||||||
|  |         if self.ssl_implicit: | ||||||
|  |             ftps_session.prot_p() | ||||||
|  | 
 | ||||||
|  |         return ftps_session | ||||||
| @@ -0,0 +1,87 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import datetime | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Iterable, Iterator | ||||||
|  | import logging.handlers | ||||||
|  |  | ||||||
|  | from octoprint.util import get_dos_filename | ||||||
|  |  | ||||||
|  | from .ftps_client import IoTFTPSClient, IoTFTPSConnection | ||||||
|  | from .file_info import FileInfo | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RemoteSDCardFileList: | ||||||
|  |  | ||||||
|  |     def __init__(self, settings) -> None: | ||||||
|  |         self._settings = settings | ||||||
|  |         self._selected_project_file: FileInfo | None = None | ||||||
|  |         self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") | ||||||
|  |  | ||||||
|  |     def delete_file(self, file_path: Path) -> None: | ||||||
|  |         try: | ||||||
|  |             with self.get_ftps_client() as ftp: | ||||||
|  |                 if ftp.delete_file(file_path.as_posix()): | ||||||
|  |                     self._logger.debug(f"{file_path} deleted") | ||||||
|  |                 else: | ||||||
|  |                     raise RuntimeError(f"Deleting file {file_path} failed") | ||||||
|  |         except Exception as e: | ||||||
|  |             self._logger.exception(e) | ||||||
|  |  | ||||||
|  |     def list_files( | ||||||
|  |         self, | ||||||
|  |         folder: str, | ||||||
|  |         extensions: str | list[str] | None, | ||||||
|  |         ftp: IoTFTPSConnection, | ||||||
|  |         existing_files=None, | ||||||
|  |     ): | ||||||
|  |         if existing_files is None: | ||||||
|  |             existing_files = [] | ||||||
|  |  | ||||||
|  |         return list( | ||||||
|  |             self.get_file_info_for_names( | ||||||
|  |                 ftp, ftp.list_files(folder, extensions), existing_files | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def _get_ftp_file_info( | ||||||
|  |         self, | ||||||
|  |         ftp: IoTFTPSConnection, | ||||||
|  |         file_path: Path, | ||||||
|  |         existing_files: list[str] | None = None, | ||||||
|  |     ): | ||||||
|  |         file_size = ftp.get_file_size(file_path.as_posix()) | ||||||
|  |         date = ftp.get_file_date(file_path.as_posix()) | ||||||
|  |         file_name = file_path.name.lower() | ||||||
|  |         dosname = get_dos_filename(file_name, existing_filenames=existing_files).lower() | ||||||
|  |         return FileInfo( | ||||||
|  |             dosname, | ||||||
|  |             file_path, | ||||||
|  |             file_size if file_size is not None else 0, | ||||||
|  |             date, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def get_file_info_for_names( | ||||||
|  |         self, | ||||||
|  |         ftp: IoTFTPSConnection, | ||||||
|  |         files: Iterable[Path], | ||||||
|  |         existing_files: list[str] | None = None, | ||||||
|  |     ) -> Iterator[FileInfo]: | ||||||
|  |         if existing_files is None: | ||||||
|  |             existing_files = [] | ||||||
|  |  | ||||||
|  |         for entry in files: | ||||||
|  |             try: | ||||||
|  |                 file_info = self._get_ftp_file_info(ftp, entry, existing_files) | ||||||
|  |                 yield file_info | ||||||
|  |                 existing_files.append(file_info.file_name) | ||||||
|  |                 existing_files.append(file_info.dosname) | ||||||
|  |             except Exception as e: | ||||||
|  |                 self._logger.exception(e, exc_info=False) | ||||||
|  |  | ||||||
|  |     def get_ftps_client(self): | ||||||
|  |         host = self._settings.get(["host"]) | ||||||
|  |         access_code = self._settings.get(["access_code"]) | ||||||
|  |         return IoTFTPSClient( | ||||||
|  |             f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True | ||||||
|  |         ) | ||||||
							
								
								
									
										319
									
								
								octoprint_bambu_printer/printer/gcode_executor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								octoprint_bambu_printer/printer/gcode_executor.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | |||||||
|  | import itertools | ||||||
|  | import logging | ||||||
|  | from inspect import signature | ||||||
|  | import traceback | ||||||
|  |  | ||||||
|  |  | ||||||
|  | GCODE_DOCUMENTATION = { | ||||||
|  |     "G0": "Linear Move", | ||||||
|  |     "G1": "Linear Move", | ||||||
|  |     "G2": "Arc or Circle Move", | ||||||
|  |     "G3": "Arc or Circle Move", | ||||||
|  |     "G4": "Dwell", | ||||||
|  |     "G5": "Bézier cubic spline", | ||||||
|  |     "G6": "Direct Stepper Move", | ||||||
|  |     "G10": "Retract", | ||||||
|  |     "G11": "Recover", | ||||||
|  |     "G12": "Clean the Nozzle", | ||||||
|  |     "G17": "CNC Workspace Planes", | ||||||
|  |     "G18": "CNC Workspace Planes", | ||||||
|  |     "G19": "CNC Workspace Planes", | ||||||
|  |     "G20": "Inch Units", | ||||||
|  |     "G21": "Millimeter Units", | ||||||
|  |     "G26": "Mesh Validation Pattern", | ||||||
|  |     "G27": "Park toolhead", | ||||||
|  |     "G28": "Auto Home", | ||||||
|  |     "G29": "Bed Leveling", | ||||||
|  |     "G29": "Bed Leveling (3-Point)", | ||||||
|  |     "G29": "Bed Leveling (Linear)", | ||||||
|  |     "G29": "Bed Leveling (Manual)", | ||||||
|  |     "G29": "Bed Leveling (Bilinear)", | ||||||
|  |     "G29": "Bed Leveling (Unified)", | ||||||
|  |     "G30": "Single Z-Probe", | ||||||
|  |     "G31": "Dock Sled", | ||||||
|  |     "G32": "Undock Sled", | ||||||
|  |     "G33": "Delta Auto Calibration", | ||||||
|  |     "G34": "Z Steppers Auto-Alignment", | ||||||
|  |     "G34": "Mechanical Gantry Calibration", | ||||||
|  |     "G35": "Tramming Assistant", | ||||||
|  |     "G38.2": "Probe target", | ||||||
|  |     "G38.3": "Probe target", | ||||||
|  |     "G38.4": "Probe target", | ||||||
|  |     "G38.5": "Probe target", | ||||||
|  |     "G42": "Move to mesh coordinate", | ||||||
|  |     "G53": "Move in Machine Coordinates", | ||||||
|  |     "G60": "Save Current Position", | ||||||
|  |     "G61": "Return to Saved Position", | ||||||
|  |     "G76": "Probe temperature calibration", | ||||||
|  |     "G80": "Cancel Current Motion Mode", | ||||||
|  |     "G90": "Absolute Positioning", | ||||||
|  |     "G91": "Relative Positioning", | ||||||
|  |     "G92": "Set Position", | ||||||
|  |     "G425": "Backlash Calibration", | ||||||
|  |     "M0": "Unconditional stop", | ||||||
|  |     "M1": "Unconditional stop", | ||||||
|  |     "M3": "Spindle CW / Laser On", | ||||||
|  |     "M4": "Spindle CCW / Laser On", | ||||||
|  |     "M5": "Spindle / Laser Off", | ||||||
|  |     "M7": "Coolant Controls", | ||||||
|  |     "M8": "Coolant Controls", | ||||||
|  |     "M9": "Coolant Controls", | ||||||
|  |     "M10": "Vacuum / Blower Control", | ||||||
|  |     "M11": "Vacuum / Blower Control", | ||||||
|  |     "M16": "Expected Printer Check", | ||||||
|  |     "M17": "Enable Steppers", | ||||||
|  |     "M18": "Disable steppers", | ||||||
|  |     "M84": "Disable steppers", | ||||||
|  |     "M20": "List SD Card", | ||||||
|  |     "M21": "Init SD card", | ||||||
|  |     "M22": "Release SD card", | ||||||
|  |     "M23": "Select SD file", | ||||||
|  |     "M24": "Start or Resume SD print", | ||||||
|  |     "M25": "Pause SD print", | ||||||
|  |     "M26": "Set SD position", | ||||||
|  |     "M27": "Report SD print status", | ||||||
|  |     "M28": "Start SD write", | ||||||
|  |     "M29": "Stop SD write", | ||||||
|  |     "M30": "Delete SD file", | ||||||
|  |     "M31": "Print time", | ||||||
|  |     "M32": "Select and Start", | ||||||
|  |     "M33": "Get Long Path", | ||||||
|  |     "M34": "SDCard Sorting", | ||||||
|  |     "M42": "Set Pin State", | ||||||
|  |     "M43": "Debug Pins", | ||||||
|  |     "M48": "Probe Repeatability Test", | ||||||
|  |     "M73": "Set Print Progress", | ||||||
|  |     "M75": "Start Print Job Timer", | ||||||
|  |     "M76": "Pause Print Job Timer", | ||||||
|  |     "M77": "Stop Print Job Timer", | ||||||
|  |     "M78": "Print Job Stats", | ||||||
|  |     "M80": "Power On", | ||||||
|  |     "M81": "Power Off", | ||||||
|  |     "M82": "E Absolute", | ||||||
|  |     "M83": "E Relative", | ||||||
|  |     "M85": "Inactivity Shutdown", | ||||||
|  |     "M86": "Hotend Idle Timeout", | ||||||
|  |     "M87": "Disable Hotend Idle Timeout", | ||||||
|  |     "M92": "Set Axis Steps-per-unit", | ||||||
|  |     "M100": "Free Memory", | ||||||
|  |     "M102": "Configure Bed Distance Sensor", | ||||||
|  |     "M104": "Set Hotend Temperature", | ||||||
|  |     "M105": "Report Temperatures", | ||||||
|  |     "M106": "Set Fan Speed", | ||||||
|  |     "M107": "Fan Off", | ||||||
|  |     "M108": "Break and Continue", | ||||||
|  |     "M109": "Wait for Hotend Temperature", | ||||||
|  |     "M110": "Set / Get Line Number", | ||||||
|  |     "M111": "Debug Level", | ||||||
|  |     "M112": "Full Shutdown", | ||||||
|  |     "M113": "Host Keepalive", | ||||||
|  |     "M114": "Get Current Position", | ||||||
|  |     "M115": "Firmware Info", | ||||||
|  |     "M117": "Set LCD Message", | ||||||
|  |     "M118": "Serial print", | ||||||
|  |     "M119": "Endstop States", | ||||||
|  |     "M120": "Enable Endstops", | ||||||
|  |     "M121": "Disable Endstops", | ||||||
|  |     "M122": "TMC Debugging", | ||||||
|  |     "M123": "Fan Tachometers", | ||||||
|  |     "M125": "Park Head", | ||||||
|  |     "M126": "Baricuda 1 Open", | ||||||
|  |     "M127": "Baricuda 1 Close", | ||||||
|  |     "M128": "Baricuda 2 Open", | ||||||
|  |     "M129": "Baricuda 2 Close", | ||||||
|  |     "M140": "Set Bed Temperature", | ||||||
|  |     "M141": "Set Chamber Temperature", | ||||||
|  |     "M143": "Set Laser Cooler Temperature", | ||||||
|  |     "M145": "Set Material Preset", | ||||||
|  |     "M149": "Set Temperature Units", | ||||||
|  |     "M150": "Set RGB(W) Color", | ||||||
|  |     "M154": "Position Auto-Report", | ||||||
|  |     "M155": "Temperature Auto-Report", | ||||||
|  |     "M163": "Set Mix Factor", | ||||||
|  |     "M164": "Save Mix", | ||||||
|  |     "M165": "Set Mix", | ||||||
|  |     "M166": "Gradient Mix", | ||||||
|  |     "M190": "Wait for Bed Temperature", | ||||||
|  |     "M191": "Wait for Chamber Temperature", | ||||||
|  |     "M192": "Wait for Probe temperature", | ||||||
|  |     "M193": "Set Laser Cooler Temperature", | ||||||
|  |     "M200": "Set Filament Diameter", | ||||||
|  |     "M201": "Print / Travel Move Limits", | ||||||
|  |     "M203": "Set Max Feedrate", | ||||||
|  |     "M204": "Set Starting Acceleration", | ||||||
|  |     "M205": "Set Advanced Settings", | ||||||
|  |     "M206": "Set Home Offsets", | ||||||
|  |     "M207": "Set Firmware Retraction", | ||||||
|  |     "M208": "Firmware Recover", | ||||||
|  |     "M209": "Set Auto Retract", | ||||||
|  |     "M211": "Software Endstops", | ||||||
|  |     "M217": "Filament swap parameters", | ||||||
|  |     "M218": "Set Hotend Offset", | ||||||
|  |     "M220": "Set Feedrate Percentage", | ||||||
|  |     "M221": "Set Flow Percentage", | ||||||
|  |     "M226": "Wait for Pin State", | ||||||
|  |     "M240": "Trigger Camera", | ||||||
|  |     "M250": "LCD Contrast", | ||||||
|  |     "M255": "LCD Sleep/Backlight Timeout", | ||||||
|  |     "M256": "LCD Brightness", | ||||||
|  |     "M260": "I2C Send", | ||||||
|  |     "M261": "I2C Request", | ||||||
|  |     "M280": "Servo Position", | ||||||
|  |     "M281": "Edit Servo Angles", | ||||||
|  |     "M282": "Detach Servo", | ||||||
|  |     "M290": "Babystep", | ||||||
|  |     "M300": "Play Tone", | ||||||
|  |     "M301": "Set Hotend PID", | ||||||
|  |     "M302": "Cold Extrude", | ||||||
|  |     "M303": "PID autotune", | ||||||
|  |     "M304": "Set Bed PID", | ||||||
|  |     "M305": "User Thermistor Parameters", | ||||||
|  |     "M306": "Model Predictive Temp. Control", | ||||||
|  |     "M350": "Set micro-stepping", | ||||||
|  |     "M351": "Set Microstep Pins", | ||||||
|  |     "M355": "Case Light Control", | ||||||
|  |     "M360": "SCARA Theta A", | ||||||
|  |     "M361": "SCARA Theta-B", | ||||||
|  |     "M362": "SCARA Psi-A", | ||||||
|  |     "M363": "SCARA Psi-B", | ||||||
|  |     "M364": "SCARA Psi-C", | ||||||
|  |     "M380": "Activate Solenoid", | ||||||
|  |     "M381": "Deactivate Solenoids", | ||||||
|  |     "M400": "Finish Moves", | ||||||
|  |     "M401": "Deploy Probe", | ||||||
|  |     "M402": "Stow Probe", | ||||||
|  |     "M403": "MMU2 Filament Type", | ||||||
|  |     "M404": "Set Filament Diameter", | ||||||
|  |     "M405": "Filament Width Sensor On", | ||||||
|  |     "M406": "Filament Width Sensor Off", | ||||||
|  |     "M407": "Filament Width", | ||||||
|  |     "M410": "Quickstop", | ||||||
|  |     "M412": "Filament Runout", | ||||||
|  |     "M413": "Power-loss Recovery", | ||||||
|  |     "M420": "Bed Leveling State", | ||||||
|  |     "M421": "Set Mesh Value", | ||||||
|  |     "M422": "Set Z Motor XY", | ||||||
|  |     "M423": "X Twist Compensation", | ||||||
|  |     "M425": "Backlash compensation", | ||||||
|  |     "M428": "Home Offsets Here", | ||||||
|  |     "M430": "Power Monitor", | ||||||
|  |     "M486": "Cancel Objects", | ||||||
|  |     "M493": "Fixed-Time Motion", | ||||||
|  |     "M500": "Save Settings", | ||||||
|  |     "M501": "Restore Settings", | ||||||
|  |     "M502": "Factory Reset", | ||||||
|  |     "M503": "Report Settings", | ||||||
|  |     "M504": "Validate EEPROM contents", | ||||||
|  |     "M510": "Lock Machine", | ||||||
|  |     "M511": "Unlock Machine", | ||||||
|  |     "M512": "Set Passcode", | ||||||
|  |     "M524": "Abort SD print", | ||||||
|  |     "M540": "Endstops Abort SD", | ||||||
|  |     "M569": "Set TMC stepping mode", | ||||||
|  |     "M575": "Serial baud rate", | ||||||
|  |     "M592": "Nonlinear Extrusion Control", | ||||||
|  |     "M593": "ZV Input Shaping", | ||||||
|  |     "M600": "Filament Change", | ||||||
|  |     "M603": "Configure Filament Change", | ||||||
|  |     "M605": "Multi Nozzle Mode", | ||||||
|  |     "M665": "Delta Configuration", | ||||||
|  |     "M665": "SCARA Configuration", | ||||||
|  |     "M666": "Set Delta endstop adjustments", | ||||||
|  |     "M666": "Set dual endstop offsets", | ||||||
|  |     "M672": "Duet Smart Effector sensitivity", | ||||||
|  |     "M701": "Load filament", | ||||||
|  |     "M702": "Unload filament", | ||||||
|  |     "M710": "Controller Fan settings", | ||||||
|  |     "M808": "Repeat Marker", | ||||||
|  |     "M851": "XYZ Probe Offset", | ||||||
|  |     "M852": "Bed Skew Compensation", | ||||||
|  |     "M871": "Probe temperature config", | ||||||
|  |     "M876": "Handle Prompt Response", | ||||||
|  |     "M900": "Linear Advance Factor", | ||||||
|  |     "M906": "Stepper Motor Current", | ||||||
|  |     "M907": "Set Motor Current", | ||||||
|  |     "M908": "Set Trimpot Pins", | ||||||
|  |     "M909": "DAC Print Values", | ||||||
|  |     "M910": "Commit DAC to EEPROM", | ||||||
|  |     "M911": "TMC OT Pre-Warn Condition", | ||||||
|  |     "M912": "Clear TMC OT Pre-Warn", | ||||||
|  |     "M913": "Set Hybrid Threshold Speed", | ||||||
|  |     "M914": "TMC Bump Sensitivity", | ||||||
|  |     "M915": "TMC Z axis calibration", | ||||||
|  |     "M916": "L6474 Thermal Warning Test", | ||||||
|  |     "M917": "L6474 Overcurrent Warning Test", | ||||||
|  |     "M918": "L6474 Speed Warning Test", | ||||||
|  |     "M919": "TMC Chopper Timing", | ||||||
|  |     "M928": "Start SD Logging", | ||||||
|  |     "M951": "Magnetic Parking Extruder", | ||||||
|  |     "M993": "Back up flash settings to SD", | ||||||
|  |     "M994": "Restore flash from SD", | ||||||
|  |     "M995": "Touch Screen Calibration", | ||||||
|  |     "M997": "Firmware update", | ||||||
|  |     "M999": "STOP Restart", | ||||||
|  |     "M7219": "MAX7219 Control", | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GCodeExecutor: | ||||||
|  |     def __init__(self): | ||||||
|  |         self._log = logging.getLogger( | ||||||
|  |             "octoprint.plugins.bambu_printer.BambuPrinter.gcode_executor" | ||||||
|  |         ) | ||||||
|  |         self.handler_names = set() | ||||||
|  |         self.gcode_handlers = {} | ||||||
|  |         self.gcode_handlers_no_data = {} | ||||||
|  |  | ||||||
|  |     def __contains__(self, item): | ||||||
|  |         return item in self.gcode_handlers or item in self.gcode_handlers_no_data | ||||||
|  |  | ||||||
|  |     def _get_required_args_count(self, func): | ||||||
|  |         sig = signature(func) | ||||||
|  |         required_count = sum( | ||||||
|  |             1 | ||||||
|  |             for p in sig.parameters.values() | ||||||
|  |             if (p.kind == p.POSITIONAL_OR_KEYWORD or p.kind == p.POSITIONAL_ONLY) | ||||||
|  |             and p.default == p.empty | ||||||
|  |         ) | ||||||
|  |         return required_count | ||||||
|  |  | ||||||
|  |     def register(self, gcode): | ||||||
|  |         def decorator(func): | ||||||
|  |             required_count = self._get_required_args_count(func) | ||||||
|  |             if required_count == 1: | ||||||
|  |                 self.gcode_handlers_no_data[gcode] = func | ||||||
|  |             elif required_count == 2: | ||||||
|  |                 self.gcode_handlers[gcode] = func | ||||||
|  |             else: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f"Cannot register function with {required_count} required parameters" | ||||||
|  |                 ) | ||||||
|  |             return func | ||||||
|  |  | ||||||
|  |         return decorator | ||||||
|  |  | ||||||
|  |     def register_no_data(self, gcode): | ||||||
|  |         def decorator(func): | ||||||
|  |             self.gcode_handlers_no_data[gcode] = func | ||||||
|  |             return func | ||||||
|  |  | ||||||
|  |         return decorator | ||||||
|  |  | ||||||
|  |     def execute(self, printer, gcode, data): | ||||||
|  |         gcode_info = self._gcode_with_info(gcode) | ||||||
|  |         try: | ||||||
|  |             if gcode in self.gcode_handlers: | ||||||
|  |                 self._log.debug(f"Executing {gcode_info}") | ||||||
|  |                 return self.gcode_handlers[gcode](printer, data) | ||||||
|  |             elif gcode in self.gcode_handlers_no_data: | ||||||
|  |                 self._log.debug(f"Executing {gcode_info}") | ||||||
|  |                 return self.gcode_handlers_no_data[gcode](printer) | ||||||
|  |             else: | ||||||
|  |                 self._log.debug(f"ignoring {gcode_info} command.") | ||||||
|  |                 return False | ||||||
|  |         except Exception as e: | ||||||
|  |             self._log.error(f"Error during gcode {gcode_info}") | ||||||
|  |             raise | ||||||
|  |  | ||||||
|  |     def _gcode_with_info(self, gcode): | ||||||
|  |         return f"{gcode} ({GCODE_DOCUMENTATION.get(gcode, 'Info not specified')})" | ||||||
							
								
								
									
										18
									
								
								octoprint_bambu_printer/printer/print_job.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								octoprint_bambu_printer/printer/print_job.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||||
|  |     FileInfo, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class PrintJob: | ||||||
|  |     file_info: FileInfo | ||||||
|  |     progress: int | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_position(self): | ||||||
|  |         if self.file_info.size is None: | ||||||
|  |             return 0 | ||||||
|  |         return int(self.file_info.size * self.progress / 100) | ||||||
							
								
								
									
										257
									
								
								octoprint_bambu_printer/printer/printer_serial_io.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								octoprint_bambu_printer/printer/printer_serial_io.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,257 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from io import BufferedIOBase | ||||||
|  | import logging | ||||||
|  | import queue | ||||||
|  | import re | ||||||
|  | import threading | ||||||
|  | import traceback | ||||||
|  | from types import TracebackType | ||||||
|  | from typing import Callable | ||||||
|  |  | ||||||
|  | from octoprint.util import to_bytes, to_unicode | ||||||
|  | from serial import SerialTimeoutException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PrinterSerialIO(threading.Thread, BufferedIOBase): | ||||||
|  |     command_regex = re.compile(r"^([GM])(\d+)") | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         handle_command_callback: Callable[[str, str], None], | ||||||
|  |         settings, | ||||||
|  |         serial_log_handler=None, | ||||||
|  |         read_timeout=5.0, | ||||||
|  |         write_timeout=10.0, | ||||||
|  |     ) -> None: | ||||||
|  |         super().__init__( | ||||||
|  |             name="octoprint.plugins.bambu_printer.printer_worker", daemon=True | ||||||
|  |         ) | ||||||
|  |         self._handle_command_callback = handle_command_callback | ||||||
|  |         self._settings = settings | ||||||
|  |         self._log = self._init_logger(serial_log_handler) | ||||||
|  |  | ||||||
|  |         self._read_timeout = read_timeout | ||||||
|  |         self._write_timeout = write_timeout | ||||||
|  |  | ||||||
|  |         self.current_line = 0 | ||||||
|  |         self._received_lines = 0 | ||||||
|  |         self._wait_interval = 5.0 | ||||||
|  |         self._running = True | ||||||
|  |  | ||||||
|  |         self._rx_buffer_size = 64 | ||||||
|  |         self._incoming_lock = threading.RLock() | ||||||
|  |  | ||||||
|  |         self.input_bytes = queue.Queue(self._rx_buffer_size) | ||||||
|  |         self.output_bytes = queue.Queue() | ||||||
|  |         self._error_detected: Exception | None = None | ||||||
|  |  | ||||||
|  |     def _init_logger(self, log_handler): | ||||||
|  |         log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter.serial") | ||||||
|  |         if log_handler is not None: | ||||||
|  |             log.addHandler(log_handler) | ||||||
|  |         log.debug("-" * 78) | ||||||
|  |         return log | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def incoming_lock(self): | ||||||
|  |         return self._incoming_lock | ||||||
|  |  | ||||||
|  |     def run(self) -> None: | ||||||
|  |         buffer = b"" | ||||||
|  |  | ||||||
|  |         while self._running: | ||||||
|  |             try: | ||||||
|  |                 data = self.input_bytes.get(block=True, timeout=0.01) | ||||||
|  |                 data = to_bytes(data, encoding="ascii", errors="replace") | ||||||
|  |  | ||||||
|  |                 buffer += data | ||||||
|  |                 line, buffer = self._read_next_line(buffer) | ||||||
|  |                 while line is not None: | ||||||
|  |                     self._received_lines += 1 | ||||||
|  |                     self._process_input_gcode_line(line) | ||||||
|  |                     line, buffer = self._read_next_line(buffer) | ||||||
|  |                 self.input_bytes.task_done() | ||||||
|  |             except queue.Empty: | ||||||
|  |                 continue | ||||||
|  |             except Exception as e: | ||||||
|  |                 self._error_detected = e | ||||||
|  |                 self.input_bytes.task_done() | ||||||
|  |                 self._clearQueue(self.input_bytes) | ||||||
|  |                 self._log.info( | ||||||
|  |                     "\n".join(traceback.format_exception_only(type(e), e)[-50:]) | ||||||
|  |                 ) | ||||||
|  |                 self._running = False | ||||||
|  |  | ||||||
|  |         self._log.debug("Closing IO read loop") | ||||||
|  |  | ||||||
|  |     def _read_next_line(self, buffer: bytes): | ||||||
|  |         new_line_pos = buffer.find(b"\n") + 1 | ||||||
|  |         if new_line_pos > 0: | ||||||
|  |             line = buffer[:new_line_pos] | ||||||
|  |             buffer = buffer[new_line_pos:] | ||||||
|  |             return line, buffer | ||||||
|  |         else: | ||||||
|  |             return None, buffer | ||||||
|  |  | ||||||
|  |     def close(self): | ||||||
|  |         self.flush() | ||||||
|  |         self._running = False | ||||||
|  |         self.join() | ||||||
|  |  | ||||||
|  |     def flush(self): | ||||||
|  |         self.input_bytes.join() | ||||||
|  |         self.raise_if_error() | ||||||
|  |  | ||||||
|  |     def raise_if_error(self): | ||||||
|  |         if self._error_detected is not None: | ||||||
|  |             raise self._error_detected | ||||||
|  |  | ||||||
|  |     def write(self, data: bytes) -> int: | ||||||
|  |         data = to_bytes(data, errors="replace") | ||||||
|  |         u_data = to_unicode(data, errors="replace") | ||||||
|  |  | ||||||
|  |         with self._incoming_lock: | ||||||
|  |             if self.is_closed(): | ||||||
|  |                 return 0 | ||||||
|  |  | ||||||
|  |             try: | ||||||
|  |                 self._log.debug(f"<<< {u_data}") | ||||||
|  |                 self.input_bytes.put(data, timeout=self._write_timeout) | ||||||
|  |                 return len(data) | ||||||
|  |             except queue.Full: | ||||||
|  |                 self._log.error( | ||||||
|  |                     "Incoming queue is full, raising SerialTimeoutException" | ||||||
|  |                 ) | ||||||
|  |                 raise SerialTimeoutException() | ||||||
|  |  | ||||||
|  |     def readline(self) -> bytes: | ||||||
|  |         try: | ||||||
|  |             # fetch a line from the queue, wait no longer than timeout | ||||||
|  |             line = to_unicode( | ||||||
|  |                 self.output_bytes.get(timeout=self._read_timeout), errors="replace" | ||||||
|  |             ) | ||||||
|  |             self._log.debug(f">>> {line.strip()}") | ||||||
|  |             self.output_bytes.task_done() | ||||||
|  |             return to_bytes(line) | ||||||
|  |         except queue.Empty: | ||||||
|  |             # queue empty? return empty line | ||||||
|  |             return b"" | ||||||
|  |  | ||||||
|  |     def readlines(self): | ||||||
|  |         result = [] | ||||||
|  |         next_line = self.readline() | ||||||
|  |         while next_line != b"": | ||||||
|  |             result.append(next_line) | ||||||
|  |             next_line = self.readline() | ||||||
|  |         return result | ||||||
|  |  | ||||||
|  |     def send(self, line: str) -> None: | ||||||
|  |         if self.output_bytes is not None: | ||||||
|  |             self.output_bytes.put(line) | ||||||
|  |  | ||||||
|  |     def sendOk(self): | ||||||
|  |         self.send("ok") | ||||||
|  |  | ||||||
|  |     def reset(self): | ||||||
|  |         self._clearQueue(self.input_bytes) | ||||||
|  |         self._clearQueue(self.output_bytes) | ||||||
|  |  | ||||||
|  |     def is_closed(self): | ||||||
|  |         return not self._running | ||||||
|  |  | ||||||
|  |     def _process_input_gcode_line(self, data: bytes): | ||||||
|  |         if b"*" in data: | ||||||
|  |             checksum = int(data[data.rfind(b"*") + 1 :]) | ||||||
|  |             data = data[: data.rfind(b"*")] | ||||||
|  |             if not checksum == self._calculate_checksum(data): | ||||||
|  |                 self._triggerResend(expected=self.current_line + 1) | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             self.current_line += 1 | ||||||
|  |         elif self._settings.get_boolean(["forceChecksum"]): | ||||||
|  |             self.send(self._format_error("checksum_missing")) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         line = self._process_linenumber_marker(data) | ||||||
|  |         if line is None: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         command = to_unicode(line, encoding="ascii", errors="replace").strip() | ||||||
|  |         command_match = self.command_regex.match(command) | ||||||
|  |         if command_match is not None: | ||||||
|  |             gcode = command_match.group(0) | ||||||
|  |             self._handle_command_callback(gcode, command) | ||||||
|  |         else: | ||||||
|  |             self._log.warn(f'Not a valid gcode command "{command}"') | ||||||
|  |  | ||||||
|  |     def _process_linenumber_marker(self, data: bytes): | ||||||
|  |         linenumber = 0 | ||||||
|  |         if data.startswith(b"N") and b"M110" in data: | ||||||
|  |             linenumber = int(re.search(b"N([0-9]+)", data).group(1)) | ||||||
|  |             self.lastN = linenumber | ||||||
|  |             self.current_line = linenumber | ||||||
|  |             self.sendOk() | ||||||
|  |             return None | ||||||
|  |         elif data.startswith(b"N"): | ||||||
|  |             linenumber = int(re.search(b"N([0-9]+)", data).group(1)) | ||||||
|  |             expected = self.lastN + 1 | ||||||
|  |             if linenumber != expected: | ||||||
|  |                 self._triggerResend(actual=linenumber) | ||||||
|  |                 return None | ||||||
|  |             else: | ||||||
|  |                 self.lastN = linenumber | ||||||
|  |             data = data.split(None, 1)[1].strip() | ||||||
|  |         return data | ||||||
|  |  | ||||||
|  |     def _triggerResend( | ||||||
|  |         self, | ||||||
|  |         expected: int | None = None, | ||||||
|  |         actual: int | None = None, | ||||||
|  |         checksum: int | None = None, | ||||||
|  |     ) -> None: | ||||||
|  |         with self._incoming_lock: | ||||||
|  |             if expected is None: | ||||||
|  |                 expected = self.lastN + 1 | ||||||
|  |             else: | ||||||
|  |                 self.lastN = expected - 1 | ||||||
|  |  | ||||||
|  |             if actual is None: | ||||||
|  |                 if checksum: | ||||||
|  |                     self.send(self._format_error("checksum_mismatch")) | ||||||
|  |                 else: | ||||||
|  |                     self.send(self._format_error("checksum_missing")) | ||||||
|  |             else: | ||||||
|  |                 self.send(self._format_error("lineno_mismatch", expected, actual)) | ||||||
|  |  | ||||||
|  |             def request_resend(): | ||||||
|  |                 self.send("Resend:%d" % expected) | ||||||
|  |                 self.sendOk() | ||||||
|  |  | ||||||
|  |             request_resend() | ||||||
|  |  | ||||||
|  |     def _calculate_checksum(self, line: bytes) -> int: | ||||||
|  |         checksum = 0 | ||||||
|  |         for c in bytearray(line): | ||||||
|  |             checksum ^= c | ||||||
|  |         return checksum | ||||||
|  |  | ||||||
|  |     def _format_error(self, error: str, *args, **kwargs) -> str: | ||||||
|  |         errors = { | ||||||
|  |             "checksum_mismatch": "Checksum mismatch", | ||||||
|  |             "checksum_missing": "Missing checksum", | ||||||
|  |             "lineno_mismatch": "expected line {} got {}", | ||||||
|  |             "lineno_missing": "No Line Number with checksum, Last Line: {}", | ||||||
|  |             "maxtemp": "MAXTEMP triggered!", | ||||||
|  |             "mintemp": "MINTEMP triggered!", | ||||||
|  |             "command_unknown": "Unknown command {}", | ||||||
|  |         } | ||||||
|  |         return f"Error: {errors.get(error).format(*args, **kwargs)}" | ||||||
|  |  | ||||||
|  |     def _clearQueue(self, q: queue.Queue): | ||||||
|  |         try: | ||||||
|  |             while q.get(block=False): | ||||||
|  |                 q.task_done() | ||||||
|  |                 continue | ||||||
|  |         except queue.Empty: | ||||||
|  |             pass | ||||||
							
								
								
									
										0
									
								
								octoprint_bambu_printer/printer/states/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								octoprint_bambu_printer/printer/states/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										46
									
								
								octoprint_bambu_printer/printer/states/a_printer_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								octoprint_bambu_printer/printer/states/a_printer_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from octoprint_bambu_printer.printer.bambu_virtual_printer import ( | ||||||
|  |         BambuVirtualPrinter, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class APrinterState: | ||||||
|  |     def __init__(self, printer: BambuVirtualPrinter) -> None: | ||||||
|  |         self._log = logging.getLogger( | ||||||
|  |             "octoprint.plugins.bambu_printer.BambuPrinter.states" | ||||||
|  |         ) | ||||||
|  |         self._printer = printer | ||||||
|  |  | ||||||
|  |     def init(self): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     def finalize(self): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     def handle_gcode(self, gcode): | ||||||
|  |         self._log.debug(f"{self.__class__.__name__} gcode execution disabled") | ||||||
|  |  | ||||||
|  |     def update_print_job_info(self): | ||||||
|  |         self._log_skip_state_transition("start_new_print") | ||||||
|  |  | ||||||
|  |     def start_new_print(self): | ||||||
|  |         self._log_skip_state_transition("start_new_print") | ||||||
|  |  | ||||||
|  |     def pause_print(self): | ||||||
|  |         self._log_skip_state_transition("pause_print") | ||||||
|  |  | ||||||
|  |     def cancel_print(self): | ||||||
|  |         self._log_skip_state_transition("cancel_print") | ||||||
|  |  | ||||||
|  |     def resume_print(self): | ||||||
|  |         self._log_skip_state_transition("resume_print") | ||||||
|  |  | ||||||
|  |     def _log_skip_state_transition(self, method): | ||||||
|  |         self._log.debug( | ||||||
|  |             f"skipping {self.__class__.__name__} state transition for '{method}'" | ||||||
|  |         ) | ||||||
							
								
								
									
										56
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IdleState(APrinterState): | ||||||
|  |  | ||||||
|  |     def start_new_print(self): | ||||||
|  |         selected_file = self._printer.selected_file | ||||||
|  |         if selected_file is None: | ||||||
|  |             self._log.warn("Cannot start print job if file was not selected") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         print_command = self._get_print_command_for_file(selected_file) | ||||||
|  |         self._log.debug(f"Sending print command: {print_command}") | ||||||
|  |         if self._printer.bambu_client.publish(print_command): | ||||||
|  |             self._log.info(f"Started print for {selected_file.file_name}") | ||||||
|  |         else: | ||||||
|  |             self._log.warn(f"Failed to start print for {selected_file.file_name}") | ||||||
|  |  | ||||||
|  |     def _get_print_command_for_file(self, selected_file: FileInfo): | ||||||
|  |  | ||||||
|  |         # URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf" | ||||||
|  |         filesystem_root = ( | ||||||
|  |             "file:///mnt/sdcard/" | ||||||
|  |             if self._printer._settings.get(["device_type"]) in ["X1", "X1C"] | ||||||
|  |             else "file:///" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         print_command = { | ||||||
|  |             "print": { | ||||||
|  |                 "sequence_id": 0, | ||||||
|  |                 "command": "project_file", | ||||||
|  |                 "param": "Metadata/plate_1.gcode", | ||||||
|  |                 "md5": "", | ||||||
|  |                 "profile_id": "0", | ||||||
|  |                 "project_id": "0", | ||||||
|  |                 "subtask_id": "0", | ||||||
|  |                 "task_id": "0", | ||||||
|  |                 "subtask_name": selected_file.file_name, | ||||||
|  |                 "url": f"{filesystem_root}{selected_file.path.as_posix()}", | ||||||
|  |                 "bed_type": "auto", | ||||||
|  |                 "timelapse": self._printer._settings.get_boolean(["timelapse"]), | ||||||
|  |                 "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), | ||||||
|  |                 "flow_cali": self._printer._settings.get_boolean(["flow_cali"]), | ||||||
|  |                 "vibration_cali": self._printer._settings.get_boolean( | ||||||
|  |                     ["vibration_cali"] | ||||||
|  |                 ), | ||||||
|  |                 "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), | ||||||
|  |                 "use_ams": self._printer._settings.get_boolean(["use_ams"]), | ||||||
|  |                 "ams_mapping": "", | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return print_command | ||||||
							
								
								
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from octoprint_bambu_printer.printer.bambu_virtual_printer import ( | ||||||
|  |         BambuVirtualPrinter, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | import threading | ||||||
|  |  | ||||||
|  | import pybambu.commands | ||||||
|  | from octoprint.util import RepeatedTimer | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PausedState(APrinterState): | ||||||
|  |  | ||||||
|  |     def __init__(self, printer: BambuVirtualPrinter) -> None: | ||||||
|  |         super().__init__(printer) | ||||||
|  |         self._pausedLock = threading.Event() | ||||||
|  |         self._paused_repeated_report = None | ||||||
|  |  | ||||||
|  |     def init(self): | ||||||
|  |         if not self._pausedLock.is_set(): | ||||||
|  |             self._pausedLock.set() | ||||||
|  |  | ||||||
|  |         self._printer.sendIO("// action:paused") | ||||||
|  |         self._printer.start_continuous_status_report(3) | ||||||
|  |  | ||||||
|  |     def finalize(self): | ||||||
|  |         if self._pausedLock.is_set(): | ||||||
|  |             self._pausedLock.clear() | ||||||
|  |             if self._paused_repeated_report is not None: | ||||||
|  |                 self._paused_repeated_report.join() | ||||||
|  |                 self._paused_repeated_report = None | ||||||
|  |  | ||||||
|  |     def start_new_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.RESUME): | ||||||
|  |                 self._log.info("print resumed") | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print resume failed") | ||||||
|  |  | ||||||
|  |     def cancel_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.STOP): | ||||||
|  |                 self._log.info("print cancelled") | ||||||
|  |                 self._printer.finalize_print_job() | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print cancel failed") | ||||||
							
								
								
									
										94
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import time | ||||||
|  | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from octoprint_bambu_printer.printer.bambu_virtual_printer import ( | ||||||
|  |         BambuVirtualPrinter, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | import threading | ||||||
|  |  | ||||||
|  | import pybambu | ||||||
|  | import pybambu.models | ||||||
|  | import pybambu.commands | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.print_job import PrintJob | ||||||
|  | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PrintingState(APrinterState): | ||||||
|  |  | ||||||
|  |     def __init__(self, printer: BambuVirtualPrinter) -> None: | ||||||
|  |         super().__init__(printer) | ||||||
|  |         self._current_print_job = None | ||||||
|  |         self._is_printing = False | ||||||
|  |         self._sd_printing_thread = None | ||||||
|  |  | ||||||
|  |     def init(self): | ||||||
|  |         self._is_printing = True | ||||||
|  |         self._printer.remove_project_selection() | ||||||
|  |         self.update_print_job_info() | ||||||
|  |         self._start_worker_thread() | ||||||
|  |  | ||||||
|  |     def finalize(self): | ||||||
|  |         if self._sd_printing_thread is not None and self._sd_printing_thread.is_alive(): | ||||||
|  |             self._is_printing = False | ||||||
|  |             self._sd_printing_thread.join() | ||||||
|  |             self._sd_printing_thread = None | ||||||
|  |         self._printer.current_print_job = None | ||||||
|  |  | ||||||
|  |     def _start_worker_thread(self): | ||||||
|  |         if self._sd_printing_thread is None: | ||||||
|  |             self._is_printing = True | ||||||
|  |             self._sd_printing_thread = threading.Thread(target=self._printing_worker) | ||||||
|  |             self._sd_printing_thread.start() | ||||||
|  |  | ||||||
|  |     def _printing_worker(self): | ||||||
|  |         while ( | ||||||
|  |             self._is_printing | ||||||
|  |             and self._printer.current_print_job is not None | ||||||
|  |             and self._printer.current_print_job.progress < 100 | ||||||
|  |         ): | ||||||
|  |             self.update_print_job_info() | ||||||
|  |             self._printer.report_print_job_status() | ||||||
|  |             time.sleep(3) | ||||||
|  |  | ||||||
|  |         self.update_print_job_info() | ||||||
|  |         if ( | ||||||
|  |             self._printer.current_print_job is not None | ||||||
|  |             and self._printer.current_print_job.progress >= 100 | ||||||
|  |         ): | ||||||
|  |             self._printer.finalize_print_job() | ||||||
|  |  | ||||||
|  |     def update_print_job_info(self): | ||||||
|  |         print_job_info = self._printer.bambu_client.get_device().print_job | ||||||
|  |         task_name: str = print_job_info.subtask_name | ||||||
|  |         project_file_info = self._printer.project_files.get_file_by_stem( | ||||||
|  |             task_name, [".gcode", ".3mf"] | ||||||
|  |         ) | ||||||
|  |         if project_file_info is None: | ||||||
|  |             self._log.debug(f"No 3mf file found for {print_job_info}") | ||||||
|  |             self._current_print_job = None | ||||||
|  |             self._printer.change_state(self._printer._state_idle) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         progress = print_job_info.print_percentage | ||||||
|  |         self._printer.current_print_job = PrintJob(project_file_info, progress) | ||||||
|  |         self._printer.select_project_file(project_file_info.path.as_posix()) | ||||||
|  |  | ||||||
|  |     def pause_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.PAUSE): | ||||||
|  |                 self._log.info("print paused") | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print pause failed") | ||||||
|  |  | ||||||
|  |     def cancel_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.STOP): | ||||||
|  |                 self._log.info("print cancelled") | ||||||
|  |                 self._printer.finalize_print_job() | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print cancel failed") | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -7,3 +7,10 @@ | |||||||
| ### | ### | ||||||
|  |  | ||||||
| . | . | ||||||
|  |  | ||||||
|  | pytest~=7.4.4 | ||||||
|  | pybambu~=1.0.1 | ||||||
|  | OctoPrint~=1.10.2 | ||||||
|  | setuptools~=70.0.0 | ||||||
|  | pyserial~=3.5 | ||||||
|  | Flask~=2.2.5 | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								setup.py
									
									
									
									
									
								
							| @@ -14,20 +14,20 @@ plugin_package = "octoprint_bambu_printer" | |||||||
| plugin_name = "OctoPrint-BambuPrinter" | plugin_name = "OctoPrint-BambuPrinter" | ||||||
|  |  | ||||||
| # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module | # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module | ||||||
| plugin_version = "0.0.23" | plugin_version = "1.0.0" | ||||||
|  |  | ||||||
| # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin | # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin | ||||||
| # module | # module | ||||||
| plugin_description = """Connects OctoPrint to BambuLabs printers.""" | plugin_description = """Connects OctoPrint to BambuLabs printers.""" | ||||||
|  |  | ||||||
| # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module | # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module | ||||||
| plugin_author = "jneilliii" | plugin_author = "ManuelW" | ||||||
|  |  | ||||||
| # The plugin's author's mail address. | # The plugin's author's mail address. | ||||||
| plugin_author_email = "jneilliii+github@gmail.com" | plugin_author_email = "manuelw@example.com" | ||||||
|  |  | ||||||
| # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module | # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module | ||||||
| plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter" | plugin_url = "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter" | ||||||
|  |  | ||||||
| # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module | # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module | ||||||
| plugin_license = "AGPLv3" | plugin_license = "AGPLv3" | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								test/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								test/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										9
									
								
								test/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								test/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | from pathlib import Path | ||||||
|  | from pytest import fixture | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def output_folder(): | ||||||
|  |     folder = Path(__file__).parent / "test_output" | ||||||
|  |     folder.mkdir(parents=True, exist_ok=True) | ||||||
|  |     return folder | ||||||
							
								
								
									
										30
									
								
								test/test_data_conversions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								test/test_data_conversions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from octoprint.util import get_formatted_size, get_formatted_datetime | ||||||
|  | from octoprint_bambu_printer.printer.file_system.bambu_timelapse_file_info import ( | ||||||
|  |     BambuTimelapseFileInfo, | ||||||
|  | ) | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_timelapse_info_valid(): | ||||||
|  |     file_name = "part.mp4" | ||||||
|  |     file_size = 1000 | ||||||
|  |     file_date = datetime(2020, 1, 1) | ||||||
|  |     file_timestamp = file_date.timestamp() | ||||||
|  |  | ||||||
|  |     file_info = FileInfo(file_name, Path(file_name), file_size, file_date) | ||||||
|  |     timelapse = BambuTimelapseFileInfo.from_file_info(file_info) | ||||||
|  |  | ||||||
|  |     assert timelapse.to_dict() == { | ||||||
|  |         "bytes": file_size, | ||||||
|  |         "date": get_formatted_datetime(datetime.fromtimestamp(file_timestamp)), | ||||||
|  |         "name": file_name, | ||||||
|  |         "size": get_formatted_size(file_size), | ||||||
|  |         "thumbnail": "/plugin/bambu_printer/thumbnail/" | ||||||
|  |         + file_name.replace(".mp4", ".jpg").replace(".avi", ".jpg"), | ||||||
|  |         "timestamp": file_timestamp, | ||||||
|  |         "url": f"/plugin/bambu_printer/timelapse/{file_name}", | ||||||
|  |     } | ||||||
							
								
								
									
										562
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										562
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,562 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  | from datetime import datetime, timezone | ||||||
|  | import logging | ||||||
|  | from pathlib import Path | ||||||
|  | import sys | ||||||
|  | from typing import Any | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||||
|  | import pybambu | ||||||
|  | import pybambu.commands | ||||||
|  | from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  | from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient | ||||||
|  | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||||
|  |     RemoteSDCardFileList, | ||||||
|  | ) | ||||||
|  | from octoprint_bambu_printer.printer.states.idle_state import IdleState | ||||||
|  | from octoprint_bambu_printer.printer.states.paused_state import PausedState | ||||||
|  | from octoprint_bambu_printer.printer.states.printing_state import PrintingState | ||||||
|  | from pytest import fixture | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def output_test_folder(output_folder: Path): | ||||||
|  |     folder = output_folder / "test_gcode" | ||||||
|  |     folder.mkdir(parents=True, exist_ok=True) | ||||||
|  |     return folder | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def log_test(): | ||||||
|  |     log = logging.getLogger("gcode_unittest") | ||||||
|  |     log.setLevel(logging.DEBUG) | ||||||
|  |     return log | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DictGetter: | ||||||
|  |     def __init__(self, options: dict, default_value=None) -> None: | ||||||
|  |         self.options: dict[str | tuple[str, ...], Any] = options | ||||||
|  |         self._default_value = default_value | ||||||
|  |  | ||||||
|  |     def __call__(self, key: str | list[str] | tuple[str, ...]): | ||||||
|  |         if isinstance(key, list): | ||||||
|  |             key = tuple(key) | ||||||
|  |         return self.options.get(key, self._default_value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def settings(output_test_folder): | ||||||
|  |     _settings = MagicMock() | ||||||
|  |     _settings.get.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "serial": "BAMBU", | ||||||
|  |             "host": "localhost", | ||||||
|  |             "access_code": "12345", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     _settings.get_boolean.side_effect = DictGetter({"forceChecksum": False}) | ||||||
|  |  | ||||||
|  |     log_file_path = output_test_folder / "log.txt" | ||||||
|  |     log_file_path.touch() | ||||||
|  |     _settings.get_plugin_logfile_path.return_value = log_file_path.as_posix() | ||||||
|  |     return _settings | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def profile_manager(): | ||||||
|  |     _profile_manager = MagicMock() | ||||||
|  |     _profile_manager.get_current.side_effect = MagicMock() | ||||||
|  |     _profile_manager.get_current().get.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "heatedChamber": False, | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     return _profile_manager | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _ftp_date_format(dt: datetime): | ||||||
|  |     return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def project_files_info_ftp(): | ||||||
|  |     return { | ||||||
|  |         "print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))), | ||||||
|  |         "print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def cache_files_info_ftp(): | ||||||
|  |     return { | ||||||
|  |         "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |         "cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |         "cache/long file path with spaces.gcode.3mf": ( | ||||||
|  |             1200, | ||||||
|  |             _ftp_date_format(datetime(2024, 5, 7)), | ||||||
|  |         ), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp): | ||||||
|  |     all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp) | ||||||
|  |     ftps_session = MagicMock() | ||||||
|  |     ftps_session.size.side_effect = DictGetter( | ||||||
|  |         {file: info[0] for file, info in all_file_info.items()} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     ftps_session.sendcmd.side_effect = DictGetter( | ||||||
|  |         {f"MDTM {file}": info[1] for file, info in all_file_info.items()} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     ftps_session.nlst.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "": list(map(lambda p: Path(p).name, project_files_info_ftp)) | ||||||
|  |             + ["Mock folder"], | ||||||
|  |             "cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp)) | ||||||
|  |             + ["Mock folder"], | ||||||
|  |             "timelapse/": ["video.mp4", "video.avi"], | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session) | ||||||
|  |     yield ftps_session | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def print_job_mock(): | ||||||
|  |     print_job = MagicMock() | ||||||
|  |     print_job.subtask_name = "" | ||||||
|  |     print_job.print_percentage = 0 | ||||||
|  |     return print_job | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def temperatures_mock(): | ||||||
|  |     temperatures = MagicMock() | ||||||
|  |     temperatures.nozzle_temp = 0 | ||||||
|  |     temperatures.target_nozzle_temp = 0 | ||||||
|  |     temperatures.bed_temp = 0 | ||||||
|  |     temperatures.target_bed_temp = 0 | ||||||
|  |     temperatures.chamber_temp = 0 | ||||||
|  |     return temperatures | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient: | ||||||
|  |     bambu_client = MagicMock() | ||||||
|  |     bambu_client.connected = True | ||||||
|  |     device_mock = MagicMock() | ||||||
|  |     device_mock.print_job = print_job_mock | ||||||
|  |     device_mock.temperatures = temperatures_mock | ||||||
|  |     bambu_client.get_device.return_value = device_mock | ||||||
|  |     return bambu_client | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def printer( | ||||||
|  |     output_test_folder, | ||||||
|  |     settings, | ||||||
|  |     profile_manager, | ||||||
|  |     log_test, | ||||||
|  |     ftps_session_mock, | ||||||
|  |     bambu_client_mock, | ||||||
|  | ): | ||||||
|  |     async def _mock_connection(self): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     BambuVirtualPrinter._create_client_connection_async = _mock_connection | ||||||
|  |     printer_test = BambuVirtualPrinter( | ||||||
|  |         settings, | ||||||
|  |         profile_manager, | ||||||
|  |         data_folder=output_test_folder, | ||||||
|  |         serial_log_handler=log_test, | ||||||
|  |         read_timeout=0.01, | ||||||
|  |         faked_baudrate=115200, | ||||||
|  |     ) | ||||||
|  |     printer_test._bambu_client = bambu_client_mock | ||||||
|  |     printer_test.flush() | ||||||
|  |     printer_test.readlines() | ||||||
|  |     yield printer_test | ||||||
|  |     printer_test.close() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_initial_state(printer: BambuVirtualPrinter): | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_sd_card(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M20\n")  # GCode for listing SD card | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"Begin file list" | ||||||
|  |     assert result[1].endswith(b'"print.3mf"') | ||||||
|  |     assert result[2].endswith(b'"print2.3mf"') | ||||||
|  |     assert result[3].endswith(b'"print.3mf"') | ||||||
|  |     assert result[4].endswith(b'"print3.gcode.3mf"') | ||||||
|  |     assert result[-3] == b"End file list" | ||||||
|  |     assert result[-2] == b"ok" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_ftp_paths_p1s(settings, ftps_session_mock): | ||||||
|  |     file_system = RemoteSDCardFileList(settings) | ||||||
|  |     file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi") | ||||||
|  |  | ||||||
|  |     timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"] | ||||||
|  |     ftps_session_mock.size.side_effect = DictGetter( | ||||||
|  |         {file: 100 for file in timelapse_files} | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||||
|  |             for file in timelapse_files | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.nlst.side_effect = DictGetter( | ||||||
|  |         {"timelapse/": [Path(f).name for f in timelapse_files]} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     timelapse_paths = list(map(Path, timelapse_files)) | ||||||
|  |     result_files = file_view.get_all_info() | ||||||
|  |     assert len(timelapse_files) == len(result_files) and all( | ||||||
|  |         file_info.path in timelapse_paths for file_info in result_files | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_ftp_paths_x1(settings, ftps_session_mock): | ||||||
|  |     file_system = RemoteSDCardFileList(settings) | ||||||
|  |     file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4") | ||||||
|  |  | ||||||
|  |     timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"] | ||||||
|  |     ftps_session_mock.size.side_effect = DictGetter( | ||||||
|  |         {file: 100 for file in timelapse_files} | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||||
|  |             for file in timelapse_files | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files}) | ||||||
|  |  | ||||||
|  |     timelapse_paths = list(map(Path, timelapse_files)) | ||||||
|  |     result_files = file_view.get_all_info() | ||||||
|  |     assert len(timelapse_files) == len(result_files) and all( | ||||||
|  |         file_info.path in timelapse_paths for file_info in result_files | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_delete_sd_file_gcode(printer: BambuVirtualPrinter): | ||||||
|  |     with patch( | ||||||
|  |         "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" | ||||||
|  |     ) as delete_function: | ||||||
|  |         printer.write(b"M30 print.3mf\n") | ||||||
|  |         printer.flush() | ||||||
|  |         result = printer.readlines() | ||||||
|  |         assert result[-1] == b"ok" | ||||||
|  |         delete_function.assert_called_with("print.3mf") | ||||||
|  |  | ||||||
|  |         printer.write(b"M30 cache/print.3mf\n") | ||||||
|  |         printer.flush() | ||||||
|  |         result = printer.readlines() | ||||||
|  |         assert result[-1] == b"ok" | ||||||
|  |         delete_function.assert_called_with("cache/print.3mf") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter): | ||||||
|  |     with patch( | ||||||
|  |         "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" | ||||||
|  |     ) as delete_function: | ||||||
|  |         file_info = printer.project_files.get_file_data("cache/print.3mf") | ||||||
|  |         assert file_info is not None | ||||||
|  |  | ||||||
|  |         printer.write(b"M30 " + file_info.dosname.encode() + b"\n") | ||||||
|  |         printer.flush() | ||||||
|  |         assert printer.readlines()[-1] == b"ok" | ||||||
|  |         assert delete_function.call_count == 1 | ||||||
|  |         delete_function.assert_called_with("cache/print.3mf") | ||||||
|  |  | ||||||
|  |         printer.write(b"M30 cache/print.3mf\n") | ||||||
|  |         printer.flush() | ||||||
|  |         assert printer.readlines()[-1] == b"ok" | ||||||
|  |         assert delete_function.call_count == 2 | ||||||
|  |         delete_function.assert_called_with("cache/print.3mf") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_select_project_file_by_stem(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M23 print3\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.path == Path("cache/print3.gcode.3mf") | ||||||
|  |     assert result[-2] == b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_select_project_long_name_file_with_multiple_extensions( | ||||||
|  |     printer: BambuVirtualPrinter, | ||||||
|  | ): | ||||||
|  |     printer.write(b"M23 long file path with spaces.gcode.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.path == Path( | ||||||
|  |         "cache/long file path with spaces.gcode.3mf" | ||||||
|  |     ) | ||||||
|  |     assert result[-2] == b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_non_existing_file_not_selected(printer: BambuVirtualPrinter): | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 non_existing.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-2] != b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\n") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-2] == b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print.3mf" | ||||||
|  |  | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     # emulate printer reporting it's status | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\n") | ||||||
|  |     printer.write(b"M23 print.3mf\n") | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |     printer.write(b"M25\n")  # pausing the print | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "PAUSE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PausedState) | ||||||
|  |     bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "PAUSE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PausedState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "IDLE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FINISH" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FAILED" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_printer_info_check(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M27\n")  # printer get info | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\nM23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     print_job_mock.print_percentage = 50 | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |     printer.write(b"M26 S0\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\nM23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     printer.write(b"M25\n") | ||||||
|  |     printer.flush() | ||||||
|  |     print_job_mock.gcode_state = "PAUSE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     printer.readlines() | ||||||
|  |     assert isinstance(printer.current_state, PausedState) | ||||||
|  |  | ||||||
|  |     printer.write(b"M26 S0\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock): | ||||||
|  |     gcode = b"G28\nG1 X10 Y10\n" | ||||||
|  |     printer.write(gcode) | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE | ||||||
|  |     gcode_command["print"]["param"] = "G28\n" | ||||||
|  |     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||||
|  |  | ||||||
|  |     gcode_command["print"]["param"] = "G1 X10 Y10\n" | ||||||
|  |     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_file_selection_does_not_affect_current_print( | ||||||
|  |     printer: BambuVirtualPrinter, print_job_mock | ||||||
|  | ): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print2.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_finished_print_job_reset_after_new_file_selected( | ||||||
|  |     printer: BambuVirtualPrinter, print_job_mock | ||||||
|  | ): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |     print_job_mock.print_percentage = 100 | ||||||
|  |     printer.current_state.update_print_job_info() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job.progress == 100 | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FINISH" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |     assert printer.current_print_job is None | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print2.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     assert printer.current_print_job is None | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print2.3mf" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     print_job_mock.print_percentage = 99 | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 99 | ||||||
|  |  | ||||||
|  |     print_job_mock.print_percentage = 100 | ||||||
|  |     print_job_mock.gcode_state = "FINISH" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-3].endswith(b"1000/1000") | ||||||
|  |     assert result[-2] == b"Done printing file" | ||||||
|  |     assert result[-1] == b"Not SD printing" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |     assert printer.current_print_job is None | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print.3mf" | ||||||
		Reference in New Issue
	
	Block a user