amdgpu_fan 0.2.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 474b8500f7505a2793cf2a3cc1345d39273c78b2749cde1a40bef0f8050910e0
4
- data.tar.gz: ceb407543897bd36b851c1c1c42660e8f146878cd354ee0411a104b9358fb133
3
+ metadata.gz: 7f57ebc2011d08efe82baa67964bf75a17dfe7c85a518249a93c7b6eeadf207e
4
+ data.tar.gz: 1e1fa6d5af4f3cecc5346003949eb4a1edeb1c1ef943e60478f09cd8e3600099
5
5
  SHA512:
6
- metadata.gz: 2808b9d2b5ce15880b9ee3937bac0f534c81b66c3824fe44439ebfc7449ba52d7ad44e930cc21f201bedd1bc6b34a3d17f3d469aed62a534fc8e6cc005064a09
7
- data.tar.gz: 2ddc2aeedb9648308e7bf39f7421d5ae5397addd9df7ed35e6f15e903c78b8c36c946df1a48e529e2cdd2de7629696e0e7caa67d0923d65d9b2b72f56aad7bcd
6
+ metadata.gz: a185688066129ae01860f6dcf15fa75d84b5441f0cf3fbf23df812986c8da194eb6ed00557afe607299d6d3640beed3b5dc60483b1e6155d09d4e40180865793
7
+ data.tar.gz: 2ef75cd3c949fd579eaf855c63ca985a1ddff172e41fceb46dd10b5f1177e0ddaf7f74427e3ce64c26a86ac15da473806f400014abaf428fb7275f075cd79069
data/README.md CHANGED
@@ -1,33 +1,104 @@
1
- # amdgpu-fan-rb
1
+ # amdgpu_fan
2
2
 
