eaton 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42cf738c337ad3fae01953c5e78003e8ed0c789146ee7b71bacaf8f3162b98ae
4
- data.tar.gz: 0ebf36ddf22c38dea0a216777f8b04fb5da841648cbc1ccc0e4a83a8f45427ff
3
+ metadata.gz: eb4213ff1de1459504fd9aef3ff717e711d4926dcc482af271a5ced054c4dd72
4
+ data.tar.gz: 5cb7bf81a8fc1cd3cba0d27407f352731bb49a07a22e210b87ddcd6fffe5eb9d
5
5
  SHA512:
6
- metadata.gz: 5d342a6f0162da2fc073ccdb52f768a98daa7a6486b1c327c261b0a4b88e41c3d4d3763f55b02a14fb24e4393ff89d95f4d88f5c49ce728ad95c5fd78e98adbb
7
- data.tar.gz: 26622a5ed0fcd33eb9249b25f92550b887f6844c528fd9641380782aabdbe3f4de5717020d6554d0212640b1aeffd6853963e6b3038c5757e3962e6245fbceed
6
+ metadata.gz: 5d64daf3ed46a838ee8e1b0b94501122af4b4124c1c9fe934fab670d504268ccf7809470690d69ce102d4ccfa5cde454e03b9f4585444d54b6ffbe1379d70a18
7
+ data.tar.gz: 8bb820f4d75d9149196d12da53af22e8b75dbd2521894c9b8cfa6d64d09976f0a72c1a35818553ce5a66203b9aa91d86596a486b915c2df9cd60de28c900a72a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-09-10
9
+
10
+ ### Added
11
+ - `Client#put` for settings writes; the client could only read, POST and DELETE
12
+ - `Eaton::Network`, mixed into `Client`:
13
+ - `#ipv4` reports the address, mask, gateway and mode eth0 is running on
14
+ - `#set_ipv4(address:, subnet_mask:, gateway:)` switches eth0 to a static
15
+ address. All three are required and a blank one raises `ArgumentError`:
16
+ the PDU applies them together, and a wrong mask can leave it unreachable.
17
+
18
+ ### Improved
19
+ - Enhanced authentication error handling for expired credentials
20
+ - Added detailed password policy requirements in error messages
21
+ - Improved user guidance for first-time login and password expiration scenarios
22
+ - Error messages now include direct instructions for changing password via web interface
23
+
8
24
  ## [0.1.0] - 2025-01-20
9
25
 
10
26
  ### Added
data/README.md CHANGED
@@ -176,29 +176,31 @@ client = Eaton::Client.new(
176
176
  verify_ssl: true
177
177
  )
178
178
 
179
- # Extend with power monitoring
180
- client.extend(Eaton::Power)
181
-
182
179
  # Get PDU info
183
- info = client.pdu_info
180
+ info = client.info
184
181
  puts "#{info[:model]} - #{info[:serial_number]}"
185
182
 
186
183
  # Get overall power
187
- power = client.overall_power
184
+ power = client.power
188
185
  puts "Current draw: #{power} watts"
189
186
 
190
187
  # Get active outlets
191
- outlets = client.outlet_power
188
+ outlets = client.outlets
192
189
  outlets.select { |o| o[:watts] > 0 }.each do |outlet|
193
190
  puts "#{outlet[:name]}: #{outlet[:watts]}W"
194
191
  end
195
192
 
196
193
  # Get branch distribution
197
- branches = client.branch_power
194
+ branches = client.branches
198
195
  branches.each do |branch|
199
196
  puts "#{branch[:name]}: #{branch[:current]}A @ #{branch[:voltage]}V"
200
197
  end
201
198
 
199
+ # Get detailed metrics
200
+ detailed = client.detailed
201
+ puts "Power Factor: #{detailed[:overall][:power_factor]}"
202
+ puts "Frequency: #{detailed[:overall][:frequency]} Hz"
203
+
202
204
  # Clean up
203
205
  client.logout
