Compare commits
	
		
			42 Commits
		
	
	
		
			0.0.1
			...
			698f8f4151
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 698f8f4151 | |||
| 7a0293bac7 | |||
|  | d0fd4a5434 | ||
|  | 3c218a548d | ||
|  | 03af51608d | ||
|  | c00285b1b2 | ||
|  | 7f1ae5a24b | ||
|  | 5754e81b72 | ||
|  | cd4103cc71 | ||
|  | 01c6cacf15 | ||
|  | fda4b86cbc | ||
|  | ad862d5ebd | ||
|  | b04202463d | ||
|  | 8e3eb9c64b | ||
|  | e1ea88dbae | ||
|  | ac7bb16a2b | ||
|  | 112210a3f1 | ||
|  | 176154cfee | ||
|  | 56e5fb4dd2 | ||
|  | 3e7708429d | ||
|  | 908173214f | ||
|  | df4bd6cf44 | ||
|  | bcb1e0f649 | ||
|  | f37eadf3ea | ||
|  | 48027f6008 | ||
|  | 616fdf7a82 | ||
|  | c110fa140a | ||
|  | 3889efa67a | ||
|  | cb4b345aa7 | ||
|  | 3d0cc26147 | ||
|  | ff58636e41 | ||
|  | f54ab5c29f | ||
|  | 7a4439c53e | ||
|  | 9eb8b0da65 | ||
|  | ef969d3d3b | ||
|  | 3d92d73879 | ||
|  | 41dad23c49 | ||
|  | 15538a9d0d | ||
|  | f910a6b03e | ||
|  | d94c76b96e | ||
|  | 8d8005d10e | ||
|  | 2799c23b0b | 
							
								
								
									
										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 | ||||
