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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/lib/prusalink/auto_initializable.rb +224 -0
- data/lib/prusalink/base_resource.rb +92 -0
- data/lib/prusalink/client.rb +190 -0
- data/lib/prusalink/deserializable.rb +491 -0
- data/lib/prusalink/digest_auth.rb +80 -0
- data/lib/prusalink/exceptions.rb +36 -0
- data/lib/prusalink/files/file.rb +87 -0
- data/lib/prusalink/files/folder.rb +84 -0
- data/lib/prusalink/files/refs.rb +36 -0
- data/lib/prusalink/files.rb +184 -0
- data/lib/prusalink/info.rb +82 -0
- data/lib/prusalink/job/file.rb +70 -0
- data/lib/prusalink/job.rb +191 -0
- data/lib/prusalink/server_version.rb +70 -0
- data/lib/prusalink/status/camera.rb +26 -0
- data/lib/prusalink/status/job.rb +41 -0
- data/lib/prusalink/status/printer.rb +136 -0
- data/lib/prusalink/status/storage.rb +40 -0
- data/lib/prusalink/status/transfer.rb +40 -0
- data/lib/prusalink/status.rb +64 -0
- data/lib/prusalink/storage.rb +87 -0
- data/lib/prusalink/transfer.rb +108 -0
- data/lib/prusalink/types.rb +27 -0
- data/lib/prusalink/version.rb +7 -0
- data/lib/prusalink.rb +77 -0
- data/lib/tapioca/dsl/compilers/auto_initializable.rb +88 -0
- metadata +158 -0
|
@@ -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
|