204
206
  ```
data/TODO.md ADDED
@@ -0,0 +1,81 @@
1
+ # TODO
2
+
3
+ ## Future Enhancements
4
+
5
+ ### Programmatic Password Change (Issue #1)
6
+
7
+ **Priority**: Medium
8
+ **Context**: First-time login and password expiration handling
9
+
10
+ **Problem**:
11
+ Eaton PDUs enforce password expiration policies. When credentials expire, users currently must:
12
+ 1. Navigate to the PDU web interface
13
+ 2. Manually change the password
14
+ 3. Update credentials in their application
15
+
16
+ This creates friction, especially for:
17
+ - First-time setup (initial default password change)
18
+ - Automated systems that need to handle password rotation
19
+ - Multiple PDU management scenarios
20
+
21
+ **Proposed Solution**:
22
+ Add programmatic password change capability to the client:
23
+
24
+ ```ruby
25
+ # Ruby API
26
+ client = Eaton::Client.new(
27
+ host: 'pdu.example.com',
28
+ username: 'admin',
29
+ password: 'current_password'
30
+ )
31
+
32
+ client.change_password(new_password: 'NewSecurePass123!')
33
+
34
+ # CLI
35
+ eaton change-password \
36
+ --host pdu.example.com \
37
+ --username admin \
38
+ --current-password old_pass \
39
+ --new-password new_pass
40
+ ```
41
+
42
+ **Implementation Requirements**:
43
+ 1. Research Eaton PDU API endpoint for password changes
44
+ 2. Implement `change_password` method in `Eaton::Client`
45
+ 3. Add password policy validation before attempting change
46
+ 4. Create CLI command `eaton change-password`
47
+ 5. Handle special case: changing expired credentials
48
+ 6. Add comprehensive error handling
49
+ 7. Update documentation with password management guide
50
+ 8. Add tests for password change functionality
51
+
52
+ **API Research Needed**:
53
+ - Identify correct REST endpoint (likely `/users/{id}` or `/accounts/{id}`)
54
+ - Determine if special authentication is needed for expired credentials
55
+ - Understand session requirements during password change
56
+ - Check if password history/reuse policies are enforced
57
+
58
+ **Security Considerations**:
59
+ - Never log passwords
60
+ - Clear old password from memory after use
61
+ - Validate new password against policy before submitting
62
+ - Handle authentication state correctly during transition
63
+ - Consider secure input methods (no command-line password echo)
64
+
65
+ **Testing Approach**:
66
+ - Mock API responses for password change
67
+ - Test policy validation
68
+ - Test error scenarios (wrong current password, policy violations)
69
+ - Integration test with real PDU (manual, not CI)
70
+
71
+ **Documentation Updates**:
72
+ - Add password management section to README
73
+ - Include examples for both API and CLI
74
+ - Document password policy requirements
75
+ - Add troubleshooting guide for common scenarios
76
+
77
+ **Related Files**:
78
+ - `lib/eaton/client.rb` - Add change_password method
79
+ - `lib/eaton/cli.rb` - Add CLI command
80
+ - `spec/*` - Add test coverage
81
+ - `README.md` - Update documentation
data/lib/eaton/cli.rb CHANGED
@@ -16,7 +16,7 @@ module Eaton
16
16
  desc "power", "Get overall power consumption in watts"
17
17
  def power
18
18
  with_client do |client|
19
- power = client.overall_power
19
+ power = client.power
20
20
  output_result("Overall Power", { watts: power })
21
21
  end
22
22
  end
@@ -24,7 +24,7 @@ module Eaton
24
24
  desc "outlets", "Get per-outlet power consumption"
25
25
  def outlets
26
26
  with_client do |client|
27
- outlets = client.outlet_power
27
+ outlets = client.outlets
28
28
  # Filter out zero-power outlets in text mode
29
29
  if options[:format] == "text"
30
30
  outlets = outlets.select { |o| o[:watts] && o[:watts] > 0 }
@@ -36,7 +36,7 @@ module Eaton
36
36
  desc "detailed", "Get detailed power information"
37
37
  def detailed
38
38
  with_client do |client|
39
- info = client.detailed_power_info
39
+ info = client.detailed
40
40
  # Filter outlets in text mode
41
41
  if options[:format] == "text" && info[:outlets]
42
42
  info[:outlets] = info[:outlets].select { |o| o[:watts] && o[:watts] > 0 }
@@ -48,7 +48,7 @@ module Eaton
48
48
  desc "branches", "Get power consumption per branch"
49
49
  def branches
50
50
  with_client do |client|
51
- branches = client.branch_power
51
+ branches = client.branches
52
52
  # Filter out zero-current branches in text mode
53
53
  if options[:format] == "text"
54
54
  branches = branches.select { |b| b[:current] && b[:current] > 0 }
@@ -60,7 +60,7 @@ module Eaton
60
60
  desc "info", "Display PDU device information"
61
61
  def info
62
62
  with_client do |client|
63
- info = client.pdu_info
63
+ info = client.info
64
64
  output_result("PDU Device Information", info)
65
65
  end
66
66
  end
@@ -88,9 +88,6 @@ module Eaton
88
88
  host_header: options[:host_header]
89
89
  )
90
90
 
91
- # Mix in the Power module to add power monitoring methods
92
- client.extend(Power)
93
-
94
91
  yield client
95
92
  rescue Client::AuthenticationError => e
96
93
  error("Authentication failed: #{e.message}")
data/lib/eaton/client.rb CHANGED
@@ -10,6 +10,9 @@ module Eaton
10
10
  class AuthenticationError < StandardError; end
11
11
  class APIError < StandardError; end
12
12
 
13
+ include Power
14
+ include Network
15
+
13
16
  attr_reader :host, :username, :base_url
14
17
 
15
18
  def initialize(host:, username:, password:, port: 443, verify_ssl: false, host_header: nil)
@@ -37,7 +40,7 @@ module Eaton
37
40
  @session = data["session"]
38
41
  @token
39
42
  else
40
- raise AuthenticationError, "Authentication failed: #{response.body}"
43
+ handle_auth_error(response)
41
44
  end
42
45
  rescue JSON::ParserError => e
43
46
  raise AuthenticationError, "Invalid response from server: #{e.message}"
@@ -66,6 +69,16 @@ module Eaton
66
69
  handle_response(execute_request(request))
67
70
  end
68
71
 
72
+ def put(path, data = {})
73
+ authenticate! unless authenticated?
74
+
75
+ request = Net::HTTP::Put.new("#{@base_path}#{path}")
76
+ add_auth_headers(request)
77
+ request.body = data.to_json
78
+
79
+ handle_response(execute_request(request))
80
+ end
81
+
69
82
  def logout
70
83
  return unless authenticated?
71
84
 
@@ -81,6 +94,65 @@ module Eaton
81
94
 
82
95
  private
83
96
 
97
+ def handle_auth_error(response)
98
+ begin
99
+ error_data = JSON.parse(response.body)
100
+
101
+ # Check for expired credentials error
102
+ if error_data["code"] == 112 && error_data["description"] =~ /expired/i
103
+ policy = parse_password_policy(error_data["args"])
104
+ message = build_expired_credentials_message(policy)
105
+ raise AuthenticationError, message
106
+ end
107
+ rescue JSON::ParserError
108
+ # Fall through to generic error
109
+ end
110
+
111
+ # Generic authentication error
112
+ raise AuthenticationError, "Authentication failed: #{response.body}"
113
+ end
114
+
115
+ def parse_password_policy(args)
116
+ return nil unless args && args.is_a?(Array) && args.length >= 7
117
+
118
+ {
119
+ min_length: args[0],
120
+ max_length: args[1],
121
+ require_uppercase: args[2] == 1,
122
+ require_lowercase: args[3] == 1,
123
+ require_numbers: args[4] == 1,
124
+ require_special: args[5] == 1,
125
+ special_chars: args[6]
126
+ }
127
+ end
128
+
129
+ def build_expired_credentials_message(policy)
130
+ message = "Credentials are expired. Please change the password via the PDU web interface.\n\n"
131
+
132
+ if policy
133
+ message += "Password requirements:\n"
134
+ message += " - Length: #{policy[:min_length]}-#{policy[:max_length]} characters\n"
135
+
136
+ requirements = []
137
+ requirements << "uppercase letters" if policy[:require_uppercase]
138
+ requirements << "lowercase letters" if policy[:require_lowercase]
139
+ requirements << "numbers" if policy[:require_numbers]
140
+ requirements << "special characters" if policy[:require_special]
141
+
142
+ message += " - Must include: #{requirements.join(', ')}\n" if requirements.any?
143
+ message += " - Allowed special characters: #{policy[:special_chars]}\n" if policy[:special_chars]
144
+ message += "\n"
145
+ end
146
+
147
+ message += "To change password:\n"
148
+ message += " 1. Navigate to https://#{@host}#{@port == 443 ? '' : ":#{@port}"}\n"
149
+ message += " 2. Log in with current credentials\n"
150
+ message += " 3. Follow prompts to change password\n"
151
+ message += "\nNote: First-time login may require password change before API access is granted."
152
+
153
+ message
154
+ end
155
+
84
156
  def http_connection
85
157
  @http_connection ||= begin
86
158
  http = Net::HTTP.new(@host, @port)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eaton
4
+ # eth0 addressing on the PDU's network management card.
5
+ module Network
6
+ ETH0_PATH = "/managers/1/networkService/networkInterfaces/eth0"
7
+
8
+ # Current eth0 addressing. :mode is "dhcp client" or "manual"; the address,
9
+ # subnet_mask and gateway are what the interface is running on right now,
10
+ # which is what DHCP handed it when the mode is dhcp.
11
+ def ipv4
12
+ data = get(ETH0_PATH)
13
+
14
+ {
15
+ mode: data.dig("ipv4", "settings", "mode"),
16
+ address: data.dig("ipv4", "status", "address"),
17
+ subnet_mask: data.dig("ipv4", "status", "subnetMask"),
18
+ gateway: data.dig("ipv4", "status", "gateway")
19
+ }
20
+ end
21
+
22
+ # Switch eth0 to a static address. All three values are required: the PDU
23
+ # applies them together, and a wrong mask strands it on the wrong subnet.
24
+ def set_ipv4(address:, subnet_mask:, gateway:)
25
+ raise ArgumentError, "address, subnet_mask and gateway are all required" if
26
+ [address, subnet_mask, gateway].any? { |v| v.to_s.strip.empty? }
27
+
28
+ put(ETH0_PATH, ipv4: {
29
+ settings: {
30
+ enabled: true,
31
+ mode: "manual",
32
+ manual: { address: address, subnetMask: subnet_mask, gateway: gateway }
33
+ }
34
+ })
35
+ end
36
+ end
37
+ end
data/lib/eaton/power.rb CHANGED
@@ -4,14 +4,14 @@ module Eaton
4
4
  module Power
5
5
  # Get overall power consumption for the PDU
6
6
  # Returns power in watts
7
- def overall_power
7
+ def power
8
8
  data = get("/powerDistributions/1/inputs/1")
9
9
  data.dig("measures", "activePower")
10
10
  end
11
11
 
12
12
  # Get per-outlet power consumption
13
13
  # Returns an array of hashes with outlet info and power in watts
14
- def outlet_power
14
+ def outlets
15
15
  # Get list of outlets
16
16
  outlets_list = get("/powerDistributions/1/outlets")
17
17
  member_count = outlets_list["members@count"] || 0
@@ -41,7 +41,7 @@ module Eaton
41
41
  end
42
42
 
43
43
  # Get detailed power information including voltage, current, and power factor
44
- def detailed_power_info
44
+ def detailed
45
45
  input_data = get("/powerDistributions/1/inputs/1")
46
46
 
47
47
  {
@@ -55,13 +55,13 @@ module Eaton
55
55
  cumulated_energy: input_data.dig("measures", "cumulatedEnergy"),
56
56
  partial_energy: input_data.dig("measures", "partialEnergy")
57
57
  },
58
- outlets: outlet_power
58
+ outlets: outlets
59
59
  }
60
60
  end
61
61
 
62
62
  # Get branch power information
63
63
  # Returns an array of branch power data
64
- def branch_power
64
+ def branches
65
65
  branches_list = get("/powerDistributions/1/branches")
66
66
  member_count = branches_list["members@count"] || 0
67
67
 
@@ -87,7 +87,7 @@ module Eaton
87
87
  end
88
88
 
89
89
  # Get PDU information
90
- def pdu_info
90
+ def info
91
91
  data = get("/powerDistributions/1")
92
92
 
93
93
  {
data/lib/eaton/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Eaton
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/eaton.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "eaton/version"
4
- require_relative "eaton/client"
5
4
  require_relative "eaton/power"
5
+ require_relative "eaton/network"
6
+ require_relative "eaton/client"
6
7
  require_relative "eaton/cli"
7
8
 
8
9
  module Eaton
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Siegel
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-10-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -29,7 +28,7 @@ description: Comprehensive power monitoring and management for Eaton Rack PDU G4
29
28
  detailed metrics (voltage, current, power factor), OAuth2 authentication, and SSH
30
29
  tunneling support.
31
30
  email:
32
- - jonathan@example.com
31
+ - "<248302+usiegj00@users.noreply.github.com>"
33
32
  executables:
34
33
  - eaton
35
34
  extensions: []
@@ -39,10 +38,12 @@ files:
39
38
  - LICENSE
40
39
  - README.md
41
40
  - Rakefile
41
+ - TODO.md
42
42
  - exe/eaton
43
43
  - lib/eaton.rb
44
44
  - lib/eaton/cli.rb
45
45
  - lib/eaton/client.rb
46
+ - lib/eaton/network.rb
46
47
  - lib/eaton/power.rb
47
48
  - lib/eaton/version.rb
48
49
  - sig/pdu_manager.rbs
@@ -55,7 +56,6 @@ metadata:
55
56
  bug_tracker_uri: https://github.com/usiegj00/eaton/issues
56
57
  changelog_uri: https://github.com/usiegj00/eaton/blob/main/CHANGELOG.md
57
58
  documentation_uri: https://github.com/usiegj00/eaton
58
- post_install_message:
59
59
  rdoc_options: []
60
60
  require_paths:
61
61
  - lib
@@ -70,8 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  - !ruby/object:Gem::Version
71
71
  version: '0'
72
72
  requirements: []
73
- rubygems_version: 3.5.22
74
- signing_key:
73
+ rubygems_version: 3.6.9
75
74
  specification_version: 4
76
75
  summary: Ruby gem and CLI for managing Eaton Rack PDU G4 devices via REST API
77
76
  test_files: []