| *.zip | ||||
| 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/>. | ||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,17 +1,13 @@ | ||||
| # OctoPrint-BambuPrinter | ||||
|  | ||||
| **TODO:** Describe what your plugin does. | ||||
| 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 | ||||
|  | ||||
| * Python 3.9 or higher (OctoPi 1.0.0) | ||||
|  | ||||
| ## Setup | ||||
|  | ||||
| Install via the bundled [Plugin Manager](https://docs.octoprint.org/en/master/bundledplugins/pluginmanager.html) | ||||
| or manually using this URL: | ||||
| Install manually using this URL: | ||||
|  | ||||
|     https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/master.zip | ||||
|  | ||||
| **TODO:** Describe how to install your plugin, if more needs to be done than just installing it via pip or through | ||||
| the plugin manager. | ||||
|  | ||||
| ## Configuration | ||||
|  | ||||
| **TODO:** Describe your plugin's configuration options (if any). | ||||
|   | ||||
| @@ -1,123 +1,10 @@ | ||||
| # coding=utf-8 | ||||
| from __future__ import absolute_import | ||||
|  | ||||
| import threading | ||||
| import time | ||||
|  | ||||
| import octoprint.plugin | ||||
|  | ||||
| from .ftpsclient import IoTFTPSClient | ||||
|  | ||||
|  | ||||
| class BambuPrintPlugin( | ||||
|     octoprint.plugin.SettingsPlugin, octoprint.plugin.TemplatePlugin | ||||
| ): | ||||
|  | ||||
|     def get_template_configs(self): | ||||
|         return [{"type": "settings", "custom_bindings": False}] | ||||
|  | ||||
|     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} | ||||
|  | ||||
|     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 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_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_pythoncompat__ = ">=3.7,<4" | ||||
|  | ||||
| from .bambu_print_plugin import BambuPrintPlugin | ||||
|  | ||||
|  | ||||
| def __plugin_load__(): | ||||
|     plugin = BambuPrintPlugin() | ||||
| @@ -132,4 +19,6 @@ def __plugin_load__(): | ||||
|         "octoprint.filemanager.extension_tree": __plugin_implementation__.support_3mf_files, | ||||
|         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, | ||||
|         "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.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,2 +0,0 @@ | ||||
| from ._client import IoTFTPSClient | ||||
| from ._version import __version__ | ||||
| @@ -1,159 +0,0 @@ | ||||
| """wrapper for FTPS server interactions""" | ||||
|  | ||||
| import ftplib | ||||
| import ssl | ||||
| from typing import List, Optional, Union | ||||
|  | ||||
|  | ||||
| class ImplicitTLS(ftplib.FTP_TLS): | ||||
|     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self._sock = None | ||||
|  | ||||
|     @property | ||||
|     def sock(self): | ||||
|         """return socket""" | ||||
|         return self._sock | ||||
|  | ||||
|     @sock.setter | ||||
|     def sock(self, value): | ||||
|         """wrap and set SSL socket""" | ||||
|         if value is not None and not isinstance(value, ssl.SSLSocket): | ||||
|             value = self.context.wrap_socket(value) | ||||
|         self._sock = value | ||||
|  | ||||
|     def ntransfercmd(self, cmd, rest=None): | ||||
|         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) | ||||
|         if self._prot_p: | ||||
|             conn = self.context.wrap_socket(conn, | ||||
|                                             server_hostname=self.host, | ||||
|                                             session=self.sock.session)  # this is the fix | ||||
|         return conn, size | ||||
|  | ||||
|  | ||||
| class IoTFTPSClient: | ||||
|     """iot ftps ftpsclient""" | ||||
|  | ||||
|     ftps_host: str | ||||
|     ftps_port: int | ||||
|     ftps_user: str | ||||
|     ftps_pass: str | ||||
|     ssl_implicit: bool | ||||
|     ftps_session: Union[ftplib.FTP, ImplicitTLS] | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         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""" | ||||
|         try: | ||||
|             if self.ssl_implicit: | ||||
|                 self.ftps_session = ImplicitTLS() | ||||
|             else: | ||||
|                 self.ftps_session = ftplib.FTP() | ||||
|  | ||||
|             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() | ||||
|  | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return | ||||
|  | ||||
|     def disconnect(self) -> None: | ||||
|         """disconnect the current session from the ftps server""" | ||||
|         try: | ||||
|             self.ftps_session.close() | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return | ||||
|  | ||||
|     def download_file(self, source: str, dest: str) -> bool: | ||||
|         """download a file to a path on the local filesystem""" | ||||
|         try: | ||||
|             with open(dest, "wb") as file: | ||||
|                 self.ftps_session.retrbinary(f"RETR {source}", file.write) | ||||
|             return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def upload_file(self, source: str, dest: str) -> bool: | ||||
|         """upload a file to a path inside the FTPS server""" | ||||
|         try: | ||||
|             with open(source, "rb") as file: | ||||
|                 self.ftps_session.storbinary(f"STOR {dest}", file) | ||||
|             return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def delete_file(self, path: str) -> bool: | ||||
|         """delete a file from under a path inside the FTPS server""" | ||||
|         try: | ||||
|             self.ftps_session.delete(path) | ||||
|             return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def move_file(self, source: str, dest: str) -> bool: | ||||
|         """move a file inside the FTPS server to another path inside the FTPS server""" | ||||
|         try: | ||||
|             self.ftps_session.rename(source, dest) | ||||
|             return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def list_files( | ||||
|         self, path: str, file_pattern: Optional[str] = None | ||||
|     ) -> Union[List[str], None]: | ||||
|         """list files under a path inside the FTPS server""" | ||||
|         try: | ||||
|             files = self.ftps_session.nlst(path) | ||||
|             if not files: | ||||
|                 return | ||||
|             if file_pattern: | ||||
|                 return [f for f in files if file_pattern in f] | ||||
|             return files | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return | ||||
| @@ -1,3 +0,0 @@ | ||||
| VERSION = "1.1.1" | ||||
|  | ||||
| __version__ = VERSION | ||||
							
								
								
									
										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) | ||||
							
								
								
									
										256
									
								
								octoprint_bambu_printer/printer/file_system/ftps_client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								octoprint_bambu_printer/printer/file_system/ftps_client.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,256 @@ | ||||
| """ | ||||
| Based on: <https://github.com/dgonzo27/py-iot-utils> | ||||
|  | ||||
| MIT License | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
| in the Software without restriction, including without limitation the rights | ||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all | ||||
| copies or substantial portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| SOFTWARE. | ||||
|  | ||||
| wrapper for FTPS server interactions | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
| from dataclasses import dataclass | ||||
| from datetime import datetime, timezone | ||||
| import ftplib | ||||
| import os | ||||
| from pathlib import Path | ||||
| import socket | ||||
| import ssl | ||||
| from typing import Generator, Union | ||||
|  | ||||
| from contextlib import redirect_stdout | ||||
| import io | ||||
| import re | ||||
|  | ||||
|  | ||||
| class ImplicitTLS(ftplib.FTP_TLS): | ||||
|     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self._sock = None | ||||
|  | ||||
|     @property | ||||
|     def sock(self): | ||||
|         """return socket""" | ||||
|         return self._sock | ||||
|  | ||||
|     @sock.setter | ||||
|     def sock(self, value): | ||||
|         """wrap and set SSL socket""" | ||||
|         if value is not None and not isinstance(value, ssl.SSLSocket): | ||||
|             value = self.context.wrap_socket(value) | ||||
|         self._sock = value | ||||
|  | ||||
|     def ntransfercmd(self, cmd, rest=None): | ||||
|         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) | ||||
|  | ||||
|         if self._prot_p: | ||||
|             conn = self.context.wrap_socket( | ||||
|                 conn, server_hostname=self.host, session=self.sock.session | ||||
|             )  # this is the fix | ||||
|         return conn, size | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class IoTFTPSConnection: | ||||
|     """iot ftps ftpsclient""" | ||||
|  | ||||
|     ftps_session: ftplib.FTP | ImplicitTLS | ||||
|  | ||||
|     def close(self) -> None: | ||||
|         """close the current session from the ftps server""" | ||||
|         self.ftps_session.close() | ||||
|  | ||||
|     def download_file(self, source: str, dest: str): | ||||
|         """download a file to a path on the local filesystem""" | ||||
|         with open(dest, "wb") as file: | ||||
|             self.ftps_session.retrbinary(f"RETR {source}", file.write) | ||||
|  | ||||
|     def upload_file(self, source: str, dest: str, callback=None) -> bool: | ||||
|         """upload a file to a path inside the FTPS server""" | ||||
|  | ||||
|         file_size = os.path.getsize(source) | ||||
|  | ||||
|         block_size = max(file_size // 100, 8192) | ||||
|         rest = None | ||||
|  | ||||
|         try: | ||||
|             # Taken from ftplib.storbinary but with custom ssl handling | ||||
|             # due to the shitty bambu p1p ftps server TODO fix properly. | ||||
|             with open(source, "rb") as fp: | ||||
|                 self.ftps_session.voidcmd("TYPE I") | ||||
|  | ||||
|                 with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: | ||||
|                     while 1: | ||||
|                         buf = fp.read(block_size) | ||||
|  | ||||
|                         if not buf: | ||||
|                             break | ||||
|  | ||||
|                         conn.sendall(buf) | ||||
|  | ||||
|                         if callback: | ||||
|                             callback(buf) | ||||
|  | ||||
|                     # shutdown ssl layer | ||||
|                     if ftplib._SSLSocket is not None and isinstance( | ||||
|                         conn, ftplib._SSLSocket | ||||
|                     ): | ||||
|                         # Yeah this is suposed to be conn.unwrap | ||||
|                         # But since we operate in prot p mode | ||||
|                         # we can close the connection always. | ||||
|                         # This is cursed but it works. | ||||
|                         if "vsFTPd" in self.ftps_session.welcome: | ||||
|                             conn.unwrap() | ||||
|                         else: | ||||
|                             conn.shutdown(socket.SHUT_RDWR) | ||||
|  | ||||
|                 return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def delete_file(self, path: str) -> bool: | ||||
|         """delete a file from under a path inside the FTPS server""" | ||||
|         try: | ||||
|             self.ftps_session.delete(path) | ||||
|             return True | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return False | ||||
|  | ||||
|     def move_file(self, source: str, dest: str): | ||||
|         """move a file inside the FTPS server to another path inside the FTPS server""" | ||||
|         self.ftps_session.rename(source, dest) | ||||
|  | ||||
|     def mkdir(self, path: str) -> str: | ||||
|         return self.ftps_session.mkd(path) | ||||
|  | ||||
|     def list_files( | ||||
|         self, list_path: str, extensions: str | list[str] | None = None | ||||
|     ) -> Generator[Path]: | ||||
|         """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: | ||||
|             list_result = self.ftps_session.nlst(list_path) or [] | ||||
|             for file_list_entry in list_result: | ||||
|                 path = Path(list_path) / Path(file_list_entry).name | ||||
|                 if _extension_acceptable(path): | ||||
|                     yield path | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|  | ||||
|     def list_files_ex(self, path: str) -> Union[list[str], None]: | ||||
|         """list files under a path inside the FTPS server""" | ||||
|         try: | ||||
|             f = io.StringIO() | ||||
|             with redirect_stdout(f): | ||||
|                 self.ftps_session.dir(path) | ||||
|             s = f.getvalue() | ||||
|             files = [] | ||||
|             for row in s.split("\n"): | ||||
|                 if len(row) <= 0: | ||||
|                     continue | ||||
|  | ||||
|                 attribs = row.split(" ") | ||||
|  | ||||
|                 match = re.search(r".*\ (\d\d\:\d\d|\d\d\d\d)\ (.*)", row) | ||||
|                 name = "" | ||||
|                 if match: | ||||
|                     name = match.groups(1)[1] | ||||
|                 else: | ||||
|                     name = attribs[len(attribs) - 1] | ||||
|  | ||||
|                 file = (attribs[0], name) | ||||
|                 files.append(file) | ||||
|             return files | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: [{ex}]") | ||||
|             pass | ||||
|         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") | ||||
| @@ -4,26 +4,145 @@ | ||||
|  * Author: jneilliii | ||||
|  * License: AGPLv3 | ||||
|  */ | ||||
| $(function() { | ||||
|  | ||||
| $(function () { | ||||
|     function Bambu_printerViewModel(parameters) { | ||||
|         var self = this; | ||||
|  | ||||
|         // assign the injected parameters, e.g.: | ||||
|         // self.loginStateViewModel = parameters[0]; | ||||
|         // self.settingsViewModel = parameters[1]; | ||||
|         self.settingsViewModel = parameters[0]; | ||||
|         self.filesViewModel = parameters[1]; | ||||
|         self.loginStateViewModel = parameters[2]; | ||||
|         self.accessViewModel = parameters[3]; | ||||
|         self.timelapseViewModel = parameters[4]; | ||||
|  | ||||
|         // TODO: Implement your plugin's view model here. | ||||
|         self.getAuthToken = function (data) { | ||||
|             self.settingsViewModel.settings.plugins.bambu_printer.auth_token(""); | ||||
|             OctoPrint.simpleApiCommand("bambu_printer", "register", { | ||||
|                 "email": self.settingsViewModel.settings.plugins.bambu_printer.email(), | ||||
|                 "password": $("#bambu_cloud_password").val(), | ||||
|                 "region": self.settingsViewModel.settings.plugins.bambu_printer.region(), | ||||
|                 "auth_token": self.settingsViewModel.settings.plugins.bambu_printer.auth_token() | ||||
|             }) | ||||
|                 .done(function (response) { | ||||
|                     console.log(response); | ||||
|                     self.settingsViewModel.settings.plugins.bambu_printer.auth_token(response.auth_token); | ||||
|                     self.settingsViewModel.settings.plugins.bambu_printer.username(response.username); | ||||
|                 }); | ||||
|         }; | ||||
|  | ||||
|                 // initialize list helper | ||||
|         self.listHelper = new ItemListHelper( | ||||
|             "timelapseFiles", | ||||
|             { | ||||
|                 name: function (a, b) { | ||||
|                     // sorts ascending | ||||
|                     if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) | ||||
|                         return -1; | ||||
|                     if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) | ||||
|                         return 1; | ||||
|                     return 0; | ||||
|                 }, | ||||
|                 date: function (a, b) { | ||||
|                     // sorts descending | ||||
|                     if (a["date"] > b["date"]) return -1; | ||||
|                     if (a["date"] < b["date"]) return 1; | ||||
|                     return 0; | ||||
|                 }, | ||||
|                 size: function (a, b) { | ||||
|                     // sorts descending | ||||
|                     if (a["bytes"] > b["bytes"]) return -1; | ||||
|                     if (a["bytes"] < b["bytes"]) return 1; | ||||
|                     return 0; | ||||
|                 } | ||||
|             }, | ||||
|             {}, | ||||
|             "name", | ||||
|             [], | ||||
|             [], | ||||
|             CONFIG_TIMELAPSEFILESPERPAGE | ||||
|         ); | ||||
|  | ||||
|         self.onDataUpdaterPluginMessage = function(plugin, data) { | ||||
|             if (plugin != "bambu_printer") { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (data.files !== undefined) { | ||||
|                 console.log(data.files); | ||||
|                 self.listHelper.updateItems(data.files); | ||||
|                 self.listHelper.resetPage(); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         self.onBeforeBinding = function () { | ||||
|             $('#bambu_timelapse').appendTo("#timelapse"); | ||||
|         }; | ||||
|  | ||||
|         self.showTimelapseThumbnail = function(data) { | ||||
|             $("#bambu_printer_timelapse_thumbnail").attr("src", data.thumbnail); | ||||
|             $("#bambu_printer_timelapse_preview").modal('show'); | ||||
|         }; | ||||
|  | ||||
|         /*$('#files div.upload-buttons > span.fileinput-button:first, #files div.folder-button').remove(); | ||||
|         $('#files div.upload-buttons > span.fileinput-button:first').removeClass('span6').addClass('input-block-level'); | ||||
|  | ||||
|         self.onBeforePrintStart = function(start_print_command) { | ||||
|             let confirmation_html = '' + | ||||
|                 '            <div class="row-fluid form-vertical">\n' + | ||||
|                 '                <div class="control-group">\n' + | ||||
|                 '                    <label class="control-label">' + gettext("Plate Number") + '</label>\n' + | ||||
|                 '                    <div class="controls">\n' + | ||||
|                 '                        <input type="number" min="1" value="1" id="bambu_printer_plate_number" class="input-mini">\n' + | ||||
|                 '                    </div>\n' + | ||||
|                 '                </div>\n' + | ||||
|                 '            </div>'; | ||||
|  | ||||
|             if(!self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options()){ | ||||
|                 confirmation_html += '\n' + | ||||
|                     '            <div class="row-fluid">\n' + | ||||
|                     '                <div class="span6">\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_timelapse" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.timelapse()) ? ' checked' : '') + '> ' + gettext("Enable timelapse") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_bed_leveling" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling()) ? ' checked' : '') + '> ' + gettext("Enable bed leveling") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_flow_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.flow_cali()) ? ' checked' : '') + '> ' + gettext("Enable flow calibration") + '</label>\n' + | ||||
|                     '                </div>\n' + | ||||
|                     '                <div class="span6">\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_vibration_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali()) ? ' checked' : '') + '> ' + gettext("Enable vibration calibration") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_layer_inspect" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect()) ? ' checked' : '') + '> ' + gettext("Enable first layer inspection") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_use_ams" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.use_ams()) ? ' checked' : '') + '> ' + gettext("Use AMS") + '</label>\n' + | ||||
|                     '                </div>\n' + | ||||
|                     '            </div>\n'; | ||||
|             } | ||||
|  | ||||
|             showConfirmationDialog({ | ||||
|                 title: "Bambu Print Options", | ||||
|                 html: confirmation_html, | ||||
|                 cancel: gettext("Cancel"), | ||||
|                 proceed: [gettext("Print"), gettext("Always")], | ||||
|                 onproceed: function (idx) { | ||||
|                     if(idx === 1){ | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.timelapse($('#bambu_printer_timelapse').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling($('#bambu_printer_bed_leveling').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.flow_cali($('#bambu_printer_flow_cali').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali($('#bambu_printer_vibration_cali').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect($('#bambu_printer_layer_inspect').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.use_ams($('#bambu_printer_use_ams').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options(true); | ||||
|                         self.settingsViewModel.saveData(); | ||||
|                     } | ||||
|                     // replace this with our own print command API call? | ||||
|                     start_print_command(); | ||||
|                 }, | ||||
|                 nofade: true | ||||
|             }); | ||||
|             return false; | ||||
|         };*/ | ||||
|     } | ||||
|  | ||||
|     /* view model class, parameters for constructor, container to bind to | ||||
|      * Please see http://docs.octoprint.org/en/master/plugins/viewmodels.html#registering-custom-viewmodels for more details | ||||
|      * and a full list of the available options. | ||||
|      */ | ||||
|     OCTOPRINT_VIEWMODELS.push({ | ||||
|         construct: Bambu_printerViewModel, | ||||
|         // ViewModels your plugin depends on, e.g. loginStateViewModel, settingsViewModel, ... | ||||
|         dependencies: [ /* "loginStateViewModel", "settingsViewModel" */ ], | ||||
|         dependencies: ["settingsViewModel", "filesViewModel", "loginStateViewModel", "accessViewModel", "timelapseViewModel"], | ||||
|         // Elements to bind to, e.g. #settings_plugin_bambu_printer, #tab_plugin_bambu_printer, ... | ||||
|         elements: [ /* ... */ ] | ||||
|         elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse"] | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -1,40 +1,78 @@ | ||||
| <h3>Virtual Printer</h3> | ||||
| <h3>Bambu Printer Settings <small>{{ _('Version') }} {{ plugin_bambu_printer_plugin_version }}</small></h3> | ||||
|  | ||||
| <form class="form-horizontal" onsubmit="return false;"> | ||||
|     <div class="control-group"> | ||||
|         <label class="control-label">{{ _('Device Type') }}</label> | ||||
|         <div class="controls"> | ||||
|             <select class="input-block-level" data-bind="options: ['A1', 'A1MINI', 'P1P', 'P1S', 'X1', 'X1C'], value: settings.plugins.bambu_printer.device_type, allowUnset: true"> | ||||
|             <select class="input-block-level" data-bind="options: ['A1', 'A1MINI', 'P1P', 'P1S', 'X1', 'X1C'], value: settingsViewModel.settings.plugins.bambu_printer.device_type, allowUnset: true"> | ||||
|             </select> | ||||
|         </div> | ||||
|     </div> | ||||
| 	<div class="control-group"> | ||||
| 		<label class="control-label">{{ _('IP Address') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settings.plugins.bambu_printer.host" placeholder="192.168.0.2" title="{{ _('IP address or hostname of the printer') }}"></input> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.host" placeholder="192.168.0.2" title="{{ _('IP address or hostname of the printer') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group"> | ||||
| 		<label class="control-label">{{ _('Serial Number') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settings.plugins.bambu_printer.serial" title="{{ _('Serial number of printer') }}"></input> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.serial" title="{{ _('Serial number of printer') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group"> | ||||
| 		<label class="control-label">{{ _('Access Code') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settings.plugins.bambu_printer.access_code" title="{{ _('Access code of printer') }}"></input> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.access_code" title="{{ _('Access code of printer') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
|     <div class="control-group"> | ||||
|         <label class="control-label">{{ _('Print Options') }}</label> | ||||
|         <div class="controls"> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.timelapse"> {{ _('Enable timelapse') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.bed_leveling"> {{ _('Enable bed leveling') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.flow_cali"> {{ _('Enable flow calibration') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.vibration_cali"> {{ _('Enable vibration calibration') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.layer_inspect"> {{ _('Enable first layer inspection') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.use_ams"> {{ _('Use AMS') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.local_mqtt"> {{ _('Use Local Access, disable for cloud connection') }}</label> | ||||
|         </div> | ||||
|     </div> | ||||
| 	<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()"> | ||||
| 		<label class="control-label">{{ _('Region') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.region" title="{{ _('Region used to connect, ie China, US') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()"> | ||||
| 		<label class="control-label">{{ _('Email') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.email" title="{{ _('Registered email address') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()"> | ||||
| 		<label class="control-label">{{ _('Password') }}</label> | ||||
| 		<div class="controls"> | ||||
|             <div class="input-block-level input-append"> | ||||
| 			    <input id="bambu_cloud_password" type="password" class="input-text input-block-level" title="{{ _('Password to generate Auth Token') }}"></input> | ||||
|                 <span class="btn btn-primary add-on" data-bind="click: getAuthToken">{{ _('Login') }}</span> | ||||
|             </div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()"> | ||||
| 		<label class="control-label">{{ _('Auth Token') }}</label> | ||||
| 		<div class="controls"> | ||||
| 			<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.auth_token" title="{{ _('Auth Token') }}"></input> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="control-group"> | ||||
|         <label class="control-label">{{ _('Default Print Options') }}</label> | ||||
|         <div class="controls"> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.timelapse"> {{ _('Enable timelapse') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.bed_leveling"> {{ _('Enable bed leveling') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.flow_cali"> {{ _('Enable flow calibration') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.vibration_cali"> {{ _('Enable vibration calibration') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.layer_inspect"> {{ _('Enable first layer inspection') }}</label> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.use_ams"> {{ _('Use AMS') }}</label> | ||||
|         </div> | ||||
|     </div> | ||||
| 	{#<div class="control-group"> | ||||
|         <label class="control-label">{{ _('Always Use Default') }}</label> | ||||
|         <div class="controls"> | ||||
|             <label class="checkbox"><input type="checkbox" data-bind="checked: settings.plugins.bambu_printer.always_use_default_options"> </label> | ||||
|         </div> | ||||
|     </div>#} | ||||
| </form> | ||||
|   | ||||
							
								
								
									
										71
									
								
								octoprint_bambu_printer/templates/bambu_timelapse.jinja2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								octoprint_bambu_printer/templates/bambu_timelapse.jinja2
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| <div class="row-fluid" id="bambu_timelapse"> | ||||
|     <h1>{{ _('Bambu Timelapses') }}</h1> | ||||
|  | ||||
|     <div class="pull-right"> | ||||
|         <div class="btn-group"> | ||||
|             <button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="fas fa-wrench"></i> <span class="caret"></span></button> | ||||
|             <ul class="dropdown-menu dropdown-menu-right"> | ||||
|                 <li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('name'); }"><i class="fas fa-check" data-bind="style: {visibility: listHelper.currentSorting() == 'name' ? 'visible' : 'hidden'}"></i> {{ _('Sort by name') }} ({{ _('ascending') }})</a></li> | ||||
|                 <li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('date'); }"><i class="fas fa-check" data-bind="style: {visibility: listHelper.currentSorting() == 'date' ? 'visible' : 'hidden'}"></i> {{ _('Sort by date') }} ({{ _('descending') }})</a></li> | ||||
|                 <li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('size'); }"><i class="fas fa-check" data-bind="style: {visibility: listHelper.currentSorting() == 'size' ? 'visible' : 'hidden'}"></i> {{ _('Sort by file size') }} ({{ _('descending') }})</a></li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
|     <table class="table table-hover table-condensed table-hover" id="bambu_timelapse_files"> | ||||
|         <thead> | ||||
|         <tr> | ||||
|             <th class="timelapse_files_thumb"></th> | ||||
|             <th class="timelapse_files_details">{{ _('Details') }}</th> | ||||
|             <th class="timelapse_files_action">{{ _('Action') }}</th> | ||||
|         </tr> | ||||
|         </thead> | ||||
|         <tbody data-bind="foreach: listHelper.paginatedItems"> | ||||
|         <tr data-bind="attr: {title: name}"> | ||||
|             <td class="timelapse_files_thumb"> | ||||
|                 <div class="thumb" data-bind="css: { letterbox: $data.thumbnail }"> | ||||
|                     <!-- ko if: $data.thumbnail --> | ||||
|                     <img data-bind="attr:{src: thumbnail}" loading="lazy" style="aspect-ratio: 3 / 2;"/> | ||||
|                     <!-- /ko --> | ||||
|                     <a href="javascript:void(0)" data-bind="css: {disabled: !$root.timelapseViewModel.isTimelapseViewable($data)}, click: $root.showTimelapseThumbnail"></a> | ||||
|                 </div> | ||||
|             </td> | ||||
|             <td class="timelapse_files_details"> | ||||
|                 <p class="name" data-bind="text: name"></p> | ||||
|                 <p class="detail">{{ _('Recorded:') }} <span data-bind="text: formatTimeAgo(timestamp)"/></p> | ||||
|                 <p class="detail">{{ _('Size:') }} <span data-bind="text: size"/></p> | ||||
|             </td> | ||||
|             <td class="timelapse_files_action"> | ||||
|                 <div class="btn-group action-buttons"> | ||||
|                     <a href="javascript:void(0)" class="btn btn-mini" data-bind="css: {disabled: !$root.loginStateViewModel.hasPermissionKo($root.accessViewModel.permissions.TIMELAPSE_DOWNLOAD)()}, attr: { href: ($root.loginStateViewModel.hasPermission($root.accessViewModel.permissions.TIMELAPSE_DOWNLOAD)) ? $data.url : 'javascript:void(0)' }"><i class="fas fa-download"></i></a> | ||||
|                 </div> | ||||
|             </td> | ||||
|         </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
|     <div class="pagination pagination-mini pagination-centered"> | ||||
|         <ul> | ||||
|             <li data-bind="css: {disabled: listHelper.currentPage() === 0}"><a href="javascript:void(0)" data-bind="click: listHelper.prevPage">«</a></li> | ||||
|         </ul> | ||||
|         <ul data-bind="foreach: listHelper.pages"> | ||||
|             <li data-bind="css: { active: $data.number === $root.listHelper.currentPage(), disabled: $data.number === -1 }"><a href="javascript:void(0)" data-bind="text: $data.text, click: function() { $root.listHelper.changePage($data.number); }"></a></li> | ||||
|         </ul> | ||||
|         <ul> | ||||
|             <li data-bind="css: {disabled: listHelper.currentPage() === listHelper.lastPage()}"><a href="javascript:void(0)" data-bind="click: listHelper.nextPage">»</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div id="bambu_printer_timelapse_preview" class="modal hide fade"> | ||||
| 	<div class="modal-header"> | ||||
| 		<a href="#" class="close" data-dismiss="modal" aria-hidden="true">×</a> | ||||
| 		<h3>{{ _('Timelapse Thumbnail') }}</h3> | ||||
| 	</div> | ||||
|     <div class="modal-body"> | ||||
|         <div class="row-fluid"> | ||||
|             <img id="bambu_printer_timelapse_thumbnail" src="" class="row-fluid" style="aspect-ratio: 3 / 2;"/> | ||||
|         </div> | ||||
| 	</div> | ||||
| 	<div class="modal-footer"> | ||||
| 		<a href="#" class="btn" data-dismiss="modal" aria-hidden="true">{{ _('Close') }}</a> | ||||
| 	</div> | ||||
| </div> | ||||
| @@ -1,970 +0,0 @@ | ||||
| __author__ = "Gina Häußge <osd@foosel.net>" | ||||
| __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" | ||||
|  | ||||
|  | ||||
| import collections | ||||
| import os | ||||
| import queue | ||||
| import re | ||||
| import threading | ||||
| import time | ||||
| from typing import Any, Dict, List, Optional | ||||
| import asyncio | ||||
| from pybambu import BambuClient, commands | ||||
|  | ||||
| from serial import SerialTimeoutException | ||||
| from octoprint.util import RepeatedTimer, to_bytes, to_unicode, get_dos_filename | ||||
|  | ||||
| from .ftpsclient import IoTFTPSClient | ||||
|  | ||||
|  | ||||
| # noinspection PyBroadException | ||||
| class BambuPrinter: | ||||
|     command_regex = re.compile(r"^([GM])(\d+)") | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         settings, | ||||
|         printer_profile_manager, | ||||
|         data_folder, | ||||
|         seriallog_handler=None, | ||||
|         read_timeout=5.0, | ||||
|         write_timeout=10.0, | ||||
|         faked_baudrate=115200, | ||||
|     ): | ||||
|         self._busyInterval = 2.0 | ||||
|         self.tick_rate = 2.0 | ||||
|         self._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 {}", | ||||
|             } | ||||
|         self._sendBusy = False | ||||
|         self._ambient_temperature = 21.3 | ||||
|         self.temp = [self._ambient_temperature ] | ||||
|         self.targetTemp = [0.0] | ||||
|         self.bedTemp = self._ambient_temperature | ||||
|         self.bedTargetTemp = 0.0 | ||||
|         self.chamberTemp = self._ambient_temperature | ||||
|         self.chamberTargetTemp = 0.0 | ||||
|         self.lastTempAt = time.monotonic() | ||||
|         self._firmwareName = "Bambu" | ||||
|         self._m115FormatString = "FIRMWARE_NAME:{firmware_name} PROTOCOL_VERSION:1.0" | ||||
|         self._received_lines = 0 | ||||
|         self.extruderCount = 1 | ||||
|         self._waitInterval = 5.0 | ||||
|         self._killed = False | ||||
|         self._heatingUp = False | ||||
|         self.current_line = 0 | ||||
|         self._writingToSd = False | ||||
|  | ||||
|         self._sdCardReady = True | ||||
|         self._sdPrinter = None | ||||
|         self._sdPrinting = False | ||||
|         self._sdPrintingSemaphore = threading.Event() | ||||
|         self._sdPrintingPausedSemaphore = threading.Event() | ||||
|         self._selectedSdFile = None | ||||
|         self._selectedSdFileSize = 0 | ||||
|         self._selectedSdFilePos = 0 | ||||
|  | ||||
|         self._busy = None | ||||
|         self._busy_loop = None | ||||
|  | ||||
|  | ||||
|         import logging | ||||
|  | ||||
|         self._logger = logging.getLogger( | ||||
|             "octoprint.plugins.bambu_printer.BambuPrinter" | ||||
|         ) | ||||
|  | ||||
|         self._settings = settings | ||||
|         self._printer_profile_manager = printer_profile_manager | ||||
|         self._faked_baudrate = faked_baudrate | ||||
|         self._plugin_data_folder = data_folder | ||||
|  | ||||
|         self._seriallog = logging.getLogger( | ||||
|             "octoprint.plugins.bambu_printer.BambuPrinter.serial" | ||||
|         ) | ||||
|         self._seriallog.setLevel(logging.CRITICAL) | ||||
|         self._seriallog.propagate = False | ||||
|  | ||||
|         if seriallog_handler is not None: | ||||
|             import logging.handlers | ||||
|  | ||||
|             self._seriallog.addHandler(seriallog_handler) | ||||
|             self._seriallog.setLevel(logging.INFO) | ||||
|  | ||||
|         self._seriallog.debug("-" * 78) | ||||
|  | ||||
|         self._read_timeout = read_timeout | ||||
|         self._write_timeout = write_timeout | ||||
|  | ||||
|         self._rx_buffer_size = 64 | ||||
|         self._incoming_lock = threading.RLock() | ||||
|  | ||||
|         self.incoming = CharCountingQueue(self._rx_buffer_size, name="RxBuffer") | ||||
|         self.outgoing = queue.Queue() | ||||
|         self.buffered = queue.Queue(maxsize=4) | ||||
|  | ||||
|         self._last_hms_errors = None | ||||
|  | ||||
|         self.bambu = None | ||||
|  | ||||
|         readThread = threading.Thread( | ||||
|             target=self._processIncoming, | ||||
|             name="octoprint.plugins.bambu_printer.wait_thread", | ||||
|             daemon=True | ||||
|         ) | ||||
|         readThread.start() | ||||
|  | ||||
|         # bufferThread = threading.Thread( | ||||
|         #     target=self._processBuffer, | ||||
|         #     name="octoprint.plugins.bambu_printer.buffer_thread", | ||||
|         #     daemon=True | ||||
|         # ) | ||||
|         # bufferThread.start() | ||||
|  | ||||
|         connectionThread = threading.Thread( | ||||
|             target=self._create_connection, | ||||
|             name="octoprint.plugins.bambu_printer.connection_thread", | ||||
|             daemon=True | ||||
|         ) | ||||
|         connectionThread.start() | ||||
|  | ||||
|     def new_update(self, event_type): | ||||
|         if event_type == "event_hms_errors": | ||||
|             bambu_printer = self.bambu.get_device() | ||||
|             if bambu_printer.hms.errors != self._last_hms_errors and bambu_printer.hms.errors["Count"] > 0: | ||||
|                 self._logger.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._send(f"// action:notification {error}") | ||||
|                 self._last_hms_errors = bambu_printer.hms.errors | ||||
|         elif event_type == "event_printer_data_update": | ||||
|             device_data = self.bambu.get_device() | ||||
|             ams = device_data.ams.__dict__ | ||||
|             info = device_data.info.__dict__ | ||||
|             temperatures = device_data.temperature.__dict__ | ||||
|             lights = device_data.lights.__dict__ | ||||
|             fans = device_data.fans.__dict__ | ||||
|             speed = device_data.speed.__dict__ | ||||
|  | ||||
|             self.temp[0] = temperatures.get("nozzle_temp", 0.0) | ||||
|             self.targetTemp[0] = temperatures.get("target_nozzle_temp", 0.0) | ||||
|             self.bedTemp = temperatures.get("bed_temp", 0.0) | ||||
|             self.bedTargetTemp = temperatures.get("target_bed_temp", 0.0) | ||||
|             self.chamberTemp = temperatures.get("chamber_temp", 0.0) | ||||
|  | ||||
|             if info.get("gcode_state") == "RUNNING": | ||||
|                 if not self._sdPrintingSemaphore.is_set(): | ||||
|                     self._sdPrintingSemaphore.set() | ||||
|                 if self._sdPrintingPausedSemaphore.is_set(): | ||||
|                     self._sdPrintingPausedSemaphore.clear() | ||||
|                 if not self._sdPrinting: | ||||
|                     filename = info.get("subtask_name") | ||||
|                     self._selectSdFile(filename) | ||||
|                     self._startSdPrint(from_printer=True) | ||||
|  | ||||
|                 # fuzzy math here to get print percentage to match BambuStudio | ||||
|                 self._selectedSdFilePos = int(self._selectedSdFileSize * ((info.get("print_percentage") + 1)/100)) | ||||
|  | ||||
|             if info.get("gcode_state") == "PAUSE": | ||||
|                 if not self._sdPrintingPausedSemaphore.is_set(): | ||||
|                     self._sdPrintingPausedSemaphore.set() | ||||
|                 if self._sdPrintingSemaphore.is_set(): | ||||
|                     self._sdPrintingSemaphore.clear() | ||||
|                     self._send("// action:paused") | ||||
|                     self._sendPaused() | ||||
|  | ||||
|             if info.get("gcode_state") == "FINISH" and self._sdPrintingSemaphore.is_set(): | ||||
|                 self._selectedSdFilePos = self._selectedSdFileSize | ||||
|                 self._finishSdPrint() | ||||
|     def _create_connection(self): | ||||
|         if (self._settings.get(["device_type"]) != "" and | ||||
|             self._settings.get(["serial"]) != "" and | ||||
|             self._settings.get(["serial"]) != "" and | ||||
|             self._settings.get(["username"]) != "" and | ||||
|             self._settings.get(["access_code"]) != "" | ||||
|         ): | ||||
|             asyncio.run(self._create_connection_async()) | ||||
|  | ||||
|     async def _create_connection_async(self): | ||||
|         self.bambu = BambuClient(device_type=self._settings.get(["device_type"]), | ||||
|                                  serial=self._settings.get(["serial"]), | ||||
|                                  host=self._settings.get(["host"]), | ||||
|                                  username=self._settings.get(["username"]), | ||||
|                                  access_code=self._settings.get(["access_code"]) | ||||
|                                  ) | ||||
|  | ||||
|         await self.bambu.connect(callback=self.new_update) | ||||
|         self._logger.info(f"bambu connection status: {self.bambu.connected}") | ||||
|         self._sendOk() | ||||
|         # while True: | ||||
|         #     await asyncio.sleep(self.tick_rate) | ||||
|         #     self._processTemperatureQuery() | ||||
|  | ||||
|     def __str__(self): | ||||
|         return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( | ||||
|             read_timeout=self._read_timeout, | ||||
|             write_timeout=self._write_timeout, | ||||
|             options={"device_type": self._settings.get(["device_type"]), "host": self._settings.get(["host"])}, | ||||
|         ) | ||||
|  | ||||
|     def _calculate_resend_every_n(self, resend_ratio): | ||||
|         self._resend_every_n = (100 // resend_ratio) if resend_ratio else 0 | ||||
|  | ||||
|     def _reset(self): | ||||
|         with self._incoming_lock: | ||||
|             self._relative = True | ||||
|             self._lastX = 0.0 | ||||
|             self._lastY = 0.0 | ||||
|             self._lastZ = 0.0 | ||||
|             self._lastE = [0.0] * self.extruderCount | ||||
|             self._lastF = 200 | ||||
|  | ||||
|             self._unitModifier = 1 | ||||
|             self._feedrate_multiplier = 100 | ||||
|             self._flowrate_multiplier = 100 | ||||
|  | ||||
|             self._sdCardReady = True | ||||
|             self._sdPrinting = False | ||||
|             if self._sdPrinter: | ||||
|                 self._sdPrinting = False | ||||
|                 self._sdPrintingSemaphore.clear() | ||||
|                 self._sdPrintingPausedSemaphore.clear() | ||||
|             self._sdPrinter = None | ||||
|             self._selectedSdFile = None | ||||
|             self._selectedSdFileSize = None | ||||
|             self._selectedSdFilePos = None | ||||
|  | ||||
|             if self._writingToSdHandle: | ||||
|                 try: | ||||
|                     self._writingToSdHandle.close() | ||||
|                 except Exception: | ||||
|                     pass | ||||
|             self._writingToSd = False | ||||
|             self._writingToSdHandle = None | ||||
|             self._writingToSdFile = None | ||||
|             self._newSdFilePos = None | ||||
|  | ||||
|             self._heatingUp = False | ||||
|  | ||||
|             self.current_line = 0 | ||||
|             self.lastN = 0 | ||||
|  | ||||
|             self._debug_awol = False | ||||
|             self._debug_sleep = 0 | ||||
|             # self._sleepAfterNext.clear() | ||||
|             # self._sleepAfter.clear() | ||||
|  | ||||
|             self._dont_answer = False | ||||
|             self._broken_klipper_connection = False | ||||
|  | ||||
|             self._debug_drop_connection = False | ||||
|  | ||||
|             self._killed = False | ||||
|  | ||||
|             if self._sdstatus_reporter is not None: | ||||
|                 self._sdstatus_reporter.cancel() | ||||
|                 self._sdstatus_reporter = None | ||||
|  | ||||
|             self._clearQueue(self.incoming) | ||||
|             self._clearQueue(self.outgoing) | ||||
|             # self._clearQueue(self.buffered) | ||||
|  | ||||
|             if self._settings.get_boolean(["simulateReset"]): | ||||
|                 for item in self._settings.get(["resetLines"]): | ||||
|                     self._send(item + "\n") | ||||
|  | ||||
|             self._locked = self._settings.get_boolean(["locked"]) | ||||
|  | ||||
|     @property | ||||
|     def timeout(self): | ||||
|         return self._read_timeout | ||||
|  | ||||
|     @timeout.setter | ||||
|     def timeout(self, value): | ||||
|         self._logger.debug(f"Setting read timeout to {value}s") | ||||
|         self._read_timeout = value | ||||
|  | ||||
|     @property | ||||
|     def write_timeout(self): | ||||
|         return self._write_timeout | ||||
|  | ||||
|     @write_timeout.setter | ||||
|     def write_timeout(self, value): | ||||
|         self._logger.debug(f"Setting write timeout to {value}s") | ||||
|         self._write_timeout = value | ||||
|  | ||||
|     @property | ||||
|     def port(self): | ||||
|         return "BAMBU" | ||||
|  | ||||
|     @property | ||||
|     def baudrate(self): | ||||
|         return self._faked_baudrate | ||||
|  | ||||
|     # noinspection PyMethodMayBeStatic | ||||
|     def _clearQueue(self, q): | ||||
|         try: | ||||
|             while q.get(block=False): | ||||
|                 q.task_done() | ||||
|                 continue | ||||
|         except queue.Empty: | ||||
|             pass | ||||
|  | ||||
|     def _processIncoming(self): | ||||
|         linenumber = 0 | ||||
|         next_wait_timeout = 0 | ||||
|  | ||||
|         def recalculate_next_wait_timeout(): | ||||
|             nonlocal next_wait_timeout | ||||
|             next_wait_timeout = time.monotonic() + self._waitInterval | ||||
|  | ||||
|         recalculate_next_wait_timeout() | ||||
|  | ||||
|         data = None | ||||
|  | ||||
|         buf = b"" | ||||
|         while self.incoming is not None and not self._killed: | ||||
|             try: | ||||
|                 data = self.incoming.get(timeout=0.01) | ||||
|                 data = to_bytes(data, encoding="ascii", errors="replace") | ||||
|                 self.incoming.task_done() | ||||
|             except queue.Empty: | ||||
|                 continue | ||||
|             except Exception: | ||||
|                 if self.incoming is None: | ||||
|                     # just got closed | ||||
|                     break | ||||
|  | ||||
|             if data is not None: | ||||
|                 buf += data | ||||
|                 nl = buf.find(b"\n") + 1 | ||||
|                 if nl > 0: | ||||
|                     data = buf[:nl] | ||||
|                     buf = buf[nl:] | ||||
|                 else: | ||||
|                     continue | ||||
|  | ||||
|             recalculate_next_wait_timeout() | ||||
|  | ||||
|             if data is None: | ||||
|                 continue | ||||
|  | ||||
|             self._received_lines += 1 | ||||
|  | ||||
|             # strip checksum | ||||
|             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) | ||||
|                     continue | ||||
|  | ||||
|                 self.current_line += 1 | ||||
|             elif self._settings.get_boolean(["forceChecksum"]): | ||||
|                 self._send(self._error("checksum_missing")) | ||||
|                 continue | ||||
|  | ||||
|             # track N = N + 1 | ||||
|             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() | ||||
|                 continue | ||||
|  | ||||
|             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) | ||||
|                     continue | ||||
|                 else: | ||||
|                     self.lastN = linenumber | ||||
|  | ||||
|                 data = data.split(None, 1)[1].strip() | ||||
|  | ||||
|             data += b"\n" | ||||
|  | ||||
|             data = to_unicode(data, encoding="ascii", errors="replace").strip() | ||||
|  | ||||
|             # actual command handling | ||||
|             command_match = BambuPrinter.command_regex.match(data) | ||||
|             if command_match is not None: | ||||
|                 command = command_match.group(0) | ||||
|                 letter = command_match.group(1) | ||||
|  | ||||
|                 try: | ||||
|                     # if we have a method _gcode_G, _gcode_M or _gcode_T, execute that first | ||||
|                     letter_handler = f"_gcode_{letter}" | ||||
|                     if hasattr(self, letter_handler): | ||||
|                         code = command_match.group(2) | ||||
|                         handled = getattr(self, letter_handler)(code, data) | ||||
|                         if handled: | ||||
|                             self._sendOk() | ||||
|                             continue | ||||
|  | ||||
|                     # then look for a method _gcode_<command> and execute that if it exists | ||||
|                     command_handler = f"_gcode_{command}" | ||||
|                     if hasattr(self, command_handler): | ||||
|                         handled = getattr(self, command_handler)(data) | ||||
|                         if handled: | ||||
|                             self._sendOk() | ||||
|                             continue | ||||
|                     else: | ||||
|                         self._sendOk() | ||||
|  | ||||
|                 finally: | ||||
|                     self._logger.debug(f"{data}") | ||||
|  | ||||
|             self._logger.debug("Closing down read loop") | ||||
|  | ||||
|     ##~~ command implementations | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M20(self, data: str) -> bool: | ||||
|         if self._sdCardReady: | ||||
|             self._listSd(incl_long="L" in data, incl_timestamp="T" in data) | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M21(self, data: str) -> bool: | ||||
|         self._sdCardReady = True | ||||
|         self._send("SD card ok") | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M22(self, data: str) -> bool: | ||||
|         self._logger.debug("ignoring M22 command.") | ||||
|         self._send("M22 disabled for Bambu") | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M23(self, data: str) -> bool: | ||||
|         if self._sdCardReady: | ||||
|             filename = data.split(None, 1)[1].strip() | ||||
|             self._selectSdFile(filename) | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M24(self, data: str) -> bool: | ||||
|         if self._sdCardReady: | ||||
|             self._startSdPrint() | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M25(self, data: str) -> bool: | ||||
|         if self._sdCardReady: | ||||
|             self._pauseSdPrint() | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M26(self, data: str) -> bool: | ||||
|         self._logger.debug("ignoring M26 command.") | ||||
|         self._send("M26 disabled for Bambu") | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M27(self, data: str) -> bool: | ||||
|         def report(): | ||||
|             if self._sdCardReady: | ||||
|                 self._reportSdStatus() | ||||
|  | ||||
|         matchS = re.search(r"S([0-9]+)", data) | ||||
|         if matchS: | ||||
|             interval = int(matchS.group(1)) | ||||
|             if self._sdstatus_reporter is not None: | ||||
|                 self._sdstatus_reporter.cancel() | ||||
|  | ||||
|             if interval > 0: | ||||
|                 self._sdstatus_reporter = RepeatedTimer(interval, report) | ||||
|                 self._sdstatus_reporter.start() | ||||
|             else: | ||||
|                 self._sdstatus_reporter = None | ||||
|  | ||||
|         report() | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M28(self, data: str) -> bool: | ||||
|         self._logger.debug("ignoring M28 command.") | ||||
|         self._send("M28 disabled for Bambu") | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M29(self, data: str) -> bool: | ||||
|         self._logger.debug("ignoring M28 command.") | ||||
|         self._send("M28 disabled for Bambu") | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M30(self, data: str) -> bool: | ||||
|         if self._sdCardReady: | ||||
|             filename = data.split(None, 1)[1].strip() | ||||
|             self._deleteSdFile(filename) | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M33(self, data: str) -> bool: | ||||
|         self._logger.debug("ignoring M33 command.") | ||||
|         self._send("M33 disabled for Bambu") | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M105(self, data: str) -> bool: | ||||
|         self._processTemperatureQuery() | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M115(self, data: str) -> bool: | ||||
|         self._send("Bambu Printer Integration") | ||||
|         self._send("Cap:EXTENDED_M20:1") | ||||
|         self._send("Cap:LFN_WRITE:1") | ||||
|         self._send("Cap:LFN_WRITE:1") | ||||
|         return True | ||||
|  | ||||
|     def _gcode_M117(self, data: str) -> bool: | ||||
|         # we'll just use this to echo a message, to allow playing around with pause triggers | ||||
|         result = re.search(r"M117\s+(.*)", data).group(1) | ||||
|         self._send(f"echo:{result}") | ||||
|         return False | ||||
|  | ||||
|     def _gcode_M118(self, data: str) -> bool: | ||||
|         match = re.search(r"M118 (?:(?P<parameter>A1|E1|Pn[012])\s)?(?P<text>.*)", data) | ||||
|         if not match: | ||||
|             self._send("Unrecognized command parameters for M118") | ||||
|         else: | ||||
|             result = match.groupdict() | ||||
|             text = result["text"] | ||||
|             parameter = result["parameter"] | ||||
|  | ||||
|             if parameter == "A1": | ||||
|                 self._send(f"//{text}") | ||||
|             elif parameter == "E1": | ||||
|                 self._send(f"echo:{text}") | ||||
|             else: | ||||
|                 self._send(text) | ||||
|         return True | ||||
|  | ||||
|     # noinspection PyUnusedLocal | ||||
|     def _gcode_M400(self, data: str) -> bool: | ||||
|         return True | ||||
|  | ||||
|     @staticmethod | ||||
|     def _check_param_letters(letters, data): | ||||
|         # Checks if any of the params (letters) are included in data | ||||
|         # Purely for saving typing :) | ||||
|         for param in list(letters): | ||||
|             if param in data: | ||||
|                 return True | ||||
|  | ||||
|     ##~~ further helpers | ||||
|  | ||||
|     # noinspection PyMethodMayBeStatic | ||||
|     def _calculate_checksum(self, line: bytes) -> int: | ||||
|         checksum = 0 | ||||
|         for c in bytearray(line): | ||||
|             checksum ^= c | ||||
|         return checksum | ||||
|  | ||||
|     def _kill(self): | ||||
|         self._killed = True | ||||
|         if self.bambu.connected: | ||||
|             self.bambu.disconnect() | ||||
|         self._send("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") | ||||
|  | ||||
|     def _triggerResend( | ||||
|         self, expected: int = None, actual: int = None, checksum: int = 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._error("checksum_mismatch")) | ||||
|                 else: | ||||
|                     self._send(self._error("checksum_missing")) | ||||
|             else: | ||||
|                 self._send(self._error("lineno_mismatch", expected, actual)) | ||||
|  | ||||
|             def request_resend(): | ||||
|                 self._send("Resend:%d" % expected) | ||||
|                 # if not self._brokenResend: | ||||
|                 self._sendOk() | ||||
|  | ||||
|             request_resend() | ||||
|  | ||||
|     def _listSd(self, incl_long=False, incl_timestamp=False): | ||||
|         line = "{name} {size} \"{name}\"" | ||||
|  | ||||
|         self._send("Begin file list") | ||||
|         for item in map(lambda x: line.format(**x), self._getSdFiles()): | ||||
|             self._send(item) | ||||
|         self._send("End file list") | ||||
|  | ||||
|     def _mappedSdList(self) -> Dict[str, Dict[str, Any]]: | ||||
|         result = {} | ||||
|         host = self._settings.get(["host"]) | ||||
|         access_code = self._settings.get(["access_code"]) | ||||
|  | ||||
|         ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|         filelist = ftp.list_files("", ".3mf") | ||||
|  | ||||
|         for entry in filelist: | ||||
|             if entry.startswith("/"): | ||||
|                 filename = entry[1:] | ||||
|             else: | ||||
|                 filename = entry | ||||
|             filesize = ftp.ftps_session.size(entry) | ||||
|             dosname = get_dos_filename(filename, existing_filenames=list(result.keys())).lower() | ||||
|             data = { | ||||
|                 "dosname": dosname, | ||||
|                 "name": filename, | ||||
|                 "path": filename, | ||||
|                 "size": filesize, | ||||
|             } | ||||
|             result[filename.lower()] = data | ||||
|             # result[dosname.lower()] = filename.lower() | ||||
|  | ||||
|         return result | ||||
|  | ||||
|     def _getSdFileData(self, filename: str) -> Optional[Dict[str, Any]]: | ||||
|         files = self._mappedSdList() | ||||
|         data = files.get(filename.lower()) | ||||
|         if isinstance(data, str): | ||||
|             data = files.get(data.lower()) | ||||
|         return data | ||||
|  | ||||
|     def _getSdFiles(self) -> List[Dict[str, Any]]: | ||||
|         files = self._mappedSdList() | ||||
|         return [x for x in files.values() if isinstance(x, dict)] | ||||
|  | ||||
|     def _selectSdFile(self, filename: str, check_already_open: bool = False) -> None: | ||||
|         if filename.startswith("/"): | ||||
|             filename = filename[1:] | ||||
|  | ||||
|         file = self._getSdFileData(filename) | ||||
|         if file is None: | ||||
|             self._send(f"{filename} open failed") | ||||
|             return | ||||
|  | ||||
|         if self._selectedSdFile == file["path"] and check_already_open: | ||||
|             return | ||||
|  | ||||
|         self._selectedSdFile = file["path"] | ||||
|         self._selectedSdFileSize = file["size"] | ||||
|         self._send(f"File opened: {file['name']}  Size: {self._selectedSdFileSize}") | ||||
|         self._send("File selected") | ||||
|  | ||||
|     def _startSdPrint(self, from_printer: bool = False) -> None: | ||||
|         if self._selectedSdFile is not None: | ||||
|             if self._sdPrinter is None: | ||||
|                 self._sdPrinting = True | ||||
|                 self._sdPrinter = threading.Thread(target=self._sdPrintingWorker, kwargs={"from_printer": from_printer}) | ||||
|                 self._sdPrinter.start() | ||||
|         # self._sdPrintingSemaphore.set() | ||||
|         if self._sdPrinter is not None: | ||||
|             if self.bambu.connected: | ||||
|                 if self.bambu.publish(commands.RESUME): | ||||
|                     self._logger.info("print resumed") | ||||
|                     # if not self._sdPrintingSemaphore.is_set(): | ||||
|                     #     self._sdPrintingSemaphore.set() | ||||
|                 else: | ||||
|                     self._logger.info("print resume failed") | ||||
|  | ||||
|     def _pauseSdPrint(self): | ||||
|         if self.bambu.connected: | ||||
|             if self.bambu.publish(commands.PAUSE): | ||||
|                 self._logger.info("print paused") | ||||
|             else: | ||||
|                 self._logger.info("print pause failed") | ||||
|  | ||||
|     def _setSdPos(self, pos): | ||||
|         self._newSdFilePos = pos | ||||
|  | ||||
|     def _reportSdStatus(self): | ||||
|         if self._sdPrinter is not None and (self._sdPrintingSemaphore.is_set() or self._sdPrintingPausedSemaphore.is_set()): | ||||
|             self._send(f"SD printing byte {self._selectedSdFilePos}/{self._selectedSdFileSize}") | ||||
|         else: | ||||
|             self._send("Not SD printing") | ||||
|  | ||||
|     def _generateTemperatureOutput(self) -> str: | ||||
|         template = "{heater}:{actual:.2f}/ {target:.2f}" | ||||
|         temps = collections.OrderedDict() | ||||
|         heater = "T" | ||||
|         temps[heater] = (self.temp[0], self.targetTemp[0]) | ||||
|         temps["B"] = (self.bedTemp, self.bedTargetTemp) | ||||
|         temps["C"] = (self.chamberTemp, self.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): | ||||
|         # includeOk = not self._okBeforeCommandOutput | ||||
|         output = self._generateTemperatureOutput() | ||||
|         self._send(output) | ||||
|  | ||||
|     def _writeSdFile(self, filename: str) -> None: | ||||
|         self._send(f"Writing to file: {filename}") | ||||
|  | ||||
|     def _finishSdFile(self): | ||||
|         try: | ||||
|             self._writingToSdHandle.close() | ||||
|         except Exception: | ||||
|             pass | ||||
|         finally: | ||||
|             self._writingToSdHandle = None | ||||
|         self._writingToSd = False | ||||
|         self._selectedSdFile = None | ||||
|         # Most printers don't have RTC and set some ancient date | ||||
|         # by default. Emulate that using 2000-01-01 01:00:00 | ||||
|         # (taken from prusa firmware behaviour) | ||||
|         st = os.stat(self._writingToSdFile) | ||||
|         os.utime(self._writingToSdFile, (st.st_atime, 946684800)) | ||||
|         self._writingToSdFile = None | ||||
|         self._send("Done saving file") | ||||
|  | ||||
|     def _sdPrintingWorker(self, from_printer: bool = False): | ||||
|         self._selectedSdFilePos = 0 | ||||
|         try: | ||||
|             if not from_printer and self.bambu.connected: | ||||
|                 print_command = {"print": {"sequence_id": 0, | ||||
|                                            "command": "project_file", | ||||
|                                            "param": "Metadata/plate_1.gcode", | ||||
|                                            "subtask_name": f"{self._selectedSdFile}", | ||||
|                                            "url": f"file:///mnt/sdcard/{self._selectedSdFile}", | ||||
|                                            "timelapse": self._settings.get_boolean(["timelapse"]), | ||||
|                                            "bed_leveling": self._settings.get_boolean(["bed_leveling"]), | ||||
|                                            "flow_cali": self._settings.get_boolean(["flow_cali"]), | ||||
|                                            "vibration_cali": self._settings.get_boolean(["vibration_cali"]), | ||||
|                                            "layer_inspect": self._settings.get_boolean(["layer_inspect"]), | ||||
|                                            "use_ams": self._settings.get_boolean(["use_ams"]) | ||||
|                                            } | ||||
|                                  } | ||||
|                 self.bambu.publish(print_command) | ||||
|  | ||||
|             while self._selectedSdFilePos < self._selectedSdFileSize: | ||||
|                 if self._killed or not self._sdPrinting: | ||||
|                     break | ||||
|  | ||||
|                 # if we are paused, wait for resuming | ||||
|                 self._sdPrintingSemaphore.wait() | ||||
|                 self._reportSdStatus() | ||||
|                 time.sleep(3) | ||||
|             self._logger.debug(f"SD File Print: {self._selectedSdFile}") | ||||
|         except AttributeError: | ||||
|             if self.outgoing is not None: | ||||
|                 raise | ||||
|  | ||||
|         self._finishSdPrint() | ||||
|  | ||||
|     def _finishSdPrint(self): | ||||
|         if not self._killed: | ||||
|             self._sdPrintingSemaphore.clear() | ||||
|             self._sdPrintingPausedSemaphore.clear() | ||||
|             self._send("Done printing file") | ||||
|             self._selectedSdFilePos = 0 | ||||
|             self._selectedSdFileSize = 0 | ||||
|             self._sdPrinting = False | ||||
|             self._sdPrinter = None | ||||
|  | ||||
|     def _deleteSdFile(self, filename: str) -> None: | ||||
|         host = self._settings.get(["host"]) | ||||
|         access_code = self._settings.get(["access_code"]) | ||||
|  | ||||
|         if filename.startswith("/"): | ||||
|             filename = filename[1:] | ||||
|         file = self._getSdFileData(filename) | ||||
|         if file is not None: | ||||
|             ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|             try: | ||||
|                 if ftp.delete_file(filename): | ||||
|                     self._logger.debug(f"{filename} deleted") | ||||
|                 else: | ||||
|                     raise Exception("delete failed") | ||||
|             except Exception as e: | ||||
|                 self._logger.debug(f"Error deleting file {filename}") | ||||
|  | ||||
|     def _setBusy(self, reason="processing"): | ||||
|         if not self._sendBusy: | ||||
|             return | ||||
|  | ||||
|         def loop(): | ||||
|             while self._busy: | ||||
|                 self._send(f"echo:busy {self._busy}") | ||||
|                 time.sleep(self._busyInterval) | ||||
|             self._sendOk() | ||||
|  | ||||
|         self._busy = reason | ||||
|         self._busy_loop = threading.Thread(target=loop) | ||||
|         self._busy_loop.daemon = True | ||||
|         self._busy_loop.start() | ||||
|  | ||||
|     def _setUnbusy(self): | ||||
|         self._busy = None | ||||
|  | ||||
|     # def _processBuffer(self): | ||||
|     #     while self.buffered is not None: | ||||
|     #         try: | ||||
|     #             line = self.buffered.get(timeout=0.5) | ||||
|     #         except queue.Empty: | ||||
|     #             continue | ||||
|     # | ||||
|     #         if line is None: | ||||
|     #             continue | ||||
|     # | ||||
|     #         self.buffered.task_done() | ||||
|     # | ||||
|     #     self._logger.debug("Closing down buffer loop") | ||||
|  | ||||
|     def _showPrompt(self, text, choices): | ||||
|         self._hidePrompt() | ||||
|         self._send(f"//action:prompt_begin {text}") | ||||
|         for choice in choices: | ||||
|             self._send(f"//action:prompt_button {choice}") | ||||
|         self._send("//action:prompt_show") | ||||
|  | ||||
|     def _hidePrompt(self): | ||||
|         self._send("//action:prompt_end") | ||||
|  | ||||
|     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.incoming is None or self.outgoing is None: | ||||
|                 return 0 | ||||
|  | ||||
|             if b"M112" in data: | ||||
|                 self._seriallog.debug(f"<<< {u_data}") | ||||
|                 self._kill() | ||||
|                 return len(data) | ||||
|  | ||||
|             try: | ||||
|                 written = self.incoming.put(data, timeout=self._write_timeout, partial=True) | ||||
|                 self._seriallog.debug(f"<<< {u_data}") | ||||
|                 return written | ||||
|             except queue.Full: | ||||
|                 self._logger.info( | ||||
|                     "Incoming queue is full, raising SerialTimeoutException" | ||||
|                 ) | ||||
|                 raise SerialTimeoutException() | ||||
|  | ||||
|     def readline(self) -> bytes: | ||||
|         timeout = self._read_timeout | ||||
|  | ||||
|         try: | ||||
|             # fetch a line from the queue, wait no longer than timeout | ||||
|             line = to_unicode(self.outgoing.get(timeout=timeout), errors="replace") | ||||
|             self._seriallog.debug(f">>> {line.strip()}") | ||||
|             self.outgoing.task_done() | ||||
|             return to_bytes(line) | ||||
|         except queue.Empty: | ||||
|             # queue empty? return empty line | ||||
|             return b"" | ||||
|  | ||||
|     def close(self): | ||||
|         if self.bambu.connected: | ||||
|             self.bambu.disconnect() | ||||
|         self._killed = True | ||||
|         self.incoming = None | ||||
|         self.outgoing = None | ||||
|         self.buffered = None | ||||
|  | ||||
|     def _sendOk(self): | ||||
|         if self.outgoing is None: | ||||
|             return | ||||
|         ok = self._ok() | ||||
|         if ok: | ||||
|             self._send(ok) | ||||
|  | ||||
|     def _isPaused(self): | ||||
|         return self._sdPrintingPausedSemaphore.is_set() | ||||
|     def _sendPaused(self): | ||||
|         paused_timer = RepeatedTimer(interval=3.0, function=self._send, args=[f"SD printing byte {self._selectedSdFilePos}/{self._selectedSdFileSize}"], | ||||
|                                      daemon=True, run_first=True, condition=self._isPaused) | ||||
|         paused_timer.start() | ||||
|  | ||||
|     def _send(self, line: str) -> None: | ||||
|         if self.outgoing is not None: | ||||
|             self.outgoing.put(line) | ||||
|  | ||||
|     def _ok(self): | ||||
|         return "ok" | ||||
|  | ||||
|     def _error(self, error: str, *args, **kwargs) -> str: | ||||
|         return f"Error: {self._errors.get(error).format(*args, **kwargs)}" | ||||
|  | ||||
| # noinspection PyUnresolvedReferences | ||||
| class CharCountingQueue(queue.Queue): | ||||
|     def __init__(self, maxsize, name=None): | ||||
|         queue.Queue.__init__(self, maxsize=maxsize) | ||||
|         self._size = 0 | ||||
|         self._name = name | ||||
|  | ||||
|     def clear(self): | ||||
|         with self.mutex: | ||||
|             self.queue.clear() | ||||
|  | ||||
|     def put(self, item, block=True, timeout=None, partial=False) -> int: | ||||
|         self.not_full.acquire() | ||||
|  | ||||
|         try: | ||||
|             if not self._will_it_fit(item) and partial: | ||||
|                 space_left = self.maxsize - self._qsize() | ||||
|                 if space_left: | ||||
|                     item = item[:space_left] | ||||
|  | ||||
|             if not block: | ||||
|                 if not self._will_it_fit(item): | ||||
|                     raise queue.Full | ||||
|             elif timeout is None: | ||||
|                 while not self._will_it_fit(item): | ||||
|                     self.not_full.wait() | ||||
|             elif timeout < 0: | ||||
|                 raise ValueError("'timeout' must be a positive number") | ||||
|             else: | ||||
|                 endtime = time.monotonic() + timeout | ||||
|                 while not self._will_it_fit(item): | ||||
|                     remaining = endtime - time.monotonic() | ||||
|                     if remaining <= 0: | ||||
|                         raise queue.Full | ||||
|                     self.not_full.wait(remaining) | ||||
|  | ||||
|             self._put(item) | ||||
|             self.unfinished_tasks += 1 | ||||
|             self.not_empty.notify() | ||||
|  | ||||
|             return self._len(item) | ||||
|         finally: | ||||
|             self.not_full.release() | ||||
|  | ||||
|     # noinspection PyMethodMayBeStatic | ||||
|     def _len(self, item): | ||||
|         return len(item) | ||||
|  | ||||
|     def _qsize(self, l=len):  # noqa: E741 | ||||
|         return self._size | ||||
|  | ||||
|     # Put a new item in the queue | ||||
|     def _put(self, item): | ||||
|         self.queue.append(item) | ||||
|         self._size += self._len(item) | ||||
|  | ||||
|     # Get an item from the queue | ||||
|     def _get(self): | ||||
|         item = self.queue.popleft() | ||||
|         self._size -= self._len(item) | ||||
|         return item | ||||
|  | ||||
|     def _will_it_fit(self, item): | ||||
|         return self.maxsize - self._qsize() >= self._len(item) | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										12
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								setup.py
									
									
									
									
									
								
							| @@ -14,26 +14,26 @@ plugin_package = "octoprint_bambu_printer" | ||||
| plugin_name = "OctoPrint-BambuPrinter" | ||||
|  | ||||
| # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module | ||||
| plugin_version = "0.0.1" | ||||
| plugin_version = "1.0.0" | ||||
|  | ||||
| # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin | ||||
| # module | ||||
| 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 | ||||
| plugin_author = "jneilliii" | ||||
| plugin_author = "ManuelW" | ||||
|  | ||||
| # 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 | ||||
| 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 | ||||
| plugin_license = "AGPLv3" | ||||
|  | ||||
| # Any additional requirements besides OctoPrint should be listed here | ||||
| plugin_requires = ["paho-mqtt", "pybambu>=1.0.0"] | ||||
| plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"] | ||||
|  | ||||
| ### -------------------------------------------------------------------------------------------------------------------- | ||||
| ### More advanced options that you usually shouldn't have to touch follow after this point | ||||
| @@ -61,7 +61,7 @@ plugin_ignored_packages = [] | ||||
| #     additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]} | ||||
| # "python_requires": ">=3,<4" blocks installation on Python 2 systems, to prevent confused users and provide a helpful error. | ||||
| # Remove it if you would like to support Python 2 as well as 3 (not recommended). | ||||
| additional_setup_parameters = {"python_requires": ">=3,<4"} | ||||
| additional_setup_parameters = {"python_requires": ">=3.9,<4"} | ||||
|  | ||||
| ######################################################################################################################## | ||||
|  | ||||
|   | ||||
							
								
								
									
										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