3
- A Ruby CLI to read and set the fan speed for AMD Radeon graphics cards running on the AMDGPU Linux driver.
3
+ [![Gem Version](https://badge.fury.io/rb/amdgpu_fan.svg)](https://badge.fury.io/rb/amdgpu_fan)
4
+ [![Build Status](https://travis-ci.org/HarlemSquirrel/amdgpu-fan-rb.svg?branch=master)](https://travis-ci.org/HarlemSquirrel/amdgpu-fan-rb) [![Maintainability](https://api.codeclimate.com/v1/badges/27233cee17ef6a2c14fd/maintainability)](https://codeclimate.com/github/HarlemSquirrel/amdgpu-fan-rb/maintainability)
4
5
 
5
- See https://wiki.archlinux.org/index.php/Fan_speed_control#AMDGPU_sysfs_fan_control
6
+ A Ruby CLI to read and set fan speed, power profiles, and more for AMD Radeon graphics cards running on the AMDGPU Linux driver.
6
7
 
7
- ## Usage
8
+ **amdgpu_fan** aims to provide a more user friendly interface on top of [sysfs](https://en.wikipedia.org/wiki/Sysfs) for displaying statistics and interacting with AMD Radeon graphics hardware running on the [AMDgpu](https://dri.freedesktop.org/docs/drm/gpu/amdgpu.html) driver.
9
+
10
+ #### Further reading
11
+
12
+ - https://wiki.archlinux.org/index.php/AMDGPU#Overclocking
13
+ - https://wiki.archlinux.org/index.php/Fan_speed_control#AMDGPU_sysfs_fan_control
14
+ - https://phoronix.com/scan.php?page=news_item&px=AMDGPU-Quick-WattMan-Cap-Test
15
+
16
+ ## Installation
17
+
18
+ The `amdgpu_fan` CLI command can be installed from [RubyGems](https://rubygems.org/gems/amdgpu_fan) or easily run from the source code.
19
+
20
+ ### From RubyGems
8
21
 
9
22
  ```
10
- ➤ bundle
23
+ gem install amdgpu_fan
24
+ ```
25
+
26
+ ### From Source
27
+
28
+ ```
29
+ ➤ git clone https://github.com/HarlemSquirrel/amdgpu-fan-rb.git
30
+ ➤ cd amdgpu-fan-rb
31
+ ➤ bundle install
32
+ ➤ bin/amdgpu_fan
33
+ ```
11
34
 
35
+ ## Usage
36
+
37
+ ```
12
38
  ➤ bin/amdgpu_fan help
13
39
  Commands:
14
- amdgpu_fan auto # Set mode to automatic (requires sudo)
15
- amdgpu_fan help [COMMAND] # Describe available commands or one specific command
16
- amdgpu_fan set PERCENTAGE # Set fan speed to PERCENTAGE (requires sudo)
17
- amdgpu_fan status # View device info, current fan speed, and temperature
18
- amdgpu_fan watch [SECONDS] # Watch current fan speed, and temperature refreshed every n seconds
40
+ amdgpu_fan connectors # View the status of the display connectors.
41
+ amdgpu_fan fan # View fan details.
42
+ amdgpu_fan fan_set PERCENTAGE/AUTO # Set fan speed to percentage or automatic mode. (requires sudo)
43
+ amdgpu_fan help [COMMAND] # Describe available commands or one specific command
44
+ amdgpu_fan profile # View power profile details.
45
+ amdgpu_fan profile_auto # Set the power profile to automatic mode.
46
+ amdgpu_fan profile_force PROFILE_NUM # Manually set a power profile. (requires sudo)
47
+ amdgpu_fan status [--logo] # View device info, current fan speed, and temperature.
48
+ amdgpu_fan watch [SECONDS] # Watch fan speed, load, power, and temperature refreshed every n seconds.
49
+ amdgpu_fan watch_csv [SECONDS] # Watch stats in CSV format refreshed every n seconds defaulting to 1 second.
19
50
 
20
51
  ➤ bin/amdgpu_fan status
21
- 📺 GPU: AMD Radeon (TM) R9 Fury Series
22
- 📄 vBIOS: 113-C8800100-102
23
- 🌀 Fan: auto mode running at 48% ~ 1828 rpm
24
- 🌡 Temp: 28.0°C
25
- ⚡ Power: 19.26 / 300.0 Watts
52
+ 📺 GPU: Advanced Micro Devices, Inc. [AMD/ATI] Radeon R9 FURY X / NANO
53
+ 📄 vBIOS: 113-C8800100-102
54
+ ⏰ Clocks: 724Mhz Core, 500Mhz Memory
55
+ 💾 Memory: 4096 MiB
56
+ 🌀 Fan: auto mode running at 1809 rpm (48%)
57
+ 🌞 Temp: 21.0°C
58
+ ⚡ Power: 3D_FULL_SCREEN profile in performance mode using 16.2 / 300.0 Watts (5%)
59
+ ⚖ Load: [ ]0%
60
+
61
+ ➤ bin/amdgpu_fan watch 3
62
+ Watching Advanced Micro Devices, Inc. [AMD/ATI] Radeon R9 FURY X / NANO every 3 second(s)...
63
+ <Press Ctrl-C to exit>
64
+ 2019-05-28 20:57:41 | Core: 724Mhz | Memory: 500Mhz | Fan: 948 rpm [* ]14% | Load: [** ]24% | Power: 16.07 W [* ]6% | Temp: 34.0°C
65
+ 2019-05-28 20:57:45 | Core: 512Mhz | Memory: 500Mhz | Fan: 948 rpm [* ]14% | Load: [ ]0% | Power: 16.13 W [* ]7% | Temp: 34.0°C
66
+ 2019-05-28 20:57:49 | Core: 892Mhz | Memory: 500Mhz | Fan: 948 rpm [* ]14% | Load: [ ]0% | Power: 25.22 W [* ]5% | Temp: 33.0°C
67
+ 2019-05-28 20:57:53 | Core: 300Mhz | Memory: 500Mhz | Fan: 948 rpm [* ]14% | Load: [ ]0% | Power: 19.1 W [* ]6% | Temp: 33.0°C
68
+ 2019-05-28 20:57:57 | Core: 1050Mhz | Memory: 500Mhz | Fan: 948 rpm [* ]14% | Load: [********* ]94% | Power: 103.04 W [*** ]31% | Temp: 36.0°C
69
+ 2019-05-28 20:58:01 | Core: 1050Mhz | Memory: 500Mhz | Fan: 954 rpm [** ]15% | Load: [********* ]91% | Power: 158.07 W [***** ]53% | Temp: 38.0°C
70
+ 2019-05-28 20:58:05 | Core: 1050Mhz | Memory: 500Mhz | Fan: 977 rpm [** ]16% | Load: [**********]100% | Power: 218.01 W [******* ]73% | Temp: 40.0°C
71
+ 2019-05-28 20:58:09 | Core: 1050Mhz | Memory: 500Mhz | Fan: 1005 rpm [** ]16% | Load: [**********]100% | Power: 216.24 W [******* ]71% | Temp: 40.0°C
72
+ 2019-05-28 20:58:13 | Core: 1050Mhz | Memory: 500Mhz | Fan: 1033 rpm [** ]17% | Load: [**********]97% | Power: 109.25 W [**** ]39% | Temp: 38.0°C
73
+ 2019-05-28 20:58:17 | Core: 724Mhz | Memory: 500Mhz | Fan: 1058 rpm [** ]17% | Load: [ ]0% | Power: 17.17 W [* ]6% | Temp: 35.0°C
74
+ ^C
75
+ And now the watch is ended.
76
+ ```
77
+
78
+ ```
79
+ ➤ bin/amdgpu_fan watch_avg
80
+ Watching Sapphire Technology Limited Vega 10 XL/XT [Radeon RX Vega 56/64] min, max and averges since
81
+ 2020-06-02 23:05:20 -0400...
82
+ <Press Ctrl-C to exit>
83
+ ⏰ Core clock min: 852 MHz avg: 887.0 MHz max: 1200 MHz now: 852 MHz
84
+ 💾 Memory clk min: 167 MHz avg: 227.1 MHz max: 945 MHz now: 167 MHz
85
+ 🌀 Fan speed min: 1231 RPM avg: 1231.0 RPM max: 1231 RPM now: 1231 RPM
86
+ ⚡ Power usage min: 6.0 W avg: 21.8 W max: 141.0 W now: 6.0 W
87
+ 🌡 Temperature min: 30 °C avg: 31.3 °C max: 35 °C now: 32 °C
88
+ ^C
89
+ And now the watch is ended.
26
90
  ```
27
91
 
28
92
  ## Dependencies
29
93
 
30
- - Ruby
31
- - bundler
32
- - thor (installed with bundler)
33
- - lspci (included with most Linux distributions)
94
+ - [Ruby](https://www.ruby-lang.org) with [Bundler](https://bundler.io)
95
+ - [Thor](http://whatisthor.com/) (installed with `bundle install`)
96
+ - [`lspci`](https://linux.die.net/man/8/lspci) (included with most Linux distributions)
97
+
98
+ ## Building a binary
99
+
100
+ [Ruby Packer](https://github.com/pmq20/ruby-packer) provides a convenient way to compile this into a single executable. For the best results, compile Ruby Packer from source from the lastest master branch.
101
+
102
+ ```sh
103
+ rubyc amdgpu_fan --output amdgpu_fan
104
+ ```
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative '../lib/amdgpu_fan_cli'
4
+ require_relative '../lib/amdgpu_fan'
5
5
 
6
- AmdgpuFanCli.start ARGV
6
+ AmdgpuFan::Cli.start ARGV
@@ -1,3 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
 
3
- require_relative '../lib/amdgpu_service'
5
+ require_relative '../lib/amdgpu_fan'
6
+ require_relative '../lib/amdgpu_fan/version'
7
+
8
+ require_relative '../lib/amdgpu_fan/mixin/cli_output_format'
9
+ require_relative '../lib/amdgpu_fan/mixin/sys_write'
10
+
11
+ require_relative '../lib/amdgpu_fan/service'
12
+ require_relative '../lib/amdgpu_fan/cli'
13
+ require_relative '../lib/amdgpu_fan/stat_set'
14
+ require_relative '../lib/amdgpu_fan/watcher'
@@ -0,0 +1,10 @@
1
+ ---
2
+ clock: ⏰
3
+ display: 📺
4
+ fan: 🌀
5
+ gpu: 👾
6
+ load: "⚖ "
7
+ memory: 💾
8
+ power: ⚡
9
+ temp: "🌡 "
10
+ vbios: 📄
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config/environment'
4
+
5
+ # The main module
6
+ module AmdgpuFan
7
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module AmdgpuFan
6
+ # The command-line interface class
7
+ class Cli < Thor
8
+ include CliOutputFormat
9
+
10
+ ICONS = YAML.load(File.read(File.join(__dir__, '../../config/icons.yml')))
11
+ .transform_keys(&:to_sym).freeze
12
+ WATCH_FIELD_SEPARATOR = ' | '
13
+
14
+ desc 'connectors', 'View the status of the display connectors.'
15
+ def connectors
16
+ amdgpu_service.connectors.each do |connector|
17
+ puts "#{connector.type} #{connector.index}:\t" +
18
+ (connector.connected? ? connector.display_name : connector.status)
19
+ end
20
+ end
21
+
22
+ desc 'profile', 'View power profile details.'
23
+ def profile
24
+ puts amdgpu_service.profile_summary
25
+ end
26
+
27
+ desc 'profile_auto', 'Set the power profile to automatic mode.'
28
+ def profile_auto
29
+ amdgpu_service.profile_auto
30
+ puts amdgpu_service.profile_summary
31
+ end
32
+
33
+ desc 'profile_force PROFILE_NUM', 'Manually set a power profile. (requires sudo)'
34
+ def profile_force(state)
35
+ amdgpu_service.profile_force = state
36
+ puts amdgpu_service.profile_summary
37
+ end
38
+
39
+ desc 'fan', 'View fan details.'
40
+ def fan
41
+ puts fan_status
42
+ end
43
+
44
+ desc 'fan_set PERCENTAGE/AUTO', 'Set fan speed to percentage or automatic mode. (requires sudo)'
45
+ def fan_set(value)
46
+ if value.strip.casecmp('auto').zero?
47
+ amdgpu_service.fan_mode = :auto
48
+ else
49
+ return puts 'Invalid percentage' unless (0..100).cover?(value.to_i)
50
+
51
+ amdgpu_service.fan_speed = value
52
+ end
53
+ puts fan_status
54
+ end
55
+
56
+ desc 'status [--logo]', 'View device info, current fan speed, and temperature.'
57
+ def status(option = nil)
58
+ puts radeon_logo if option == '--logo'
59
+ puts ICONS[:gpu] + ' GPU:'.ljust(9) + amdgpu_service.name,
60
+ ICONS[:vbios]+ ' vBIOS:'.ljust(9) + amdgpu_service.vbios_version,
61
+ ICONS[:display] + ' Displays:' + amdgpu_service.display_names.join(', '),
62
+ ICONS[:clock] + ' Clocks:'.ljust(9) + clock_status,
63
+ ICONS[:memory] + ' Memory:'.ljust(9) + mem_total_mibibyes,
64
+ ICONS[:fan] + ' Fan:'.ljust(9) + fan_status,
65
+ ICONS[:temp] + ' Temp:'.ljust(9) + "#{amdgpu_service.temperature}°C",
66
+ ICONS[:power] + ' Power:'.ljust(9) + "#{amdgpu_service.profile_mode} profile in " \
67
+ "#{amdgpu_service.power_dpm_state} mode using " \
68
+ "#{amdgpu_service.power_draw} / #{amdgpu_service.power_max} Watts "\
69
+ "(#{amdgpu_service.power_draw_percent}%)",
70
+ ICONS[:load] + ' Load:'.ljust(9) + percent_meter(amdgpu_service.busy_percent, 20)
71
+ end
72
+
73
+ desc 'version', 'Print the application version.'
74
+ def version
75
+ puts AmdgpuFan::VERSION
76
+ end
77
+
78
+ desc 'watch [SECONDS]', 'Watch fan speed, load, power, and temperature ' \
79
+ 'refreshed every n seconds.'
80
+ def watch(seconds = 1)
81
+ return puts 'Seconds must be from 1 to 600' unless (1..600).cover?(seconds.to_i)
82
+
83
+ puts "Watching #{amdgpu_service.name} every #{seconds} second(s)...",
84
+ ' <Press Ctrl-C to exit>'
85
+
86
+ trap 'SIGINT' do
87
+ puts "\nAnd now the watch is ended."
88
+ exit 0
89
+ end
90
+
91
+ loop do
92
+ time = Time.now
93
+ puts [time.strftime('%F %T'), summary_clock, summary_fan, summary_load, summary_power,
94
+ summary_temp].join(WATCH_FIELD_SEPARATOR)
95
+
96
+ # It can take a second or two to run the above so we remove them from the wait
97
+ # here to get a more consistant watch interval.
98
+ sec_left_to_wait = time.to_i + seconds.to_i - Time.now.to_i
99
+ sleep sec_left_to_wait if sec_left_to_wait.positive?
100
+ end
101
+ end
102
+
103
+ desc 'watch_avg',
104
+ <<~DOC
105
+ Watch min, max, average, and current stats.
106
+ DOC
107
+ def watch_avg
108
+ puts "Watching #{amdgpu_service.name} min, max and averges since #{Time.now}...",
109
+ ' <Press Ctrl-C to exit>',
110
+ "\n\n\n\n\n"
111
+
112
+ trap 'SIGINT' do
113
+ puts "\nAnd now the watch is ended."
114
+ exit 0
115
+ end
116
+
117
+ watcher = Watcher.new amdgpu_service
118
+
119
+ loop do
120
+ watcher.measure
121
+ 5.times { print "\033[K\033[A" } # move up a line and clear to end of line
122
+
123
+ puts ICONS[:clock] + ' Core clock ' + watcher.core_clock.to_s,
124
+ ICONS[:memory] + ' Memory clk ' + watcher.mem_clock.to_s,
125
+ ICONS[:fan] + ' Fan speed ' + watcher.fan_speed.to_s,
126
+ ICONS[:power] + ' Power usage ' + watcher.power.to_s,
127
+ ICONS[:temp] + ' Temperature ' + watcher.temp.to_s
128
+ sleep 1
129
+ end
130
+ end
131
+
132
+ desc 'watch_csv [SECONDS]', 'Watch stats in CSV format ' \
133
+ 'refreshed every n seconds defaulting to 1 second.'
134
+ def watch_csv(seconds = 1)
135
+ return puts 'Seconds must be from 1 to 600' unless (1..600).cover?(seconds.to_i)
136
+
137
+ puts 'Timestamp, Core Clock (Mhz),Memory Clock (Mhz),Fan speed (rpm), '\
138
+ 'Load (%),Power (Watts),Temp (°C)'
139
+
140
+ trap 'SIGINT' do
141
+ exit 0
142
+ end
143
+
144
+ loop do
145
+ puts [Time.now.strftime('%F %T'),
146
+ amdgpu_service.core_clock,
147
+ amdgpu_service.memory_clock,
148
+ amdgpu_service.fan_speed_rpm,
149
+ amdgpu_service.busy_percent,
150
+ amdgpu_service.power_draw,
151
+ amdgpu_service.temperature].join(',')
152
+ sleep seconds.to_i
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def amdgpu_service
159
+ @amdgpu_service ||= AmdgpuFan::Service.new
160
+ end
161
+
162
+ def clock_status
163
+ "#{amdgpu_service.core_clock} Core, #{amdgpu_service.memory_clock} Memory"
164
+ end
165
+
166
+ def fan_status
167
+ "#{amdgpu_service.fan_mode} mode running at " \
168
+ "#{amdgpu_service.fan_speed_rpm} rpm (#{amdgpu_service.fan_speed_percent}%)"
169
+ end
170
+
171
+ def mem_total_mibibyes
172
+ "#{amdgpu_service.memory_total / (2**20)} MiB"
173
+ end
174
+
175
+ def power_max
176
+ format('%<num>0.2f', num: amdgpu_service.power_max)
177
+ end
178
+
179
+ def summary_clock
180
+ "Core: #{amdgpu_service.core_clock.rjust(7)}#{WATCH_FIELD_SEPARATOR}"\
181
+ "Memory: #{amdgpu_service.memory_clock.rjust(7)}"
182
+ end
183
+
184
+ def summary_fan
185
+ fan_speed_string = "#{amdgpu_service.fan_speed_rpm} rpm".rjust(8)
186
+ "Fan: #{fan_speed_string} #{percent_meter(amdgpu_service.fan_speed_percent)}"
187
+ end
188
+
189
+ def summary_load
190
+ "Load: #{percent_meter amdgpu_service.busy_percent}"
191
+ end
192
+
193
+ def summary_power
194
+ "Power: #{format('%<num>0.02f', num: amdgpu_service.power_draw).rjust(power_max.length)} W" \
195
+ " #{percent_meter amdgpu_service.power_draw_percent}"
196
+ end
197
+
198
+ def summary_temp
199
+ temp_string = "#{amdgpu_service.temperature}°C".rjust(7)
200
+ "Temp: #{temp_string}"
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ ## Connector
5
+ #
6
+ # A model class for a GPU connector
7
+ class Connector
8
+ EDID_DESCRIPTORS_CONF = {
9
+ display_name_leading_bytes: String.new('\x00\xFC\x00', encoding: 'ascii-8bit'),
10
+ unspecified_text_leading_bytes: String.new('\x00\xFE\x00', encoding: 'ascii-8bit'),
11
+ index_range: (54..125)
12
+ }.freeze
13
+
14
+
15
+ attr_reader :card_num, :dir_path, :index, :type
16
+
17
+ class << self
18
+ ##
19
+ # Return an array of connector objects for the provided card number.
20
+ # The files are sorted to improve how they are displayed to the user.
21
+ def where(card_num:)
22
+ Dir["/sys/class/drm/card#{card_num}/card#{card_num}-*"].sort.map do |dir_path|
23
+ Connector.new card_num: card_num,
24
+ dir_path: dir_path,
25
+ index: dir_path[-1],
26
+ type: dir_path.slice(/(?<=card#{card_num}-)[A-z]+/)
27
+ end
28
+ end
29
+ end
30
+
31
+ def initialize(card_num:, dir_path:, index:, type:)
32
+ @card_num = card_num
33
+ @dir_path = dir_path
34
+ @index = index
35
+ @type = type
36
+ end
37
+
38
+ def connected?
39
+ status.casecmp('connected').zero?
40
+ end
41
+
42
+ def display_name
43
+ return if edid.to_s.empty?
44
+
45
+ (display_name_text + unspecified_text).join(' ').strip
46
+ end
47
+
48
+ def status
49
+ File.read(File.join(dir_path, 'status')).strip
50
+ end
51
+
52
+ private
53
+
54
+ def display_descriptors_raw
55
+ edid.slice EDID_DESCRIPTORS_CONF[:index_range]
56
+ end
57
+
58
+ def display_name_text
59
+ display_descriptors_raw
60
+ .scan(/(?<=#{EDID_DESCRIPTORS_CONF[:display_name_leading_bytes]}).{1,13}/)
61
+ end
62
+
63
+ def edid
64
+ File.read("#{dir_path}/edid", encoding: 'ascii-8bit')
65
+ end
66
+
67
+ def unspecified_text
68
+ display_descriptors_raw
69
+ .scan(/(?<=#{EDID_DESCRIPTORS_CONF[:unspecified_text_leading_bytes]}).{1,13}/)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ # A mixin to help with CLI output formatting
5
+ module CliOutputFormat
6
+ METER_CHAR = '*'
7
+ TIME_FORMAT = '%F %T'
8
+
9
+ private
10
+
11
+ def current_time
12
+ Time.now.strftime(TIME_FORMAT)
13
+ end
14
+
15
+ def percent_meter(percent, length = 10)
16
+ progress_bar_count = (length * percent.to_f / 100).round
17
+ percent_string = "#{format '%<num>0.2i', num: percent}%".ljust(3)
18
+ "[#{METER_CHAR * progress_bar_count}#{' ' * (length - progress_bar_count)}]#{percent_string}"
19
+ end
20
+
21
+ def radeon_logo
22
+ File.read(File.join(__dir__, '../../../assets/radeon_r_black_red_100x100.ascii'))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ ##
5
+ # A mixin to read fan details and validate input
6
+ module Fan
7
+
8
+ private
9
+
10
+ def fan_file(type)
11
+ @fan_file ||= {}
12
+ @fan_file[type] ||= "#{base_hwmon_dir}/fan1_#{type}"
13
+ end
14
+
15
+ def fan_mode_file
16
+ @fan_mode_file ||= "#{base_hwmon_dir}/pwm1_enable"
17
+ end
18
+
19
+ def fan_power_file
20
+ @fan_power_file ||= "#{base_hwmon_dir}/pwm1"
21
+ end
22
+
23
+ def fan_speed_raw
24
+ File.read(fan_power_file).strip
25
+ end
26
+
27
+ def fan_raw_speeds(type)
28
+ @fan_raw_speeds ||= {}
29
+ @fan_raw_speeds[type] ||= File.read(Dir.glob("#{base_card_dir}/**/pwm1_#{type}").first).to_i
30
+ end
31
+
32
+ ##
33
+ # Validate the raw fan speed is between the minimum and maximum values
34
+ # read from sysfs.
35
+ def valid_fan_raw_speed?(raw)
36
+ !raw.nil? && (fan_raw_speeds(:min)..fan_raw_speeds(:max)).cover?(raw.to_i)
37
+ end
38
+
39
+ def valid_fan_percent_speed?(percent)
40
+ (1..100.to_i).cover?(percent.to_i)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ # A mixin to help with writing to system files
5
+ module SysWrite
6
+
7
+ private
8
+
9
+ ##
10
+ # Write to a system file with elevated priviledges.
11
+ def sudo_write(file_path, value)
12
+ `echo "#{value}" | sudo tee #{file_path}`
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mixin/fan'
4
+ require_relative 'mixin/sys_write'
5
+
6
+ require_relative 'connector'
7
+
8
+ module AmdgpuFan
9
+ ## AmdgpuService
10
+ #
11
+ # A service class for reading and interacting with AMD radeon graphics cards
12
+ # through the amdgpu Linux kernel driver.
13
+ class Service
14
+ include Fan
15
+ include SysWrite
16
+
17
+ BASE_FOLDER = '/sys/class/drm'
18
+ FAN_MODES = { '1' => 'manual', '2' => 'auto' }.freeze
19
+
20
+ attr_reader :card_num
21
+
22
+ class Error < StandardError; end
23
+
24
+ def initialize(card_num: 0)
25
+ @card_num = card_num
26
+ end
27
+
28
+ def busy_percent
29
+ File.read("#{base_card_dir}/gpu_busy_percent").strip
30
+ end
31
+
32
+ def connectors
33
+ @connectors ||= Connector.where card_num: card_num
34
+ end
35
+
36
+ def core_clock
37
+ clock_from_pp_file "#{base_card_dir}/pp_dpm_sclk"
38
+ end
39
+
40
+ def display_names
41
+ connectors.map(&:display_name).compact
42
+ end
43
+
44
+ def fan_mode
45
+ FAN_MODES[File.read(fan_mode_file).strip] || 'unknown'
46
+ end
47
+
48
+ def fan_mode=(mode)
49
+ sudo_write fan_mode_file, FAN_MODES.key(mode.to_s)
50
+ end
51
+
52
+ def fan_speed=(value)
53
+ if valid_fan_percent_speed?(value)
54
+ new_raw = (value.to_f / 100 * fan_raw_speeds(:max).to_i).round
55
+ elsif valid_fan_raw_speed?(value)
56
+ new_raw = value
57
+ end
58
+
59
+ raise(self.class::Error, 'Invalid fan speed provided') if new_raw.to_s.empty?
60
+
61
+ self.fan_mode = :manual unless fan_mode == 'manual'
62
+
63
+ sudo_write fan_power_file, new_raw
64
+ end
65
+
66
+ def fan_speed_percent
67
+ (fan_speed_raw.to_f / fan_raw_speeds(:max).to_i * 100).round
68
+ end
69
+
70
+ def fan_speed_rpm
71
+ File.read(fan_file(:input)).strip
72
+ end
73
+
74
+ def memory_clock
75
+ clock_from_pp_file "#{base_card_dir}/pp_dpm_mclk"
76
+ end
77
+
78
+ def memory_total
79
+ File.read("#{base_card_dir}/mem_info_vram_total").to_i
80
+ end
81
+
82
+ def name
83
+ lspci_subsystem.split(': ')[1].strip
84
+ end
85
+
86
+ def power_dpm_state
87
+ File.read("#{base_card_dir}/power_dpm_state").strip
88
+ end
89
+
90
+ def power_draw
91
+ power_raw_to_watts File.read(power_avg_file)
92
+ end
93
+
94
+ def power_draw_percent
95
+ (power_draw.to_f / power_max.to_i * 100).round
96
+ end
97
+
98
+ def power_max
99
+ @power_max ||= power_raw_to_watts File.read("#{base_hwmon_dir}/power1_cap")
100
+ end
101
+
102
+ def profile_auto
103
+ sudo_write "#{base_card_dir}/power_dpm_force_performance_level", 'auto'
104
+ end
105
+
106
+ def profile_force=(state)
107
+ sudo_write "#{base_card_dir}/power_dpm_force_performance_level", 'manual'
108
+ sudo_write "#{base_card_dir}/pp_power_profile_mode", state
109
+ end
110
+
111
+ def profile_mode
112
+ File.read("#{base_card_dir}/pp_power_profile_mode").slice(/\w+\s*+\*/).delete('*').strip
113
+ end
114
+
115
+ def profile_summary
116
+ File.read("#{base_card_dir}/pp_power_profile_mode")
117
+ end
118
+
119
+ def temperature
120
+ (File.read(temperature_file).to_f / 1000).round(1)
121
+ end
122
+
123
+ def vbios_version
124
+ @vbios_version ||= File.read("#{base_card_dir}/vbios_version").strip
125
+ end
126
+
127
+ private
128
+
129
+ def base_card_dir
130
+ @base_card_dir ||= "#{BASE_FOLDER}/card#{card_num}/device"
131
+ end
132
+
133
+ def base_hwmon_dir
134
+ @base_hwmon_dir ||= Dir.glob("#{base_card_dir}/hwmon/hwmon*").first
135
+ end
136
+
137
+ def clock_from_pp_file(file)
138
+ File.read(file).slice(/\w+(?= \*)/)
139
+ end
140
+
141
+ def gpu_pci_id
142
+ @gpu_pci_id ||= `lspci -v | grep VGA`.split(' ').first
143
+ end
144
+
145
+ def lspci_subsystem
146
+ @lspci_subsystem ||= `lspci -v -s #{gpu_pci_id} | grep "Subsystem:"`
147
+ end
148
+
149
+ def power_avg_file
150
+ @power_avg_file ||= Dir.glob("#{base_card_dir}/**/power1_average").first
151
+ end
152
+
153
+ def power_raw_to_watts(raw_string)
154
+ (raw_string.strip.to_f / 1_000_000).round(2)
155
+ end
156
+
157
+ def temperature_file
158
+ @temperature_file ||= Dir.glob("#{base_card_dir}/**/temp1_input").first
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ # A set of stats
5
+ class StatSet < Hash
6
+ attr_accessor :avg, :max, :min, :now
7
+ attr_reader :unit
8
+
9
+ def initialize(unit)
10
+ @unit = unit
11
+ end
12
+
13
+ def stats
14
+ { min: min, avg: avg, max: max, now: now }
15
+ end
16
+
17
+ ##
18
+ # Return a string containing all the stats with units.
19
+ #
20
+ def to_s
21
+ stats.map { |k,v| "#{k}: #{v.to_s.rjust(6)} #{unit.ljust(3)} " }.join
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AmdgpuFan
4
+ # Current version of RSpec Core, in semantic versioning format.
5
+ VERSION = '0.6.0'
6
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stat_set'
4
+
5
+ module AmdgpuFan
6
+ # Keep track of stats over time.
7
+ class Watcher
8
+ attr_reader :core_clock, :fan_speed, :num_measurements, :mem_clock, :power, :temp
9
+
10
+ def initialize(amdgpu_service)
11
+ @amdgpu_service = amdgpu_service
12
+ @num_measurements = 0
13
+
14
+ @core_clock = StatSet.new 'MHz'
15
+ @mem_clock = StatSet.new 'MHz'
16
+ @fan_speed = StatSet.new 'RPM'
17
+ @power = StatSet.new 'W'
18
+ @temp = StatSet.new '°C'
19
+ end
20
+
21
+ ##
22
+ # Take a new set of measurements and adjust the stats.
23
+ #
24
+ def measure
25
+ @num_measurements += 1
26
+
27
+ @core_clock.now = @amdgpu_service.core_clock.to_i
28
+ @mem_clock.now = @amdgpu_service.memory_clock.to_i
29
+ @fan_speed.now = @amdgpu_service.fan_speed_rpm.to_i
30
+ @power.now = @amdgpu_service.power_draw.to_f
31
+ @temp.now = @amdgpu_service.temperature.to_i
32
+
33
+ [@core_clock, @mem_clock, @fan_speed, @power, @temp].each do |stat_set|
34
+ calculate_stats(stat_set)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def calculate_stats(stat_set)
41
+ if num_measurements == 1
42
+ stat_set.min = stat_set.now
43
+ stat_set.avg = stat_set.now.to_f
44
+ stat_set.max = stat_set.now
45
+ return
46
+ end
47
+
48
+ stat_set.min = stat_set.now if stat_set.now < stat_set.min
49
+ stat_set.avg =
50
+ ((stat_set.now + stat_set.avg * (num_measurements - 1)) / num_measurements.to_f)
51
+ .round(1)
52
+ stat_set.max = stat_set.now if stat_set.now > stat_set.max
53
+ end
54
+ end
55
+ end
metadata CHANGED
@@ -1,16 +1,58 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amdgpu_fan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin McCormack
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-01-14 00:00:00.000000000 Z
12
- dependencies: []
13
- description: A CLI for amdgpu fans
11
+ date: 2020-06-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: A CLI for interacting with the amdgpu Linux driver
14
56
  email: harlemsquirrel@gmail.com
15
57
  executables:
16
58
  - amdgpu_fan
@@ -20,9 +62,17 @@ files:
20
62
  - README.md
21
63
  - bin/amdgpu_fan
22
64
  - config/environment.rb
23
- - lib/amdgpu_fan_cli.rb
24
- - lib/amdgpu_service.rb
25
- - lib/radeon_r_black_red_100x100.ascii
65
+ - config/icons.yml
66
+ - lib/amdgpu_fan.rb
67
+ - lib/amdgpu_fan/cli.rb
68
+ - lib/amdgpu_fan/connector.rb
69
+ - lib/amdgpu_fan/mixin/cli_output_format.rb
70
+ - lib/amdgpu_fan/mixin/fan.rb
71
+ - lib/amdgpu_fan/mixin/sys_write.rb
72
+ - lib/amdgpu_fan/service.rb
73
+ - lib/amdgpu_fan/stat_set.rb
74
+ - lib/amdgpu_fan/version.rb
75
+ - lib/amdgpu_fan/watcher.rb
26
76
  homepage: https://github.com/HarlemSquirrel/amdgpu-fan-rb
27
77
  licenses:
28
78
  - MIT
@@ -42,8 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
42
92
  - !ruby/object:Gem::Version
43
93
  version: '0'
44
94
  requirements: []
45
- rubyforge_project:
46
- rubygems_version: 2.7.7
95
+ rubygems_version: 3.1.2
47
96
  signing_key:
48
97
  specification_version: 4
49
98
  summary: A CLI to view and set fan speeds for AMD graphics cards running on the open
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../config/environment'
4
-
5
- # The main class
6
- class AmdgpuFanCli < Thor
7
- desc 'auto', 'Set mode to automatic (requires sudo)'
8
- def auto
9
- amdgpu_service.set_fan_mode! :auto
10
- puts fan_status
11
- end
12
-
13
- desc 'set PERCENTAGE', 'Set fan speed to PERCENTAGE (requires sudo)'
14
- def set(percentage)
15
- return puts "Invalid percentage" unless (0..100).cover?(percentage.to_i)
16
-
17
- amdgpu_service.set_fan_manual_speed! percent: percentage
18
- puts fan_status
19
- rescue AmdgpuService::Error
20
- puts 'Invalid fan speed provided. The percentage should be between 1 and 100'
21
- exit 1
22
- end
23
-
24
- desc 'status', 'View device info, current fan speed, and temperature'
25
- def status
26
- print_radeon_logo
27
- puts "📺\tGPU: #{amdgpu_service.name}",
28
- "📄\tvBIOS: #{amdgpu_service.vbios_version}",
29
- fan_status,
30
- "🌡\tTemp: #{amdgpu_service.temperature}°C",
31
- "⚡\tPower: #{amdgpu_service.power_dpm_state} mode using " \
32
- "#{amdgpu_service.power_draw} / #{amdgpu_service.power_max} Watts",
33
- "⚖\tLoad: #{amdgpu_service.busy_percent}%"
34
- end
35
-
36
- desc 'watch [SECONDS]', 'Watch fan speed, load, power, and temperature ' \
37
- 'refreshed every n seconds'
38
- def watch(seconds=1)
39
- return puts "Seconds must be from 1 to 600" unless (1..600).cover?(seconds.to_i)
40
-
41
- puts "Watching #{amdgpu_service.name} every #{seconds} second(s)...",
42
- ' <Press Ctrl-C to exit>'
43
-
44
- trap "SIGINT" do
45
- puts 'And now the watch is ended.'
46
- exit 0
47
- end
48
-
49
- loop do
50
- puts "#{Time.now.strftime("%F %T")} " \
51
- "Fan: #{amdgpu_service.fan_speed_rpm} rpm (#{amdgpu_service.fan_speed_percent}%), " \
52
- "Load: #{amdgpu_service.busy_percent}%, " \
53
- "Power: #{amdgpu_service.power_draw} W, " \
54
- "Temp: #{amdgpu_service.temperature}°C "
55
- sleep seconds.to_i
56
- end
57
- end
58
-
59
- private
60
-
61
- def amdgpu_service
62
- @amdgpu_service ||= AmdgpuService.new
63
- end
64
-
65
- def current_time
66
- Time.now.strftime("%F %T")
67
- end
68
-
69
- def fan_status
70
- "🌀\tFan: #{amdgpu_service.fan_mode} mode running at " \
71
- "#{amdgpu_service.fan_speed_percent}% ~ #{amdgpu_service.fan_speed_rpm} rpm"
72
- end
73
-
74
- def print_radeon_logo
75
- puts File.read('lib/radeon_r_black_red_100x100.ascii')
76
- end
77
- end
@@ -1,134 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- ## AmdgpuService
4
- #
5
- # A service class for reading and interacting with AMD radeon graphics cards
6
- # through the amdgpu Linux kernel driver.
7
- class AmdgpuService
8
- BASE_FOLDER = '/sys/class/drm'
9
- FAN_MODES = { '1' => 'manual', '2' => 'auto' }.freeze
10
-
11
- attr_reader :card_num
12
-
13
- class Error < StandardError; end
14
-
15
- def initialize(card_num: 0)
16
- @card_num = card_num
17
- end
18
-
19
- def busy_percent
20
- File.read("#{base_card_folder}/gpu_busy_percent").strip
21
- end
22
-
23
- def fan_mode
24
- FAN_MODES[File.read(fan_mode_file).strip] || 'unknown'
25
- end
26
-
27
- def set_fan_mode!(mode)
28
- `echo "#{FAN_MODES.key(mode.to_s)}" | sudo tee #{fan_mode_file}`
29
- end
30
-
31
- def fan_speed_percent
32
- (fan_speed_raw.to_f / fan_speed_raw_max.to_i * 100).round
33
- end
34
-
35
- def fan_speed_raw_max
36
- @fan_speed_raw_max ||= File.read(Dir.glob("#{base_card_folder}/**/pwm1_max").first).strip
37
- end
38
-
39
- def fan_speed_rpm
40
- File.read(fan_input_file).strip
41
- end
42
-
43
- def name
44
- lspci_subsystem.split(': ')[1].strip
45
- end
46
-
47
- def power_dpm_state
48
- File.read("#{base_card_folder}/power_dpm_state").strip
49
- end
50
-
51
- def power_draw
52
- power_raw_to_watts File.read(power_avg_file)
53
- end
54
-
55
- def power_max
56
- @power_max ||= power_raw_to_watts File.read(power_max_file)
57
- end
58
-
59
- def set_fan_manual_speed!(percent: nil, raw: nil)
60
- if valid_fan_percent_speed?(percent)
61
- new_raw = (percent.to_f / 100 * fan_speed_raw_max.to_i).round
62
- elsif valid_fan_raw_speed?(raw)
63
- new_raw = raw
64
- end
65
-
66
- raise(self.class::Error, 'Invalid fan speed provided') if new_raw.to_s.empty?
67
-
68
- set_fan_mode!(:manual) unless fan_mode == 'manual'
69
-
70
- `echo "#{new_raw}" | sudo tee #{fan_power_file}`
71
- end
72
-
73
- def temperature
74
- (File.read(temperature_file).to_f / 1000).round(1)
75
- end
76
-
77
- def vbios_version
78
- @vbios_version ||= File.read("#{base_card_folder}/vbios_version").strip
79
- end
80
-
81
- private
82
-
83
- def base_card_folder
84
- @base_card_folder ||= "#{BASE_FOLDER}/card#{card_num}/device"
85
- end
86
-
87
- def fan_input_file
88
- @fan_input_file ||= Dir.glob("#{base_card_folder}/**/fan1_input").first
89
- end
90
-
91
- def fan_mode_file
92
- @fan_mode_file ||= Dir.glob("#{base_card_folder}/**/pwm1_enable").first
93
- end
94
-
95
- def fan_power_file
96
- @fan_power_file ||= Dir.glob("#{base_card_folder}/**/pwm1").first
97
- end
98
-
99
- def fan_speed_raw
100
- File.read(fan_power_file).strip
101
- end
102
-
103
- def gpu_pci_id
104
- @gpu_pci_id ||= `lspci -v | grep VGA`.split(' ').first
105
- end
106
-
107
- def lspci_subsystem
108
- @lspci_subsystem ||= `lspci -v -s #{gpu_pci_id} | grep "Subsystem:"`
109
- end
110
-
111
- def power_avg_file
112
- @power_avg_file ||= Dir.glob("#{base_card_folder}/**/power1_average").first
113
- end
114
-
115
- def power_max_file
116
- @power_avg_file ||= Dir.glob("#{base_card_folder}/**/power1_cap").first
117
- end
118
-
119
- def power_raw_to_watts(raw_string)
120
- (raw_string.strip.to_f / 1_000_000).round(2)
121
- end
122
-
123
- def temperature_file
124
- @temperature_file ||= Dir.glob("#{base_card_folder}/**/temp1_input").first
125
- end
126
-
127
- def valid_fan_raw_speed?(raw)
128
- (1..fan_speed_raw_max.to_i).include?(raw.to_i)
129
- end
130
-
131
- def valid_fan_percent_speed?(percent)
132
- (1..100.to_i).include?(percent.to_i)
133
- end
134
- end
@@ -1,40 +0,0 @@
1
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
3
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
4
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
5
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
6
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
7
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
8
- ''''''''''''''.                                        .''''''''''''''''''''''''
9
- ''''''''''''''.                                          .''''''''''''''''''''''
10
- ''''''''''''''.                                    ..'''''''''''''''''''
11
- ''''''''''''''.                                          .''''''''''''''''''
12
- ''''''''''''''.                             ..                ..''''''''''''''''
13
- ''''''''''''''.         .'''''''''''''''''''''''..           .''''''''''''''''
14
- ''''''''''''''.           ''''''''''''''''''''''''''.          .''''''''''''''''
15
- ''''''''''''''.           ''''''''''''''''''''''''''.         .''''''''''''''''
16
- ''''''''''''''.           ''''''''''''''''''''''''''.          .''''''''''''''''
17
- ''''''''''''''.           ''''''''''''''''''''''''''.        .''''''''''''''''
18
- ''''''''''''''.           ''''''''''''''''''''''''''.          .''''''''''''''''
19
- ''''''''''''''.           ''''''''''''''''''''''''''.          .''''''''''''''''
20
- ''''''''''''''.           '''''''''''''''''''''''..          .''''''''''''''''
21
- ''''''''''''''.                                               .''''''''''''''''
22
- ''''''''''''''.                                            .''''''''''''''''''
23
- ''''''''''''''.                                            ..'''''''''''''''''''
24
- ''''''''''''''.                                          .''''''''''''''''''''''
25
- ''''''''''''''.                                         .'''''''''''''''''''''''
26
- ''''''''''''''.           ''''''''''''''''..           .'''''''''''''''''''''''
27
- ''''''''''''''.           ''''''''''''''''''.           .''''''''''''''''''''''
28
- ''''''''''''''.           '''''''''''''''''''.            .''''''''''''''''''''
29
- ''''''''''''''.           ''''''''''''''''''''..            ..''''''''''''''''''
30
- ''''''''''''''.           ''''''''''''''''''''''.            .'''''''''''''''''
31
- ''''''''''''''.           '''''''''''''''''''''''..          ..'''''''''''''''
32
- ''''''''''''''.           '''''''''''''''''''''''''.             .''''''''''''''
33
- ''''''''''''''.           ''''''''''''''''''''''''''.             .'''''''''''''
34
- ''''''''''''''............''''''''''''''''''''''''''''..............''''''''''''
35
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
36
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
37
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
38
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
39
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
40
- ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''