prusalink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 51c60c7c6eff2e6ac93c9bb3e3e95973f756dd33b01e5b7064667f9b3b460c9e
4
+ data.tar.gz: 1986ff7d2a7ef7f183e0259003181b3fc912adfc44e1aad9b0978ebbbaf90e4c
5
+ SHA512:
6
+ metadata.gz: c1e9d55a44bb104802c96a1232383b13a7e589d61e2abee09349d05efd0718c6bb2711ee1f05dfc9c6888a6a2d45fbf8337204c44501821650dc6ff592e49a91
7
+ data.tar.gz: 7d6a4f0e004066aeb8550b7368952548e616617c33c8b265ec110ec3ed60ce761b557cf0b732bf799bd9c7f2eb2f170e52cf6b9d5212144aae4d9c8a7fae6887
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-11-15
4
+
5
+ - Initial release: client with HTTP Digest auth, version, info, status, job, storage, transfer and files resources
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sophie Déziel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # PrusaLink Ruby Gem
2
+
3
+ A type-safe Ruby wrapper for **PrusaLink**, the local REST API built into Prusa 3D printers, with full Sorbet integration.
4
+
5
+ Works with printers running PrusaLink: MK4/MK4S, MK3.9, MK3.5, XL, MINI/MINI+ and Core One with recent firmware, and MK3/MK3S/MK2.5 with a Raspberry Pi running PrusaLink.
6
+
7
+ Same architecture as the [`octoprint`](https://github.com/sophiedeziel/octoprint) gem.
8
+
9
+ ## ✨ Key Features
10
+
11
+ - **🔐 Digest auth built in**: PrusaLink uses HTTP Digest authentication, and the gem handles the challenge, nonce rotation and retries for you. API keys (older firmware) work too.
12
+ - **🛡️ Type Safety**: Sorbet signatures on every resource, and RBI files generated for every attribute
13
+ - **🔥 Smart Error Handling**: HTTP statuses mapped to specific exception types
14
+ - **🎯 Multi-Printer Support**: thread-local clients, and `client.use { }` to talk to several printers
15
+ - **⚡ Modern Ruby**: Ruby 3.3+, Zeitwerk autoloading
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ gem "prusalink", github: "sophiedeziel/prusalink"
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Configuration
26
+
27
+ Turn on PrusaLink on the printer (Settings > Network > PrusaLink). The screen shows the username (`maker` by default) and the password.
28
+
29
+ ```ruby
30
+ require "prusalink"
31
+
32
+ Prusalink.configure(host: "http://192.168.1.42", password: "from-the-printer-screen")
33
+
34
+ # Older firmware with an API key
35
+ Prusalink.configure(host: "http://192.168.1.43", api_key: "abcdef")
36
+
37
+ # Custom username and timeouts (defaults: open_timeout 3s, timeout 10s)
38
+ Prusalink.configure(host: "http://prusa-xl.local", username: "maker", password: "...", timeout: 30)
39
+ ```
40
+
41
+ ### Several printers
42
+
43
+ ```ruby
44
+ mk4 = Prusalink::Client.new(host: "http://192.168.1.42", password: "...")
45
+ mini = Prusalink::Client.new(host: "http://192.168.1.43", api_key: "...")
46
+
47
+ mk4.use { Prusalink::Status.get }
48
+ mini.use { Prusalink::Status.get }
49
+ ```
50
+
51
+ ### Printer status and telemetry
52
+
53
+ ```ruby
54
+ status = Prusalink::Status.get
55
+ status.printer.state # => "PRINTING"
56
+ status.printer.printing? # => true
57
+ status.printer.temp_nozzle # => 214.9
58
+ status.printer.temp_bed # => 59.5
59
+ status.job&.progress # => 42.0
60
+ status.job&.time_remaining # => 520 (seconds)
61
+
62
+ info = Prusalink::Info.get
63
+ info.name # => "MuadDib"
64
+ info.serial # => "CZPX4720X004XC34242"
65
+
66
+ Prusalink::ServerVersion.get.text # => "PrusaLink"
67
+ ```
68
+
69
+ ### Jobs
70
+
71
+ ```ruby
72
+ job = Prusalink::Job.get # => nil when nothing is printing
73
+ job.state # => "PRINTING"
74
+ job.progress # => 42.0
75
+ job.file.display_name # => "benchy_0.4n_0.2mm_PLA_MK4_1h2m.bgcode"
76
+
77
+ job.pause
78
+ job.resume
79
+ job.stop
80
+
81
+ Prusalink::Job.pause(job.id) # or by id
82
+ ```
83
+
84
+ ### Files and storage
85
+
86
+ ```ruby
87
+ Prusalink::Storage.list.map(&:path) # => ["/usb"]
88
+
89
+ # Upload, and optionally start printing
90
+ Prusalink::Files.upload("benchy.bgcode", storage: "usb", path: "benchy.bgcode", print_after_upload: true)
91
+
92
+ # Browse
93
+ folder = Prusalink::Files.get(storage: "usb")
94
+ folder.children.map(&:display_name)
95
+
96
+ # Print, check and delete existing files
97
+ Prusalink::Files.print(storage: "usb", path: "benchy.bgcode")
98
+ Prusalink::Files.exists?(storage: "usb", path: "benchy.bgcode") # => true
99
+ Prusalink::Files.delete(storage: "usb", path: "benchy.bgcode")
100
+ ```
101
+
102
+ For large uploads, give the client a longer `timeout:`.
103
+
104
+ ### Transfers
105
+
106
+ ```ruby
107
+ transfer = Prusalink::Transfer.get # => nil when nothing is uploading
108
+ transfer&.progress # => 65.82
109
+ ```
110
+
111
+ ### Error handling
112
+
113
+ ```ruby
114
+ begin
115
+ Prusalink::Job.pause(42)
116
+ rescue Prusalink::Exceptions::ConflictError => e
117
+ # The printer isn't printing
118
+ rescue Prusalink::Exceptions::AuthenticationError
119
+ # Wrong password or API key
120
+ rescue Prusalink::Exceptions::TimeoutError, Prusalink::Exceptions::ConnectionError
121
+ # Printer offline or unreachable
122
+ end
123
+ ```
124
+
125
+ | Status | Exception |
126
+ |---|---|
127
+ | 400 | `BadRequestError` |
128
+ | 401, 403 | `AuthenticationError` |
129
+ | 404 | `NotFoundError` |
130
+ | 409 | `ConflictError` |
131
+ | 415 | `UnsupportedMediaTypeError` |
132
+ | 500 | `InternalServerError` |
133
+ | 501 | `NotImplementedError` |
134
+ | 503 | `ServiceUnavailableError` |
135
+ | other | `UnknownError` |
136
+
137
+ All of them inherit from `Prusalink::Exceptions::Error`.
138
+
139
+ ## API coverage
140
+
141
+ | Endpoint | Resource |
142
+ |---|---|
143
+ | `GET /api/version` | `ServerVersion.get` |
144
+ | `GET /api/v1/info` | `Info.get` |
145
+ | `GET /api/v1/status` | `Status.get` |
146
+ | `GET/DELETE /api/v1/job`, `PUT .../pause`, `.../resume`, `.../continue` | `Job` |
147
+ | `GET /api/v1/storage` | `Storage.list` |
148
+ | `GET/DELETE /api/v1/transfer` | `Transfer` |
149
+ | `GET/PUT/POST/HEAD/DELETE /api/v1/files/{storage}/{path}` | `Files` |
150
+ | `/api/v1/cameras/*` | not yet |
151
+ | `/api/v1/update/*` | not yet |
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ bin/setup # install dependencies
157
+ bin/check # rspec + rubocop + sorbet
158
+ bin/tapioca dsl # regenerate RBIs after changing auto_attr declarations
159
+ bin/console # interactive prompt
160
+ ```
161
+
162
+ To record VCR cassettes against a real printer, see [CLAUDE.md](CLAUDE.md).
163
+
164
+ ## License
165
+
166
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
167
+
168
+ ## Code of Conduct
169
+
170
+ Everyone interacting in this project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
@@ -0,0 +1,224 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ # AutoInitializable provides automatic initialization and typed attribute generation.
6
+ #
7
+ # This module eliminates repetitive initialization code by automatically generating:
8
+ # - Typed instance variables from keyword arguments
9
+ # - Sorbet-typed attr_reader methods
10
+ # - Type conversion for nested objects
11
+ #
12
+ # @example Basic usage
13
+ # class User
14
+ # include AutoInitializable
15
+ #
16
+ # auto_attr :name, type: String, nilable: false
17
+ # auto_attr :email, type: String
18
+ # auto_attr :age, type: Integer
19
+ #
20
+ # auto_initialize!
21
+ # end
22
+ #
23
+ # user = User.new(name: "John", email: "john@example.com", age: 30)
24
+ # user.name # => "John" (typed as String)
25
+ # user.email # => "john@example.com" (typed as T.nilable(String))
26
+ #
27
+ # @example With custom classes and arrays
28
+ # class Profile
29
+ # include AutoInitializable
30
+ #
31
+ # auto_attr :user, type: User
32
+ # auto_attr :tags, type: String, array: true
33
+ # auto_attr :metadata, type: Hash
34
+ #
35
+ # auto_initialize!
36
+ # end
37
+ #
38
+ # profile = Profile.new(
39
+ # user: { name: "John", email: "john@example.com" }, # Auto-converted to User
40
+ # tags: ["admin", "developer"], # Typed as T.nilable(T::Array[String])
41
+ # metadata: { created_at: Time.now } # Preserved as Hash
42
+ # )
43
+ #
44
+ # @example Type conversion behavior
45
+ # # Basic Ruby types (Hash, Array, String, Integer) are preserved as-is
46
+ # auto_attr :data, type: Hash # No conversion, preserves original hash
47
+ #
48
+ # # Custom classes are auto-converted from hash data
49
+ # auto_attr :user, type: User # Calls User.new(**hash_data)
50
+ #
51
+ # # Arrays of custom classes
52
+ # auto_attr :users, type: User, array: true # Maps each hash to User.new(**hash)
53
+ module AutoInitializable
54
+ extend T::Sig
55
+ extend T::Helpers
56
+
57
+ # Class methods added when AutoInitializable is included.
58
+ #
59
+ # These methods provide the DSL for declaring auto-initialized attributes
60
+ # and generating the corresponding initialization logic.
61
+ module ClassMethods
62
+ extend T::Sig
63
+
64
+ # Declares an attribute that will be automatically initialized and typed.
65
+ #
66
+ # @param name [Symbol] The name of the attribute
67
+ # @param type [Class, T.untyped] The type of the attribute (String, Integer, Hash, custom classes, etc.)
68
+ # @param from [Symbol, nil] The source key in initialization hash (defaults to name)
69
+ # @param array [Boolean] Whether this attribute is an array of the specified type
70
+ # @param nilable [Boolean] Whether this attribute can be nil
71
+ #
72
+ # @example Basic attributes
73
+ # auto_attr :name, type: String, nilable: false
74
+ # auto_attr :email, type: String
75
+ # auto_attr :age, type: Integer
76
+ #
77
+ # @example Arrays
78
+ # auto_attr :tags, type: String, array: true
79
+ # auto_attr :users, type: User, array: true
80
+ #
81
+ # @example Custom key mapping
82
+ # auto_attr :display_name, type: String, from: :display
83
+ #
84
+ # @example Complex types
85
+ # auto_attr :metadata, type: Hash
86
+ # auto_attr :profile, type: Profile
87
+ #
88
+ # @note Basic Ruby types (Hash, Array, String, Integer, Float) are preserved without conversion.
89
+ # Custom classes will be instantiated from hash data using Class.new(**hash).
90
+ sig do
91
+ params(
92
+ name: Symbol,
93
+ type: T.untyped,
94
+ from: T.nilable(Symbol),
95
+ array: T::Boolean,
96
+ nilable: T::Boolean
97
+ ).void
98
+ end
99
+ def auto_attr(name, type: T.untyped, from: nil, array: false, nilable: true)
100
+ @auto_attrs ||= {}
101
+ @auto_attrs[name] = {
102
+ type: type,
103
+ from: from || name,
104
+ array: array,
105
+ nilable: nilable
106
+ }
107
+ end
108
+
109
+ # Generates the initialize method and typed attr_readers based on declared auto_attr calls.
110
+ #
111
+ # This method must be called after all auto_attr declarations to generate the actual
112
+ # initialization code and Sorbet-typed attribute readers.
113
+ #
114
+ # @example
115
+ # class User
116
+ # include AutoInitializable
117
+ #
118
+ # auto_attr :name, type: String
119
+ # auto_attr :age, type: Integer
120
+ #
121
+ # auto_initialize! # Must be called after auto_attr declarations
122
+ # end
123
+ #
124
+ # @note This method generates:
125
+ # - An initialize method that accepts keyword arguments
126
+ # - Type conversion for nested objects
127
+ # - Sorbet-typed attr_reader methods
128
+ # - Automatic validation that required attributes are provided
129
+ sig { void }
130
+ def auto_initialize!
131
+ attrs = @auto_attrs || {}
132
+ klass = self
133
+
134
+ T.unsafe(self).define_method :initialize do |**kwargs|
135
+ attrs.each do |name, config|
136
+ source_key = config[:from]
137
+ value = kwargs[source_key]
138
+
139
+ # Skip if nil and nilable
140
+ if value.nil?
141
+ T.unsafe(self).instance_variable_set("@#{name}", nil)
142
+ next
143
+ end
144
+
145
+ # Handle type conversion
146
+ converted_value = if config[:type]
147
+ if config[:array] && value.is_a?(Array)
148
+ # Handle array of objects
149
+ value.map do |item|
150
+ T.unsafe(klass).send(:convert_value, item, config[:type])
151
+ end
152
+ else
153
+ T.unsafe(klass).send(:convert_value, value, config[:type])
154
+ end
155
+ else
156
+ value
157
+ end
158
+
159
+ T.unsafe(self).instance_variable_set("@#{name}", converted_value)
160
+ end
161
+
162
+ # Call parent initializer if it exists and has an initialize method
163
+ return unless T.unsafe(self).class.superclass.method_defined?(:initialize)
164
+
165
+ super()
166
+ end
167
+
168
+ # Create simple attr_readers
169
+ # Type signatures are provided by generated RBI files
170
+ attrs.each_key do |name|
171
+ T.unsafe(self).attr_reader name
172
+ end
173
+ end
174
+
175
+ # Returns the hash of declared attributes and their configuration.
176
+ #
177
+ # @return [Hash<Symbol, Hash>] Hash mapping attribute names to their configuration
178
+ #
179
+ # @example
180
+ # User.auto_attrs
181
+ # # => {
182
+ # # name: { type: String, from: :name, array: false, nilable: false },
183
+ # # email: { type: String, from: :email, array: false, nilable: true }
184
+ # # }
185
+ sig { returns(T::Hash[Symbol, T.untyped]) }
186
+ def auto_attrs
187
+ @auto_attrs || {}
188
+ end
189
+
190
+ private
191
+
192
+ # Converts a value to the specified type if appropriate.
193
+ #
194
+ # This method handles type conversion for nested objects during initialization.
195
+ # Basic Ruby types (Hash, Array, String, etc.) are preserved as-is to avoid
196
+ # unwanted conversions. Custom classes are instantiated from hash data.
197
+ #
198
+ # @param value [Object] The value to potentially convert
199
+ # @param type [Class] The target type for conversion
200
+ # @return [Object] The converted value or original value if no conversion needed
201
+ #
202
+ # @example
203
+ # convert_value({ name: "John" }, User) # => User.new(name: "John")
204
+ # convert_value({ key: "value" }, Hash) # => { key: "value" } (unchanged)
205
+ # convert_value("text", String) # => "text" (unchanged)
206
+ sig { params(value: T.untyped, type: T.untyped).returns(T.untyped) }
207
+ def convert_value(value, type)
208
+ return value unless value.is_a?(Hash)
209
+
210
+ # Don't try to convert basic Ruby types like Hash, Array, String, etc.
211
+ return value if [Hash, Array, String, Integer, Float, TrueClass, FalseClass].include?(type)
212
+
213
+ # Check if type is a class that responds to new (for our custom classes)
214
+ if type.is_a?(Class) && type.respond_to?(:new)
215
+ type.new(**value)
216
+ else
217
+ value
218
+ end
219
+ end
220
+ end
221
+
222
+ mixes_in_class_methods(ClassMethods)
223
+ end
224
+ end
@@ -0,0 +1,92 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ # Base class for all API resources.
6
+ class BaseResource
7
+ class << self
8
+ extend T::Sig
9
+
10
+ # Defines the path for the resource's endpoint
11
+ #
12
+ # @param [String] path The API's endpoint path
13
+ sig { params(path: String).void }
14
+ def resource_path(path)
15
+ @path = path
16
+ end
17
+
18
+ # Gets a single resource and instantiates the object.
19
+ #
20
+ # @param path [String, nil] appended to the resource path
21
+ # @param deserialize [Boolean] whether to build an object from the response
22
+ # @param options [Hash] query string parameters
23
+ # @return [BaseResource, Hash, nil] nil when the printer answers 204 No Content
24
+ def fetch_resource(path = nil, deserialize: true, options: {})
25
+ path = [@path, path].compact.join("/")
26
+ path += "?#{Faraday::Utils.build_query(options)}" if options.any?
27
+ response = client.request(path, http_method: :get)
28
+ return nil if response.nil?
29
+ return deserialize(response) if deserialize
30
+
31
+ response
32
+ end
33
+
34
+ # Sends a POST request to the resource's endpoint.
35
+ sig do
36
+ params(path: String, params: T.untyped, headers: T::Hash[T.any(String, Symbol), String]).returns(T.untyped)
37
+ end
38
+ def post(path: @path, params: nil, headers: {})
39
+ client.request(path, http_method: :post, body: params, headers: headers)
40
+ end
41
+
42
+ # Sends a PUT request to the resource's endpoint. `params` may be a Hash (sent as JSON),
43
+ # a String or an IO (sent as-is).
44
+ sig do
45
+ params(path: String, params: T.untyped, headers: T::Hash[T.any(String, Symbol), String]).returns(T.untyped)
46
+ end
47
+ def put(path: @path, params: nil, headers: {})
48
+ client.request(path, http_method: :put, body: params, headers: headers)
49
+ end
50
+
51
+ # Sends a PATCH request to the resource's endpoint.
52
+ sig do
53
+ params(path: String, params: T.untyped, headers: T::Hash[T.any(String, Symbol), String]).returns(T.untyped)
54
+ end
55
+ def patch(path: @path, params: nil, headers: {})
56
+ client.request(path, http_method: :patch, body: params, headers: headers)
57
+ end
58
+
59
+ # Sends a DELETE request to the resource's endpoint.
60
+ sig { params(path: String, headers: T::Hash[T.any(String, Symbol), String]).returns(T.untyped) }
61
+ def delete(path: @path, headers: {})
62
+ client.request(path, http_method: :delete, headers: headers)
63
+ end
64
+
65
+ # Sends a HEAD request to the resource's endpoint and returns the response headers.
66
+ sig { params(path: String).returns(T::Hash[String, String]) }
67
+ def head(path: @path)
68
+ client.head(path)
69
+ end
70
+
71
+ # Instantiates an object from a hash. Can be overridden by child classes
72
+ #
73
+ # @param [Hash] attrs the object's attributes
74
+ # @return [BaseResource]
75
+ def deserialize(attrs)
76
+ new(**attrs)
77
+ end
78
+
79
+ private
80
+
81
+ sig { returns(Client) }
82
+ def client
83
+ client = Prusalink.client
84
+ raise Exceptions::NotConfiguredError, "No client configured. Use Prusalink.configure" unless client
85
+
86
+ client
87
+ end
88
+ end
89
+
90
+ def initialize(**attrs); end
91
+ end
92
+ end