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.
@@ -0,0 +1,80 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "net/http/digest_auth"
6
+
7
+ module Prusalink
8
+ # Faraday middleware for HTTP Digest authentication, the scheme PrusaLink uses.
9
+ #
10
+ # The first request goes out unsigned. When the printer answers 401 with a Digest challenge,
11
+ # the middleware signs the request and sends it again. It keeps the challenge, so later
12
+ # requests are signed up front and only retry when the printer rotates its nonce.
13
+ #
14
+ # @example
15
+ # Faraday.new("http://prusa-mk4.local") do |f|
16
+ # f.use Prusalink::DigestAuth, "maker", "password"
17
+ # end
18
+ class DigestAuth < Faraday::Middleware
19
+ extend T::Sig
20
+
21
+ sig { params(app: T.untyped, username: String, password: String).void }
22
+ def initialize(app, username, password)
23
+ super(app)
24
+ @username = username
25
+ @password = password
26
+ @digest = Net::HTTP::DigestAuth.new
27
+ @challenge = T.let(nil, T.nilable(String))
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ def call(env)
32
+ request_body = env.body
33
+ preflight(env) if request_body.respond_to?(:read) && @challenge.nil?
34
+ sign(env)
35
+
36
+ response = @app.call(env)
37
+ return response unless challenged?(response)
38
+
39
+ # The previous response replaced env.body, put the request body back before retrying
40
+ env.body = request_body
41
+ request_body.rewind if request_body.respond_to?(:rewind)
42
+ @mutex.synchronize { @challenge = response.headers["www-authenticate"] }
43
+ sign(env)
44
+
45
+ @app.call(env)
46
+ end
47
+
48
+ private
49
+
50
+ # Streams (file uploads) would be sent twice if the first attempt bounced off the challenge.
51
+ # A bodyless HEAD to the same URL fetches the challenge first, so the upload is signed up front.
52
+ def preflight(env)
53
+ head = env.dup
54
+ head.method = :head
55
+ head.body = nil
56
+ head.request_headers = Faraday::Utils::Headers.new(env.request_headers)
57
+ head.request_headers.delete("Content-Length")
58
+ head.request_headers.delete("Content-Type")
59
+
60
+ response = @app.call(head)
61
+ @mutex.synchronize { @challenge = response.headers["www-authenticate"] } if challenged?(response)
62
+ end
63
+
64
+ def challenged?(response)
65
+ response.status == 401 && response.headers["www-authenticate"].to_s.start_with?("Digest")
66
+ end
67
+
68
+ def sign(env)
69
+ @mutex.synchronize do
70
+ next if @challenge.nil?
71
+
72
+ uri = URI(env.url.to_s)
73
+ uri.user = CGI.escape(@username)
74
+ uri.password = CGI.escape(@password)
75
+
76
+ env.request_headers["Authorization"] = @digest.auth_header(uri, @challenge, env.method.to_s.upcase)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,36 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ # Prusalink exceptions
6
+ module Exceptions
7
+ # Base error.
8
+ class Error < StandardError; end
9
+ # The credentials were refused (HTTP 401/403)
10
+ class AuthenticationError < Error; end
11
+ # The request can't be processed because of invalid inputs (HTTP 400)
12
+ class BadRequestError < Error; end
13
+ # Credentials are missing from the configuration. Set them using `Prusalink.configure()`
14
+ class MissingCredentialsError < Error; end
15
+ # No client is configured for the current thread. Use `Prusalink.configure()` or `Client#use`
16
+ class NotConfiguredError < Error; end
17
+ # The resource doesn't exist (HTTP 404)
18
+ class NotFoundError < Error; end
19
+ # The printer's state doesn't allow the request, e.g. pausing an idle printer (HTTP 409)
20
+ class ConflictError < Error; end
21
+ # The uploaded media type isn't supported (HTTP 415)
22
+ class UnsupportedMediaTypeError < Error; end
23
+ # The printer encountered an unexpected condition (HTTP 500)
24
+ class InternalServerError < Error; end
25
+ # The printer doesn't implement this endpoint (HTTP 501)
26
+ class NotImplementedError < Error; end
27
+ # The printer is busy or not ready (HTTP 503)
28
+ class ServiceUnavailableError < Error; end
29
+ # Any other unexpected HTTP status
30
+ class UnknownError < Error; end
31
+ # The printer could not be reached (connection refused, host unreachable, DNS failure, ...)
32
+ class ConnectionError < Error; end
33
+ # The printer did not open the connection or respond within the configured timeouts
34
+ class TimeoutError < ConnectionError; end
35
+ end
36
+ end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ class Files
6
+ # A file on one of the printer's storages: a print file (G-code), a firmware file or any other file.
7
+ #
8
+ # @example
9
+ # file = Prusalink::Files.get(storage: "usb", path: "benchy.gcode")
10
+ # file.print_file? # => true
11
+ # file.display_name # => "benchy_0.4n_0.2mm_PLA_MK4_1h2m.gcode"
12
+ # file.meta[:"filament used [g]"] # => 15.42
13
+ class File
14
+ extend T::Sig
15
+ include AutoInitializable
16
+ include Deserializable
17
+
18
+ # @!attribute [r] name
19
+ # @return [String] Short file name (8.3 format on USB drives)
20
+ auto_attr :name, type: String
21
+
22
+ # @!attribute [r] display_name
23
+ # @return [String, nil] Long file name
24
+ auto_attr :display_name, type: String
25
+
26
+ # @!attribute [r] read_only
27
+ # @return [Boolean] Whether the file or its storage is read-only
28
+ auto_attr :read_only, type: T::Boolean
29
+
30
+ # @!attribute [r] size
31
+ # @return [Integer, nil] Size in bytes
32
+ auto_attr :size, type: Integer
33
+
34
+ # @!attribute [r] file_type
35
+ # @return [String] "PRINT_FILE", "FIRMWARE" or "FILE". Renamed from the API's `type`
36
+ auto_attr :file_type, type: String
37
+
38
+ # @!attribute [r] m_timestamp
39
+ # @return [Integer] Modification time, in seconds since the Unix epoch
40
+ auto_attr :m_timestamp, type: Integer
41
+
42
+ # @!attribute [r] refs
43
+ # @return [Files::Refs, nil] Links to download the file and to its thumbnails
44
+ auto_attr :refs, type: Files::Refs
45
+
46
+ # @!attribute [r] meta
47
+ # @return [Hash, nil] Metadata parsed from the file. The keys are kept as the printer sends them, some contain
48
+ # spaces (e.g. `:"filament used [g]"`, `:"estimated printing time (normal mode)"`)
49
+ auto_attr :meta, type: Hash
50
+
51
+ # @!attribute [r] extra
52
+ # @return [Hash] Any additional fields returned by the API
53
+ auto_attr :extra, type: Hash
54
+
55
+ auto_initialize!
56
+
57
+ deserialize_config do
58
+ nested :refs, Files::Refs
59
+ rename type: :file_type
60
+ end
61
+
62
+ # Whether the file can be printed (G-code, binary G-code or SL1)
63
+ #
64
+ # @return [Boolean]
65
+ sig { returns(T::Boolean) }
66
+ def print_file?
67
+ file_type == "PRINT_FILE"
68
+ end
69
+
70
+ # Whether the file is a firmware file
71
+ #
72
+ # @return [Boolean]
73
+ sig { returns(T::Boolean) }
74
+ def firmware?
75
+ file_type == "FIRMWARE"
76
+ end
77
+
78
+ # Always false, see {Files::Folder#folder?}
79
+ #
80
+ # @return [Boolean]
81
+ sig { returns(T::Boolean) }
82
+ def folder?
83
+ false
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,84 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ class Files
6
+ # A folder, or the root of a storage, with its content.
7
+ #
8
+ # @example
9
+ # folder = Prusalink::Files.get(storage: "usb")
10
+ # folder.children.select(&:print_file?).map(&:display_name) # => ["benchy.gcode"]
11
+ # folder.children.select(&:folder?).map(&:name) # => ["prints"]
12
+ class Folder
13
+ extend T::Sig
14
+ include AutoInitializable
15
+ include Deserializable
16
+
17
+ # @!attribute [r] name
18
+ # @return [String] Short folder name
19
+ auto_attr :name, type: String
20
+
21
+ # @!attribute [r] display_name
22
+ # @return [String, nil] Long folder name
23
+ auto_attr :display_name, type: String
24
+
25
+ # @!attribute [r] read_only
26
+ # @return [Boolean] Whether the folder or its storage is read-only
27
+ auto_attr :read_only, type: T::Boolean
28
+
29
+ # @!attribute [r] size
30
+ # @return [Integer, nil] Size in bytes. Usually absent for folders
31
+ auto_attr :size, type: Integer
32
+
33
+ # @!attribute [r] file_type
34
+ # @return [String] Always "FOLDER". Renamed from the API's `type`
35
+ auto_attr :file_type, type: String
36
+
37
+ # @!attribute [r] m_timestamp
38
+ # @return [Integer] Modification time, in seconds since the Unix epoch
39
+ auto_attr :m_timestamp, type: Integer
40
+
41
+ # @!attribute [r] children
42
+ # @return [Array<Files::File, Files::Folder>, nil] The folder's content. Nested folders don't include their
43
+ # own children
44
+ auto_attr :children, type: T.any(Files::File, Files::Folder), array: true
45
+
46
+ # @!attribute [r] extra
47
+ # @return [Hash] Any additional fields returned by the API
48
+ auto_attr :extra, type: Hash
49
+
50
+ auto_initialize!
51
+
52
+ deserialize_config do
53
+ rename type: :file_type
54
+ transform do |data|
55
+ data[:children] = data[:children].map { |child| Files.build_entry(child) } if data[:children].is_a?(Array)
56
+ end
57
+ end
58
+
59
+ # Always false
60
+ #
61
+ # @return [Boolean]
62
+ sig { returns(T::Boolean) }
63
+ def print_file?
64
+ false
65
+ end
66
+
67
+ # Always false
68
+ #
69
+ # @return [Boolean]
70
+ sig { returns(T::Boolean) }
71
+ def firmware?
72
+ false
73
+ end
74
+
75
+ # Always true
76
+ #
77
+ # @return [Boolean]
78
+ sig { returns(T::Boolean) }
79
+ def folder?
80
+ true
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,36 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ class Files
6
+ # Links to a file's download, icon and thumbnail. The paths are relative to the printer's host.
7
+ #
8
+ # @example
9
+ # file = Prusalink::Files.get(storage: "usb", path: "benchy.gcode")
10
+ # file.refs.download # => "/usb/benchy.gcode"
11
+ # file.refs.thumbnail # => "/thumb/l/usb/benchy.gcode"
12
+ class Refs
13
+ extend T::Sig
14
+ include AutoInitializable
15
+ include Deserializable
16
+
17
+ # @!attribute [r] download
18
+ # @return [String, nil] Path to download the file
19
+ auto_attr :download, type: String
20
+
21
+ # @!attribute [r] icon
22
+ # @return [String, nil] Path to the file's small thumbnail. Print files only
23
+ auto_attr :icon, type: String
24
+
25
+ # @!attribute [r] thumbnail
26
+ # @return [String, nil] Path to the file's full size thumbnail. Print files only
27
+ auto_attr :thumbnail, type: String
28
+
29
+ # @!attribute [r] extra
30
+ # @return [Hash] Any additional fields returned by the API
31
+ auto_attr :extra, type: Hash
32
+
33
+ auto_initialize!
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,184 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "erb"
5
+ require "stringio"
6
+ require "tempfile"
7
+
8
+ module Prusalink
9
+ # Files and folders on the printer's storages (USB drive, local storage, SD card).
10
+ #
11
+ # `storage` is the storage's name, with or without slashes ("usb", "/usb" or "local").
12
+ # `path` is relative to the storage, with or without a leading slash. Spaces and other special
13
+ # characters are URL-encoded for you.
14
+ #
15
+ # @example
16
+ # folder = Prusalink::Files.get(storage: "usb")
17
+ # folder.children.map(&:display_name) # => ["benchy.gcode", "prints"]
18
+ #
19
+ # Prusalink::Files.upload("benchy.gcode", storage: "usb", path: "prints/benchy.gcode", print_after_upload: true)
20
+ # Prusalink::Files.exists?(storage: "usb", path: "prints/benchy.gcode") # => true
21
+ # Prusalink::Files.delete(storage: "usb", path: "prints/benchy.gcode")
22
+ class Files < BaseResource
23
+ extend T::Sig
24
+
25
+ resource_path("/api/v1/files")
26
+
27
+ # Gets the metadata of a file, or a folder with its content.
28
+ #
29
+ # @param storage [String] the storage's name, e.g. "usb"
30
+ # @param path [String] path to the file or folder. Defaults to the storage's root
31
+ # @return [Files::File, Files::Folder]
32
+ # @raise [Prusalink::Exceptions::NotFoundError] if the file doesn't exist
33
+ sig { params(storage: String, path: String).returns(T.any(Files::File, Files::Folder)) }
34
+ def self.get(storage:, path: "")
35
+ build_entry(client.request(file_path(storage, path), http_method: :get))
36
+ end
37
+
38
+ # Uploads a file. The file is streamed, it isn't loaded in memory.
39
+ #
40
+ # @example From a path on disk
41
+ # Prusalink::Files.upload("benchy.gcode", storage: "usb", path: "benchy.gcode")
42
+ #
43
+ # @example From an IO
44
+ # Prusalink::Files.upload(StringIO.new(gcode), storage: "usb", path: "generated.gcode", overwrite: true)
45
+ #
46
+ # @param file [String, IO, StringIO, Tempfile] a path to a file on disk, or an IO with a known size
47
+ # @param storage [String] the storage's name, e.g. "usb"
48
+ # @param path [String] the destination path, including the file name
49
+ # @param print_after_upload [Boolean] whether to start printing the file once it is uploaded
50
+ # @param overwrite [Boolean] whether to replace an existing file
51
+ # @return [true]
52
+ # @raise [Prusalink::Exceptions::ConflictError] if the file already exists and overwrite is false
53
+ # @raise [ArgumentError] if the IO doesn't know its size (e.g. a pipe)
54
+ sig do
55
+ params(
56
+ file: T.any(String, IO, StringIO, Tempfile),
57
+ storage: String,
58
+ path: String,
59
+ print_after_upload: T::Boolean,
60
+ overwrite: T::Boolean
61
+ ).returns(T::Boolean)
62
+ end
63
+ def self.upload(file, storage:, path:, print_after_upload: false, overwrite: false)
64
+ destination = file_path(storage, path)
65
+ headers = {
66
+ "Content-Type" => "application/octet-stream",
67
+ "Print-After-Upload" => structured_boolean(print_after_upload),
68
+ "Overwrite" => structured_boolean(overwrite)
69
+ }
70
+
71
+ if file.is_a?(String)
72
+ ::File.open(file, "rb") { |io| upload_io(io, destination, headers) }
73
+ else
74
+ upload_io(file, destination, headers)
75
+ end
76
+ end
77
+
78
+ # Starts printing an existing file, if no print job is running.
79
+ #
80
+ # @param storage [String] the storage's name, e.g. "usb"
81
+ # @param path [String] path to the file
82
+ # @return [true]
83
+ # @raise [Prusalink::Exceptions::ConflictError] if the printer is busy
84
+ sig { params(storage: String, path: String).returns(T::Boolean) }
85
+ def self.print(storage:, path:)
86
+ post(path: file_path(storage, path))
87
+ end
88
+
89
+ # Deletes a file or a folder.
90
+ #
91
+ # Replaces {BaseResource.delete} with a signature that fits files, then calls it with the built path and
92
+ # headers. The signatures differ on purpose, hence `allow_incompatible`.
93
+ #
94
+ # @param storage [String] the storage's name, e.g. "usb"
95
+ # @param path [String] path to the file or folder
96
+ # @param force [Boolean] whether to delete a folder that isn't empty
97
+ # @return [true]
98
+ # @raise [Prusalink::Exceptions::ConflictError] if the file is being printed, or the folder isn't empty
99
+ sig do
100
+ override(allow_incompatible: true)
101
+ .params(storage: String, path: String, force: T::Boolean)
102
+ .returns(T::Boolean)
103
+ end
104
+ def self.delete(storage:, path:, force: false)
105
+ super(path: file_path(storage, path), headers: { "Force" => structured_boolean(force) })
106
+ end
107
+
108
+ # Whether a file or folder exists.
109
+ #
110
+ # @param storage [String] the storage's name, e.g. "usb"
111
+ # @param path [String] path to the file or folder
112
+ # @return [Boolean]
113
+ sig { params(storage: String, path: String).returns(T::Boolean) }
114
+ def self.exists?(storage:, path:)
115
+ head(path: file_path(storage, path))
116
+ true
117
+ rescue Exceptions::NotFoundError
118
+ false
119
+ end
120
+
121
+ # Whether a file is read-only, and whether it is being printed.
122
+ #
123
+ # @example
124
+ # Prusalink::Files.info(storage: "usb", path: "benchy.gcode")
125
+ # # => { read_only: false, currently_printed: true }
126
+ #
127
+ # @param storage [String] the storage's name, e.g. "usb"
128
+ # @param path [String] path to the file or folder
129
+ # @return [Hash{Symbol => Boolean}]
130
+ # @raise [Prusalink::Exceptions::NotFoundError] if the file doesn't exist
131
+ sig { params(storage: String, path: String).returns(T::Hash[Symbol, T::Boolean]) }
132
+ def self.info(storage:, path:)
133
+ headers = head(path: file_path(storage, path))
134
+
135
+ {
136
+ read_only: header_true?(headers["read-only"]),
137
+ currently_printed: header_true?(headers["currently-printed"])
138
+ }
139
+ end
140
+
141
+ # Builds a {Files::Folder} or a {Files::File} according to the entry's `type`.
142
+ #
143
+ # @param data [Hash] a file or folder, as returned by the API
144
+ # @return [Files::File, Files::Folder]
145
+ # @api private
146
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.any(Files::File, Files::Folder)) }
147
+ def self.build_entry(data)
148
+ data[:type] == "FOLDER" ? Files::Folder.deserialize(data) : Files::File.deserialize(data)
149
+ end
150
+
151
+ # Builds the request path, e.g. ("/usb/", "/my prints/benchy (1).gcode")
152
+ # gives "/api/v1/files/usb/my%20prints/benchy%20%281%29.gcode".
153
+ # Every segment is URL-encoded (spaces as %20, parentheses as %28 and %29), slashes are kept.
154
+ sig { params(storage: String, path: String).returns(String) }
155
+ private_class_method def self.file_path(storage, path)
156
+ segments = [storage, path].flat_map { |part| part.split("/") }.reject(&:empty?)
157
+
158
+ [@path, *segments.map { |segment| ERB::Util.url_encode(segment) }].join("/")
159
+ end
160
+
161
+ sig { params(io: T.any(IO, StringIO, Tempfile), path: String, headers: T::Hash[String, String]).returns(T::Boolean) }
162
+ private_class_method def self.upload_io(io, path, headers)
163
+ # PrusaLink requires a Content-Length, so the IO must know its size (File, StringIO, Tempfile).
164
+ # A pipe or a socket doesn't.
165
+ unless io.respond_to?(:size)
166
+ raise ArgumentError, "Can't upload a #{io.class} without a known size. Pass a path, a File or a StringIO"
167
+ end
168
+
169
+ put(path: path, params: io, headers: headers.merge("Content-Length" => T.unsafe(io).size.to_s))
170
+ end
171
+
172
+ # RFC 8941 boolean, the format PrusaLink expects in its headers
173
+ sig { params(value: T::Boolean).returns(String) }
174
+ private_class_method def self.structured_boolean(value)
175
+ value ? "?1" : "?0"
176
+ end
177
+
178
+ # PrusaLink sends booleans in headers as "true"/"false" or as RFC 8941 booleans ("?1"/"?0")
179
+ sig { params(value: T.nilable(String)).returns(T::Boolean) }
180
+ private_class_method def self.header_true?(value)
181
+ %w[true ?1 1].include?(value.to_s.strip.downcase)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,82 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ # General information about the printer: its name, location, serial number and hardware setup.
6
+ #
7
+ # @example
8
+ # info = Prusalink::Info.get
9
+ # info.name # => "MuadDib"
10
+ # info.nozzle_diameter # => 0.4
11
+ # info.mmu # => false
12
+ class Info < BaseResource
13
+ extend T::Sig
14
+ include AutoInitializable
15
+ include Deserializable
16
+
17
+ resource_path("/api/v1/info")
18
+
19
+ # @!attribute [r] mmu
20
+ # @return [Boolean] Whether a Multi Material Unit is attached
21
+ auto_attr :mmu, type: T::Boolean
22
+
23
+ # @!attribute [r] name
24
+ # @return [String] The printer's name, as set by the user
25
+ auto_attr :name, type: String
26
+
27
+ # @!attribute [r] location
28
+ # @return [String] The printer's location, as set by the user
29
+ auto_attr :location, type: String
30
+
31
+ # @!attribute [r] farm_mode
32
+ # @return [Boolean] Whether the printer runs in farm mode
33
+ auto_attr :farm_mode, type: T::Boolean
34
+
35
+ # @!attribute [r] nozzle_diameter
36
+ # @return [Float] The nozzle diameter, in millimeters
37
+ auto_attr :nozzle_diameter, type: Float
38
+
39
+ # @!attribute [r] min_extrusion_temp
40
+ # @return [Integer] The minimum nozzle temperature allowing extrusion, in °C
41
+ auto_attr :min_extrusion_temp, type: Integer
42
+
43
+ # @!attribute [r] serial
44
+ # @return [String] The printer's serial number
45
+ auto_attr :serial, type: String
46
+
47
+ # @!attribute [r] sd_ready
48
+ # @return [Boolean] Whether an SD card is inserted and ready
49
+ auto_attr :sd_ready, type: T::Boolean
50
+
51
+ # @!attribute [r] active_camera
52
+ # @return [Boolean] Whether a camera is active
53
+ auto_attr :active_camera, type: T::Boolean
54
+
55
+ # @!attribute [r] hostname
56
+ # @return [String] The printer's network hostname
57
+ auto_attr :hostname, type: String
58
+
59
+ # @!attribute [r] port
60
+ # @return [String] The serial port the printer is connected to, e.g. "/dev/tty"
61
+ auto_attr :port, type: String
62
+
63
+ # @!attribute [r] network_error_chime
64
+ # @return [Boolean] Whether the printer chimes on network errors
65
+ auto_attr :network_error_chime, type: T::Boolean
66
+
67
+ # @!attribute [r] extra
68
+ # @return [Hash] Any additional fields returned by the API
69
+ auto_attr :extra, type: Hash
70
+
71
+ auto_initialize!
72
+
73
+ # Retrieve general information about the printer.
74
+ #
75
+ # @return [Info, nil] nil when the printer answers 204 No Content
76
+ # @raise [Prusalink::Exceptions::Error] if the request fails
77
+ sig { returns(T.nilable(Info)) }
78
+ def self.get
79
+ fetch_resource
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,70 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ class Job
6
+ # The file being printed by the current job.
7
+ #
8
+ # @example
9
+ # file = Prusalink::Job.get.file
10
+ # file.display_name # => "Spice_Harvester_0.3mm_PLA_MK3S_12m.gcode"
11
+ # file.full_path # => "/usb/SPICE~1.gco"
12
+ class File
13
+ extend T::Sig
14
+ include AutoInitializable
15
+ include Deserializable
16
+
17
+ # @!attribute [r] name
18
+ # @return [String] Short filename, e.g. "SPICE~1.gco"
19
+ auto_attr :name, type: String
20
+
21
+ # @!attribute [r] display_name
22
+ # @return [String, nil] Long filename, e.g. "Spice_Harvester_0.3mm_PLA_MK3S_12m.gcode"
23
+ auto_attr :display_name, type: String
24
+
25
+ # @!attribute [r] path
26
+ # @return [String] Path of the folder containing the file, e.g. "/local"
27
+ auto_attr :path, type: String
28
+
29
+ # @!attribute [r] display_path
30
+ # @return [String, nil] Human readable path of the folder, e.g. "/PrusaLink gcodes"
31
+ auto_attr :display_path, type: String
32
+
33
+ # @!attribute [r] size
34
+ # @return [Integer, nil] File size in bytes
35
+ auto_attr :size, type: Integer
36
+
37
+ # @!attribute [r] m_timestamp
38
+ # @return [Integer] Modification time, in seconds since the epoch
39
+ auto_attr :m_timestamp, type: Integer
40
+
41
+ # @!attribute [r] meta
42
+ # @return [Hash, nil] Metadata parsed from the G-code. Keys are symbols and may contain spaces,
43
+ # e.g. :"filament used [g]"
44
+ auto_attr :meta, type: Hash
45
+
46
+ # @!attribute [r] refs
47
+ # @return [Hash, nil] Links to the file's download, icon and thumbnail, e.g. { download: "/api/files/..." }
48
+ auto_attr :refs, type: Hash
49
+
50
+ # @!attribute [r] extra
51
+ # @return [Hash] Any additional fields returned by the API
52
+ auto_attr :extra, type: Hash
53
+
54
+ auto_initialize!
55
+
56
+ # The complete path of the file on the printer, joining {#path} and {#name}.
57
+ #
58
+ # @example
59
+ # file.path # => "/usb"
60
+ # file.name # => "SPICE~1.gco"
61
+ # file.full_path # => "/usb/SPICE~1.gco"
62
+ #
63
+ # @return [String]
64
+ sig { returns(String) }
65
+ def full_path
66
+ [path.to_s.chomp("/"), name].join("/")
67
+ end
68
+ end
69
+ end
70
+ end