nanokvm 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,793 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "terminal-table"
5
+ require "yaml"
6
+ require "fileutils"
7
+ require "net/http"
8
+ require "json"
9
+
10
+ module NanoKVM
11
+ class CLI < Thor
12
+ CONFIG_DIR = File.expand_path("~/.config/nanokvm")
13
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
14
+
15
+ def self.exit_on_failure?
16
+ true
17
+ end
18
+
19
+ # Global options
20
+ class_option :debug, type: :boolean, desc: "Enable debug output"
21
+ class_option :secret_key, type: :string, desc: "Secret key for password encryption (default: nanokvm-sipeed-2024)"
22
+ class_option :host, type: :string, desc: "NanoKVM host (IP or hostname)"
23
+ class_option :username, type: :string, desc: "Username (default: admin)"
24
+ class_option :password, type: :string, desc: "Password (default: admin)"
25
+
26
+ desc "configure", "Configure default settings"
27
+ option :token, type: :string, desc: "Default token (optional)"
28
+ def configure
29
+ unless Dir.exist?(CONFIG_DIR)
30
+ FileUtils.mkdir_p(CONFIG_DIR)
31
+ end
32
+
33
+ # Load existing config
34
+ config = if File.exist?(CONFIG_FILE)
35
+ File.open(CONFIG_FILE) { |f| YAML.safe_load(f, symbolize_names: true) } rescue {}
36
+ else
37
+ {}
38
+ end
39
+
40
+ # Update with provided options
41
+ config[:host] = options[:host] if options[:host]
42
+ config[:username] = options[:username] if options[:username]
43
+ config[:password] = options[:password] if options[:password]
44
+ config[:token] = options[:token] if options[:token]
45
+ config[:secret_key] = options[:secret_key] if options[:secret_key]
46
+
47
+ # Save config
48
+ File.open(CONFIG_FILE, "w") { |f| f.write(config.to_yaml) }
49
+
50
+ # Update current configuration
51
+ NanoKVM.configure do |c|
52
+ c.default_host = config[:host]
53
+ c.default_username = config[:username]
54
+ c.default_password = config[:password]
55
+ end
56
+
57
+ puts "Configuration saved"
58
+ end
59
+
60
+ desc "info", "Get device information"
61
+ def info
62
+ display_as_table("NanoKVM Information", :get_device_info)
63
+ end
64
+
65
+ desc "power", "Press power button"
66
+ option :duration, type: :numeric, default: 800, desc: "Button press duration in ms (800=short, 8000=long)"
67
+ def power
68
+ client = authenticated_client
69
+ client.power_button(options[:duration])
70
+ puts "Power button pressed for #{options[:duration]}ms"
71
+ end
72
+
73
+ desc "reset", "Press reset button"
74
+ def reset
75
+ client = authenticated_client
76
+ client.reset_button
77
+ puts "Reset button pressed"
78
+ end
79
+
80
+ desc "leds", "Get LED status"
81
+ def leds
82
+ display_as_table("NanoKVM LEDs", :get_gpio_state)
83
+ end
84
+
85
+ desc "images", "List available images"
86
+ def images
87
+ client = create_client
88
+ ensure_authenticated(client)
89
+
90
+ begin
91
+ # Get available images
92
+ available = client.get_available_images["data"]["files"] rescue []
93
+
94
+ # Get mounted image for comparison
95
+ mounted = begin
96
+ client.get_mounted_image["data"]["file"]
97
+ rescue
98
+ nil
99
+ end
100
+
101
+ rows = []
102
+ if available.nil?
103
+ puts "No images available"
104
+ return
105
+ end
106
+
107
+ available.each do |image|
108
+ rows << [image, image == mounted ? "MOUNTED" : ""]
109
+ end
110
+
111
+ if rows.empty?
112
+ puts "No images available"
113
+ return
114
+ end
115
+
116
+ table = ::Terminal::Table.new(
117
+ title: "Available Images",
118
+ headings: ["Image Path", "Status"],
119
+ rows: rows
120
+ )
121
+
122
+ puts table
123
+ rescue NanoKVM::ApiError => e
124
+ puts "Error listing images: #{e.message}"
125
+ puts "This feature may not be available on your NanoKVM device/firmware."
126
+ end
127
+ end
128
+
129
+ desc "mount IMAGE_PATH", "Mount an image"
130
+ def mount(image_path)
131
+ client = authenticated_client
132
+ client.mount_image(image_path)
133
+ puts "Image #{image_path} mounted successfully"
134
+ end
135
+
136
+ desc "type TEXT", "Send text to the target"
137
+ def type(text)
138
+ client = authenticated_client
139
+ client.send_text(text)
140
+ puts "Text sent successfully"
141
+ end
142
+
143
+ desc "virtual", "Show virtual device status"
144
+ def virtual
145
+ display_as_table("Virtual Devices", :get_virtual_device_status)
146
+ end
147
+
148
+ desc "toggle DEVICE", "Toggle virtual device (network or disk)"
149
+ def toggle(device)
150
+ unless ["network", "disk"].include?(device)
151
+ puts "Error: Device must be 'network' or 'disk'"
152
+ return
153
+ end
154
+
155
+ client = authenticated_client
156
+ result = client.toggle_virtual_device(device)
157
+ state = result["data"]["on"]
158
+
159
+ puts "#{device.capitalize} is now #{state ? 'ENABLED' : 'DISABLED'}"
160
+ end
161
+
162
+ desc "account", "Get account information"
163
+ def account
164
+ display_as_table("NanoKVM Account Information", :get_account_info)
165
+ end
166
+
167
+ desc "network", "Get network information"
168
+ def network
169
+ display_as_table("NanoKVM Network Information", :get_network_info)
170
+ end
171
+
172
+ desc "version", "Get application version"
173
+ def version
174
+ display_as_table("NanoKVM Application Version", :get_app_version)
175
+ end
176
+
177
+ desc "reboot", "Reboot the NanoKVM system"
178
+ def reboot
179
+ client = authenticated_client
180
+ puts "Rebooting NanoKVM system..."
181
+ client.system_reboot
182
+ puts "Reboot command sent successfully."
183
+ puts "Note: The device will be unavailable during the reboot process."
184
+ end
185
+
186
+ desc "reset-hdmi", "Reset HDMI subsystem"
187
+ def reset_hdmi
188
+ client = authenticated_client
189
+ puts "Resetting HDMI subsystem..."
190
+ client.reset_hdmi
191
+ puts "HDMI reset command sent successfully."
192
+ end
193
+
194
+ desc "reset-hid", "Reset HID (keyboard/mouse) subsystem"
195
+ def reset_hid
196
+ client = authenticated_client
197
+ puts "Resetting HID subsystem..."
198
+ client.reset_hid
199
+ puts "HID reset command sent successfully."
200
+ end
201
+
202
+ desc "is-password-updated", "Check if password has been updated from default"
203
+ def is_password_updated
204
+ client = authenticated_client
205
+ begin
206
+ response = client.is_password_updated["data"]
207
+ updated = response["updated"] == true
208
+ puts "Password status: #{updated ? 'Updated from default' : 'Still using default password'}"
209
+ rescue NanoKVM::ApiError => e
210
+ puts "Error checking password status: #{e.message}"
211
+ puts "This feature may not be available on your NanoKVM device/firmware."
212
+ end
213
+ end
214
+
215
+ desc "tailscale", "Check Tailscale extension status"
216
+ def tailscale
217
+ display_as_table("NanoKVM Tailscale Status", :get_tailscale_status)
218
+ end
219
+
220
+ desc "download-enabled", "Check if downloading images is enabled"
221
+ def download_enabled
222
+ client = authenticated_client
223
+ begin
224
+ response = client.is_download_image_enabled["data"]
225
+ enabled = response["enabled"] == true
226
+ puts "Image download: #{enabled ? 'ENABLED' : 'DISABLED'}"
227
+ rescue NanoKVM::ApiError => e
228
+ puts "Error checking image download status: #{e.message}"
229
+ puts "This feature may not be available on your NanoKVM device/firmware."
230
+ end
231
+ end
232
+
233
+ desc "download-status", "Check status of image download"
234
+ option :monitor, type: :boolean, desc: "Monitor until download completes"
235
+ option :interval, type: :numeric, default: 3, desc: "Refresh interval in seconds when monitoring"
236
+ option :timeout, type: :numeric, desc: "Stop monitoring after this many seconds"
237
+ def download_status
238
+ client = authenticated_client
239
+ begin
240
+ if options[:monitor]
241
+ show_download_progress(client, options[:interval], options[:timeout])
242
+ else
243
+ display_download_status(client)
244
+ end
245
+ rescue NanoKVM::ApiError => e
246
+ puts "Error checking image download status: #{e.message}"
247
+ puts "This feature may not be available on your NanoKVM device/firmware."
248
+ rescue Interrupt
249
+ puts "\nMonitoring stopped"
250
+ end
251
+ end
252
+
253
+ desc "download-image URL", "Download an image from URL"
254
+ def download_image(url)
255
+ client = authenticated_client
256
+ begin
257
+ puts "Starting download of #{url}..."
258
+ client.download_image(url)
259
+ puts "Download initiated successfully."
260
+ puts "Use 'nanokvm download-status' to check progress."
261
+ rescue NanoKVM::ApiError => e
262
+ puts "Error starting image download: #{e.message}"
263
+ puts "This feature may not be available on your NanoKVM device/firmware."
264
+ end
265
+ end
266
+
267
+ desc "hardware", "Get hardware information"
268
+ def hardware
269
+ display_as_table("NanoKVM Hardware Information", :get_hardware_info)
270
+ end
271
+
272
+ desc "memory-limit", "Get or set memory limit"
273
+ option :enabled, type: :boolean, desc: "Enable or disable memory limit"
274
+ option :limit, type: :numeric, desc: "Memory limit in MB"
275
+ def memory_limit
276
+ client = authenticated_client
277
+
278
+ if options[:enabled].nil? && options[:limit].nil?
279
+ # Get current setting
280
+ display_as_table("Memory Limit Settings", :get_memory_limit)
281
+ else
282
+ # Set new values
283
+ unless options[:enabled] && options[:limit]
284
+ puts "Error: Both --enabled and --limit are required to set memory limit"
285
+ return
286
+ end
287
+
288
+ begin
289
+ client.set_memory_limit(options[:enabled], options[:limit])
290
+ puts "Memory limit updated successfully"
291
+ rescue NanoKVM::ApiError => e
292
+ puts "Error setting memory limit: #{e.message}"
293
+ puts "This feature may not be available on your NanoKVM device/firmware."
294
+ end
295
+ end
296
+ end
297
+
298
+ desc "oled", "Get or set OLED settings"
299
+ option :sleep, type: :numeric, desc: "Sleep timeout in seconds"
300
+ def oled
301
+ client = authenticated_client
302
+
303
+ if options[:sleep].nil?
304
+ # Get current setting
305
+ display_as_table("OLED Settings", :get_oled_info)
306
+ else
307
+ # Set new sleep timeout
308
+ begin
309
+ client.set_oled_sleep(options[:sleep])
310
+ puts "OLED sleep timeout updated to #{options[:sleep]} seconds"
311
+ rescue NanoKVM::ApiError => e
312
+ puts "Error setting OLED sleep timeout: #{e.message}"
313
+ puts "This feature may not be available on your NanoKVM device/firmware."
314
+ end
315
+ end
316
+ end
317
+
318
+ desc "ssh-status", "Get SSH server status"
319
+ def ssh_status
320
+ display_as_table("SSH Server Status", :get_ssh_status)
321
+ end
322
+
323
+ desc "cdrom", "Get CD-ROM information"
324
+ def cdrom
325
+ display_as_table("CD-ROM Information", :get_cdrom_info)
326
+ end
327
+
328
+ desc "hid-mode", "Get or set HID mode"
329
+ option :mode, type: :string, desc: "HID mode (usb or ps2)"
330
+ def hid_mode
331
+ client = authenticated_client
332
+
333
+ if options[:mode].nil?
334
+ # Get current setting
335
+ display_as_table("HID Mode", :get_hid_mode)
336
+ else
337
+ # Set new mode
338
+ unless ["usb", "ps2"].include?(options[:mode].downcase)
339
+ puts "Error: Mode must be 'usb' or 'ps2'"
340
+ return
341
+ end
342
+
343
+ begin
344
+ client.set_hid_mode(options[:mode].downcase)
345
+ puts "HID mode updated to #{options[:mode]}"
346
+ puts "Note: This change requires a reboot to take effect"
347
+ rescue NanoKVM::ApiError => e
348
+ puts "Error setting HID mode: #{e.message}"
349
+ puts "This feature may not be available on your NanoKVM device/firmware."
350
+ end
351
+ end
352
+ end
353
+
354
+ desc "script", "Get script information"
355
+ def script
356
+ display_as_table("Script Information", :get_script_info)
357
+ end
358
+
359
+ desc "mdns", "Get mDNS status"
360
+ def mdns
361
+ display_as_table("mDNS Status", :get_mdns_status)
362
+ end
363
+
364
+ desc "wol-mac", "Get Wake-on-LAN MAC address"
365
+ def wol_mac
366
+ display_as_table("Wake-on-LAN MAC Address", :get_wol_mac)
367
+ end
368
+
369
+ desc "preview", "Get or set preview state"
370
+ option :enable, type: :boolean, desc: "Enable or disable preview"
371
+ def preview
372
+ client = authenticated_client
373
+
374
+ if options[:enable].nil?
375
+ # Get current setting
376
+ display_as_table("Preview State", :get_preview_state)
377
+ else
378
+ # Set new state
379
+ begin
380
+ client.set_preview_state(options[:enable])
381
+ puts "Preview #{options[:enable] ? 'enabled' : 'disabled'}"
382
+ rescue NanoKVM::ApiError => e
383
+ puts "Error setting preview state: #{e.message}"
384
+ puts "This feature may not be available on your NanoKVM device/firmware."
385
+ end
386
+ end
387
+ end
388
+
389
+ desc "test", "Test connection to NanoKVM"
390
+ def test
391
+ puts "Testing connection to NanoKVM..."
392
+
393
+ client = create_client
394
+ client.debug = true
395
+
396
+ begin
397
+ puts "Connecting to #{client.host}..."
398
+ # Try a simple HTTP GET to see if the server is up
399
+ uri = URI.parse("http://#{client.host}")
400
+ response = Net::HTTP.get_response(uri)
401
+ puts "Server responded with status: #{response.code}"
402
+
403
+ if response.code == "200"
404
+ puts "Connection successful, attempting authentication..."
405
+ ensure_authenticated(client)
406
+ puts "Authentication successful!"
407
+ else
408
+ puts "Server responded, but with an unexpected status code"
409
+ end
410
+ rescue => e
411
+ puts "Connection failed: #{e.message}"
412
+ end
413
+ end
414
+
415
+ desc "interfaces", "Get status of HID, ETH, and HDMI interfaces"
416
+ def interfaces
417
+ display_as_table("Interface Status", :get_interface_status)
418
+ end
419
+
420
+ private
421
+
422
+ def load_config
423
+ return {} unless File.exist?(CONFIG_FILE)
424
+
425
+ YAML.safe_load(File.read(CONFIG_FILE), symbolize_names: true) rescue {}
426
+ end
427
+
428
+ def create_client
429
+ # Load saved config
430
+ config = load_config
431
+
432
+ # Command-line options override saved config
433
+ host = options[:host] || config[:host] || NanoKVM.configuration.default_host
434
+ username = options[:username] || config[:username] || NanoKVM.configuration.default_username
435
+ password = options[:password] || config[:password] || NanoKVM.configuration.default_password
436
+ token = config[:token]
437
+ debug = options[:debug]
438
+ secret_key = options[:secret_key] || config[:secret_key]
439
+
440
+ unless host
441
+ puts "Error: Host is required. Use --host or configure a default host with 'nanokvm configure'"
442
+ exit 1
443
+ end
444
+
445
+ NanoKVM::Client.new(
446
+ host: host,
447
+ username: username,
448
+ password: password,
449
+ token: token,
450
+ debug: debug,
451
+ secret_key: secret_key
452
+ )
453
+ end
454
+
455
+ def ensure_authenticated(client)
456
+ # Try using existing token
457
+ return if client.token
458
+
459
+ begin
460
+ client.login
461
+ rescue => e
462
+ puts "Authentication failed: #{e.message}"
463
+ puts "\nTroubleshooting tips:"
464
+ puts "1. Check your username and password"
465
+ puts "2. If you recently changed your password, run 'nanokvm configure'"
466
+ puts "3. Try different encryption key with --secret-key option"
467
+ puts "4. Check if your device has connectivity issues"
468
+ exit 1
469
+ end
470
+ end
471
+
472
+ # Helper method to get an authenticated client
473
+ def authenticated_client
474
+ client = create_client
475
+ ensure_authenticated(client)
476
+ client
477
+ end
478
+
479
+ # Helper method to display API data as a table
480
+ def display_as_table(title, data_method, *args)
481
+ client = authenticated_client
482
+
483
+ begin
484
+ data = client.send(data_method, *args)["data"]
485
+
486
+ rows = []
487
+ data.each do |key, value|
488
+ value_str = case value
489
+ when Hash, Array
490
+ value.to_json
491
+ else
492
+ value.to_s
493
+ end
494
+ rows << [key, value_str]
495
+ end
496
+
497
+ table = ::Terminal::Table.new(
498
+ title: title,
499
+ rows: rows
500
+ )
501
+
502
+ puts table
503
+ rescue NanoKVM::ApiError => e
504
+ puts "Error getting #{title.downcase}: #{e.message}"
505
+ puts "This feature may not be available on your NanoKVM device/firmware."
506
+ end
507
+ end
508
+
509
+ # Store previous download info for estimation
510
+ @previous_download_info = nil
511
+
512
+ # Get file size from URL using a HEAD request
513
+ def get_file_size_from_url(url)
514
+ begin
515
+ uri = URI.parse(url)
516
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
517
+ http.request_head(uri.path)
518
+ end
519
+
520
+ if response['content-length']
521
+ return response['content-length'].to_i
522
+ end
523
+ rescue => e
524
+ puts "Warning: Failed to get file size from URL: #{e.message}" if @debug
525
+ end
526
+ nil
527
+ end
528
+
529
+ def display_download_status(client)
530
+ status = client.get_download_image_status["data"]
531
+
532
+ rows = []
533
+ status.each do |key, value|
534
+ value_str = if value.is_a?(Hash)
535
+ value.to_json
536
+ else
537
+ value.to_s
538
+ end
539
+ rows << [key, value_str]
540
+ end
541
+
542
+ # Add time estimate if download in progress
543
+ if status["status"] == "in_progress"
544
+ # Extract progress from percentage string if needed
545
+ progress = if status["percentage"].to_s.include?("%")
546
+ status["percentage"].to_s.gsub("%", "").strip.to_f
547
+ else
548
+ status["progress"].to_f
549
+ end
550
+
551
+ # Try to get total file size if available or fetch from URL
552
+ total_size = status["size"].to_i
553
+ if total_size == 0 && status["file"].to_s.start_with?("http")
554
+ total_size = get_file_size_from_url(status["file"])
555
+ end
556
+
557
+ # Check if we have size information
558
+ if total_size.to_i > 0
559
+ downloaded = (total_size * (progress / 100.0)).to_i
560
+
561
+ # Add download size info
562
+ rows << ["downloaded", "#{downloaded} bytes (#{(downloaded / 1024.0 / 1024.0).round(2)} MB)"]
563
+ rows << ["total_size", "#{total_size} bytes (#{(total_size / 1024.0 / 1024.0).round(2)} MB)"]
564
+
565
+ # If we have enough information to calculate
566
+ if downloaded > 0 && progress > 0
567
+ start_time = status["start_time"].to_i rescue 0
568
+
569
+ if start_time > 0
570
+ elapsed_time = Time.now.to_i - start_time
571
+ else
572
+ # Use a default value if start_time is not available
573
+ elapsed_time = 60 # Assume 1 minute has passed for a basic estimate
574
+ end
575
+
576
+ if elapsed_time > 0
577
+ download_rate = downloaded / elapsed_time # bytes per second
578
+ remaining_bytes = total_size - downloaded
579
+ remaining_seconds = (download_rate > 0) ? (remaining_bytes / download_rate) : 0
580
+
581
+ # Format time remaining
582
+ if remaining_seconds < 60
583
+ time_estimate = "#{remaining_seconds.to_i}s"
584
+ elsif remaining_seconds < 3600
585
+ time_estimate = "#{(remaining_seconds/60).to_i}m #{(remaining_seconds%60).to_i}s"
586
+ else
587
+ time_estimate = "#{(remaining_seconds/3600).to_i}h #{(remaining_seconds%3600/60).to_i}m"
588
+ end
589
+
590
+ rows << ["estimated_time_remaining", time_estimate]
591
+ rows << ["download_speed", "#{download_rate.round(2)} bytes/s (#{(download_rate / 1024).round(2)} KB/s)"]
592
+ end
593
+ end
594
+ elsif @previous_download_info && progress > @previous_download_info[:progress]
595
+ # Calculate speed based solely on percentage change
596
+ current_time = Time.now.to_i
597
+ prev_time = @previous_download_info[:time]
598
+ prev_progress = @previous_download_info[:progress]
599
+ time_diff = current_time - prev_time
600
+ progress_diff = progress - prev_progress
601
+
602
+ if time_diff > 0 && progress_diff > 0
603
+ # Estimate remaining time based on progress rate
604
+ progress_per_second = progress_diff / time_diff.to_f
605
+ remaining_progress = 100.0 - progress
606
+ remaining_seconds = (progress_per_second > 0) ? (remaining_progress / progress_per_second).to_i : 0
607
+
608
+ # Format time remaining
609
+ if remaining_seconds < 60
610
+ time_estimate = "#{remaining_seconds}s"
611
+ elsif remaining_seconds < 3600
612
+ time_estimate = "#{(remaining_seconds/60).to_i}m #{(remaining_seconds%60).to_i}s"
613
+ else
614
+ time_estimate = "#{(remaining_seconds/3600).to_i}h #{(remaining_seconds%3600/60).to_i}m"
615
+ end
616
+
617
+ rows << ["estimated_time_remaining", time_estimate]
618
+ rows << ["download_rate", "#{progress_diff.round(2)}% per #{time_diff}s"]
619
+ end
620
+ end
621
+
622
+ # Store current info for next calculation
623
+ @previous_download_info = {
624
+ time: Time.now.to_i,
625
+ progress: progress
626
+ }
627
+
628
+ # Only store downloaded bytes if we have size information to calculate it correctly
629
+ if total_size.to_i > 0
630
+ @previous_download_info[:downloaded] = (total_size * (progress / 100.0)).to_i
631
+ end
632
+ else
633
+ # Reset tracking if not in progress
634
+ @previous_download_info = nil
635
+ end
636
+
637
+ table = ::Terminal::Table.new(
638
+ title: "Image Download Status",
639
+ rows: rows
640
+ )
641
+
642
+ puts table
643
+ end
644
+
645
+ def show_download_progress(client, interval = 3, timeout = nil)
646
+ monitor_start_time = Time.now.to_i
647
+ last_progress = nil
648
+ download_info = {}
649
+ line_count = 0
650
+
651
+ loop do
652
+ # Check timeout if specified
653
+ current_time = Time.now.to_i
654
+ if timeout && (current_time - monitor_start_time >= timeout)
655
+ puts "\nMonitoring stopped after #{timeout} seconds timeout"
656
+ break
657
+ end
658
+
659
+ # Clear previous output
660
+ if line_count > 0
661
+ print "\033[#{line_count}A\033[J" # Move cursor up and clear to end of screen
662
+ end
663
+
664
+ status = client.get_download_image_status["data"]
665
+ current_time = Time.now.to_i
666
+
667
+ # Extract progress from percentage string if needed
668
+ progress = if status["percentage"].to_s.include?("%")
669
+ status["percentage"].to_s.gsub("%", "").strip.to_f
670
+ else
671
+ status["progress"].to_f
672
+ end
673
+
674
+ # Try to get total file size
675
+ total_size = status["size"].to_i
676
+ if total_size == 0 && status["file"].to_s.start_with?("http")
677
+ total_size = get_file_size_from_url(status["file"])
678
+ end
679
+
680
+ # Update progress and info
681
+ if status["status"] == "in_progress"
682
+ # Setup the download info
683
+ download_info[:file] = status["file"]
684
+ download_info[:status] = status["status"]
685
+ download_info[:progress] = progress
686
+
687
+ # Calculate additional download information
688
+ if total_size.to_i > 0
689
+ downloaded = (total_size * (progress / 100.0)).to_i
690
+ download_info[:downloaded] = "#{downloaded} bytes (#{(downloaded / 1024.0 / 1024.0).round(2)} MB)"
691
+ download_info[:total_size] = "#{total_size} bytes (#{(total_size / 1024.0 / 1024.0).round(2)} MB)"
692
+
693
+ if @previous_download_info && @previous_download_info[:downloaded]
694
+ prev_time = @previous_download_info[:time]
695
+ prev_downloaded = @previous_download_info[:downloaded] || 0
696
+ time_diff = current_time - prev_time
697
+ bytes_diff = downloaded - prev_downloaded
698
+
699
+ if time_diff > 0 && bytes_diff > 0
700
+ download_rate = bytes_diff / time_diff.to_f # bytes per second
701
+ download_info[:speed] = "#{(download_rate / 1024).round(2)} KB/s"
702
+
703
+ remaining_bytes = total_size - downloaded
704
+ remaining_seconds = (download_rate > 0) ? (remaining_bytes / download_rate).to_i : 0
705
+ download_info[:time_remaining] = format_time_remaining(remaining_seconds)
706
+ end
707
+ end
708
+
709
+ # Store current info for next calculation
710
+ @previous_download_info = {
711
+ time: current_time,
712
+ downloaded: downloaded,
713
+ progress: progress
714
+ }
715
+ elsif @previous_download_info && progress > @previous_download_info[:progress]
716
+ # Calculate based on percentage change
717
+ prev_time = @previous_download_info[:time]
718
+ prev_progress = @previous_download_info[:progress]
719
+ time_diff = current_time - prev_time
720
+ progress_diff = progress - prev_progress
721
+
722
+ if time_diff > 0 && progress_diff > 0
723
+ progress_per_second = progress_diff / time_diff.to_f
724
+ download_info[:speed] = "#{progress_diff.round(2)}% per second"
725
+ remaining_progress = 100.0 - progress
726
+ remaining_seconds = (progress_per_second > 0) ? (remaining_progress / progress_per_second).to_i : 0
727
+ download_info[:time_remaining] = format_time_remaining(remaining_seconds)
728
+ end
729
+
730
+ @previous_download_info = {
731
+ time: current_time,
732
+ progress: progress
733
+ }
734
+ else
735
+ @previous_download_info = {
736
+ time: current_time,
737
+ progress: progress
738
+ }
739
+ end
740
+
741
+ # Display a nice progress bar
742
+ progress_width = 50
743
+ completed = (progress_width * progress / 100).to_i
744
+ bar = "[" + "#" * completed + " " * (progress_width - completed) + "]"
745
+
746
+ # Prepare output lines
747
+ output = []
748
+ output << "File: #{download_info[:file]}"
749
+ output << "Progress: #{bar} #{progress.round(2)}%"
750
+ output << "Status: #{download_info[:status]}"
751
+ output << "Downloaded: #{download_info[:downloaded]}" if download_info[:downloaded]
752
+ output << "Total size: #{download_info[:total_size]}" if download_info[:total_size]
753
+ output << "Speed: #{download_info[:speed]}" if download_info[:speed]
754
+ output << "ETA: #{download_info[:time_remaining]}" if download_info[:time_remaining]
755
+
756
+ if timeout
757
+ elapsed = current_time - monitor_start_time
758
+ remaining = timeout - elapsed
759
+ output << "Timeout in: #{remaining}s"
760
+ end
761
+
762
+ output << "\nPress Ctrl+C to stop monitoring"
763
+
764
+ # Print output and count lines for next update
765
+ puts output
766
+ line_count = output.size
767
+
768
+ # If download is complete, break the loop
769
+ if status["status"] != "in_progress"
770
+ break
771
+ end
772
+ else
773
+ puts "Download #{status['status']}"
774
+ break
775
+ end
776
+
777
+ sleep interval
778
+ end
779
+
780
+ puts "\nDownload #{client.get_download_image_status["data"]["status"]}"
781
+ end
782
+
783
+ def format_time_remaining(seconds)
784
+ if seconds < 60
785
+ "#{seconds}s"
786
+ elsif seconds < 3600
787
+ "#{(seconds/60).to_i}m #{(seconds%60).to_i}s"
788
+ else
789
+ "#{(seconds/3600).to_i}h #{(seconds%3600/60).to_i}m"
790
+ end
791
+ end
792
+ end
793
+ end