growatt 0.2.0 → 0.2.1

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.
@@ -1,36 +1,91 @@
1
- require 'digest'
2
- require File.expand_path('error', __dir__)
3
-
4
- module Growatt
5
- # Deals with authentication flow and stores it within global configuration
6
- module Authentication
7
-
8
- # Authorize to the Growatt portal
9
- def login()
10
- raise ConfigurationError, "Username/password not set" unless username || password
11
- _password = hash_password(self.password) #unless is_password_hashed
12
-
13
- _format = self.format
14
- self.format = 'x-www-form-urlencoded'
15
- response = post('newTwoLoginAPI.do', {'userName': self.username, 'password': _password})
16
- self.format = _format
17
- data = response.body['back']
18
- if data && data['success']
19
- @login_data = data
20
- data
21
- else
22
- raise AuthenticationError.new(data['error'])
23
- end
24
- end
25
- private
26
- def hash_password(password)
27
- password_md5 = Digest::MD5.hexdigest(password.encode('utf-8'))
28
- (0...password_md5.length).step(2) do |i|
29
- if password_md5[i] == '0'
30
- password_md5[i] = 'c'
31
- end
32
- end
33
- password_md5
34
- end
35
- end
36
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require File.expand_path('error', __dir__)
5
+
6
+ module Growatt
7
+ # Handles authentication flow and stores session data in the global configuration.
8
+ #
9
+ # This module provides methods for logging into the Growatt portal, hashing passwords,
10
+ # and validating credentials.
11
+ #
12
+ module Authentication
13
+ # Logs in to the Growatt portal using the stored credentials.
14
+ #
15
+ # This method:
16
+ # - Validates that the username and password are set.
17
+ # - Hashes the password using MD5.
18
+ # - Sends a login request with the credentials.
19
+ # - Processes the server response.
20
+ #
21
+ # @raise [ConfigurationError] If username or password is missing.
22
+ # @raise [AuthenticationError] If authentication fails.
23
+ # @return [Hash] The login response data if successful.
24
+ #
25
+ # @example Logging in to Growatt:
26
+ # client.login
27
+ #
28
+ def login
29
+ validate_credentials
30
+ _password = hash_password(self.password) # Hash password before sending
31
+
32
+ _format = self.format
33
+ self.format = 'x-www-form-urlencoded'
34
+ response = post('newTwoLoginAPI.do', { 'userName' => self.username, 'password' => _password })
35
+ self.format = _format
36
+ process_response(response.body['back'])
37
+ end
38
+
39
+ private
40
+
41
+ # Hashes the given password using MD5.
42
+ #
43
+ # This method generates an MD5 hash of the password and modifies it by replacing
44
+ # every occurrence of '0' at even indices with 'c'.
45
+ #
46
+ # @param password [String] The plain-text password.
47
+ # @return [String] The modified MD5-hashed password.
48
+ #
49
+ # @example Hashing a password:
50
+ # hash_password("mypassword") # => "5f4dcc3bcfcd204e074324a5e7565eaf"
51
+ #
52
+ def hash_password(password)
53
+ password_md5 = Digest::MD5.hexdigest(password.encode('utf-8'))
54
+ (0...password_md5.length).step(2) do |i|
55
+ password_md5[i] = 'c' if password_md5[i] == '0'
56
+ end
57
+ password_md5
58
+ end
59
+
60
+ # Validates that the username and password are set.
61
+ #
62
+ # @raise [ConfigurationError] If either credential is missing.
63
+ #
64
+ # @example Checking credentials before login:
65
+ # validate_credentials # Raises ConfigurationError if missing
66
+ #
67
+ def validate_credentials
68
+ raise ConfigurationError, "Username/password not set" unless username && password
69
+ end
70
+
71
+ # Processes the authentication response.
72
+ #
73
+ # If authentication is successful, stores the login data. Otherwise, raises an error.
74
+ #
75
+ # @param data [Hash] The response data from the Growatt portal.
76
+ # @raise [AuthenticationError] If authentication fails.
77
+ # @return [Hash] The login data if authentication succeeds.
78
+ #
79
+ # @example Handling a successful login:
80
+ # process_response({ "success" => true }) # Returns login data
81
+ #
82
+ def process_response(data)
83
+ if data && data['success']
84
+ @login_data = data
85
+ data
86
+ else
87
+ raise AuthenticationError.new(data['error'])
88
+ end
89
+ end
90
+ end
91
+ end
@@ -1,160 +1,219 @@
1
- require File.expand_path('api', __dir__)
2
- require File.expand_path('const', __dir__)
3
- require File.expand_path('error', __dir__)
4
-
5
- module Growatt
6
- # Wrapper for the Growatt REST API
7
- #
8
- # @see no API documentation, reverse engineered
9
- class Client < API
10
-
11
- def initialize(options = {})
12
- super(options)
13
- end
14
-
15
- # access data returned from login
16
- def login_info
17
- @login_data
18
- end
19
- def plant_list(user_id=nil)
20
- user_id = login_info['user']['id'] unless user_id
21
- _plant_list({'userId':user_id})
22
- end
23
- def plant_detail(plant_id,type=Timespan::DAY,date=Time.now)
24
- _plant_detail( {
25
- 'plantId': plant_id,
26
- 'type': type,
27
- 'date': timespan_date(type,date)
28
- })
29
- end
30
- def plant_info(plant_id)
31
- _plant_info({
32
- 'op': 'getAllDeviceList',
33
- 'plantId': plant_id,
34
- 'pageNum': 1,
35
- 'pageSize': 1
36
- })
37
- end
38
- def device_list(plant_id)
39
- plant_info(plant_id).deviceList
40
- end
41
-
42
- def inverter_list(plant_id)
43
- devices = device_list(plant_id)
44
- devices.select { |device| 'inverter'.eql? device.deviceType }
45
- end
46
-
47
- # get data for invertor control
48
- def inverter_control_data(inverter_id)
49
- _inverter_api({
50
- 'op': 'getMaxSetData',
51
- 'serialNum': inverter_id
52
- }).obj.maxSetBean
53
- end
54
-
55
- def update_inverter_setting(serial_number,command,param_id,parameters)
56
- command_parameters = {
57
- 'op': command,
58
- 'serialNum': serial_number,
59
- 'paramId': param_id
60
- }
61
- # repeated values to hash { param1: value1 }
62
-
63
- parameters = parameters.map.with_index { |value, index| ["param#{index + 1}", value] }.to_h if parameters.is_a? Array
64
- self.format = 'x-www-form-urlencoded'
65
- data = JSON.parse(post('newTcpsetAPI.do',command_parameters.merge(parameters)).body)
66
- self.format = :json
67
- data['success']
68
- end
69
-
70
- # turn invertor on of off
71
- def turn_inverter(serial_number,on=true)
72
- onoff = (on ? Inverter::ON : Inverter::OFF )
73
- update_inverter_setting(serial_number,'maxSetApi','max_cmd_on_off',[onoff])
74
- end
75
-
76
- # check if invertor is turned on
77
- def inverter_on?(serial_number)
78
- status = inverter_control_data(serial_number)
79
- Inverter::ON.eql? status.max_cmd_on_off
80
- end
81
-
82
- def export_limit(serial_number,enable,value=nil)
83
- if ExportLimit::DISABLE.eql? enable
84
- params = [0]
85
- else
86
- raise ArgumentError, "exportlimitation enable should be ExportLimit::WATT or ExportLimit::PERCENTAGE" unless [ExportLimit::WATT,ExportLimit::PERCENTAGE].include? enable
87
- raise ArgumentError, "Value should be set for export limitation" unless value
88
- params = [1, value, enable]
89
- end
90
- update_inverter_setting(serial_number,'maxSetApi','backflow_setting',params)
91
- end
92
-
93
- # utility function to get date accordign timespan month/day
94
- def timespan_date(timespan=Timespan::DAY,date=Time.now)
95
- if Timespan::YEAR.eql? timespan
96
- date.strftime("%Y")
97
- elsif Timespan::MONTH.eql? timespan
98
- date.strftime("%Y-%m")
99
- elsif Timespan::DAY.eql? timespan
100
- date.strftime("%Y-%m-%d")
101
- end
102
- end
103
-
104
- #
105
- # functions below are copied from python example code but not sure if these work with MOD9000 inverters
106
- #
107
- def inverter_data(inverter_id,type=Timespan::DAY,date=Time.now)
108
- if Timespan::DAY.eql? type
109
- operator = 'getInverterData_max'
110
- elsif Timespan::MONTH.eql? type
111
- operator = 'getMaxMonthPac'
112
- elsif Timespan::YEAR.eql? type
113
- operator = 'getMaxYearPac'
114
- end
115
- _inverter_api({
116
- 'op': operator,
117
- 'id': inverter_id,
118
- 'type': 1,
119
- 'date': timespan_date(type,date)
120
- })
121
- end
122
- =begin
123
- def inverter_detail(inverter_id)
124
- _inverter_api({
125
- 'op': 'getInverterDetailData',
126
- 'inverterId': inverter_id
127
- })
128
- end
129
- def inverter_detail_two(inverter_id)
130
- _inverter_api({
131
- 'op': 'getInverterDetailData_two',
132
- 'inverterId': inverter_id
133
- })
134
- end
135
- =end
136
- def update_mix_inverter_setting(serial_number, setting_type, parameters)
137
- update_inverter_setting(serial_number,'mixSetApiNew',setting_type,parameters)
138
- end
139
- def update_ac_inverter_setting(serial_number, setting_type, parameters)
140
- update_inverter_setting(serial_number,'spaSetApi',setting_type,parameters)
141
- end
142
-
143
-
144
- private
145
- def self.api_endpoint(method,path)
146
- # all records
147
- self.send(:define_method, method) do |params = {}|
148
- # return data result
149
- get(path,params) do |request|
150
- request.headers['Accept'] = "application/#{format}"
151
- end
152
- end
153
- end
154
- api_endpoint :_plant_list, 'PlantListAPI.do'
155
- api_endpoint :_plant_detail, 'PlantDetailAPI.do'
156
- api_endpoint :_inverter_api, 'newInverterAPI.do'
157
- api_endpoint :_plant_info, 'newTwoPlantAPI.do'
158
-
159
- end
160
- end
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('api', __dir__)
4
+ require File.expand_path('const', __dir__)
5
+ require File.expand_path('error', __dir__)
6
+
7
+ module Growatt
8
+ # Wrapper for the Growatt REST API
9
+ #
10
+ # This class provides methods to interact with the Growatt API, including:
11
+ # - Retrieving plant and inverter data
12
+ # - Managing inverters (turning them on/off, updating settings)
13
+ # - Handling authentication and session data
14
+ #
15
+ # @note No official API documentation is available; this was reverse-engineered.
16
+ #
17
+ # @see Growatt::Authentication For authentication-related logic.
18
+ class Client < API
19
+ # Initializes the Growatt API client.
20
+ #
21
+ # @param options [Hash] Additional options for the API client.
22
+ def initialize(options = {})
23
+ super(options)
24
+ end
25
+
26
+ # Retrieves login session data.
27
+ #
28
+ # @return [Hash, nil] The login data stored in @login_data.
29
+ def login_info
30
+ @login_data
31
+ end
32
+
33
+ # Retrieves a list of plants associated with a user.
34
+ #
35
+ # @param user_id [String, nil] The user ID; if nil, it defaults to the logged-in user's ID.
36
+ # @return [Hash] The list of plants.
37
+ def plant_list(user_id = nil)
38
+ user_id ||= login_info['user']['id']
39
+ _plant_list({ 'userId': user_id })
40
+ end
41
+
42
+ # Retrieves detailed information about a plant.
43
+ #
44
+ # @param plant_id [String] The plant ID.
45
+ # @param type [String] The timespan type (default: `Timespan::DAY`).
46
+ # @param date [Time] The date for the requested timespan (default: `Time.now`).
47
+ # @return [Hash] The plant details.
48
+ def plant_detail(plant_id, type = Timespan::DAY, date = Time.now)
49
+ _plant_detail({
50
+ 'plantId': plant_id,
51
+ 'type': type,
52
+ 'date': timespan_date(type, date)
53
+ })
54
+ end
55
+
56
+ # Retrieves plant information.
57
+ #
58
+ # @param plant_id [String] The plant ID.
59
+ # @return [Hash] Plant information, including available devices.
60
+ def plant_info(plant_id)
61
+ _plant_info({
62
+ 'op': 'getAllDeviceList',
63
+ 'plantId': plant_id,
64
+ 'pageNum': 1,
65
+ 'pageSize': 1
66
+ })
67
+ end
68
+
69
+ # Retrieves a list of devices in a plant.
70
+ #
71
+ # @param plant_id [String] The plant ID.
72
+ # @return [Array] A list of devices.
73
+ def device_list(plant_id)
74
+ plant_info(plant_id).deviceList
75
+ end
76
+
77
+ # Retrieves a list of inverters in a plant.
78
+ #
79
+ # @param plant_id [String] The plant ID.
80
+ # @return [Array] A list of inverters.
81
+ def inverter_list(plant_id)
82
+ devices = device_list(plant_id)
83
+ devices.select { |device| 'inverter'.eql? device.deviceType }
84
+ end
85
+
86
+ # Retrieves data for inverter control.
87
+ #
88
+ # @param inverter_id [String] The inverter's serial number.
89
+ # @return [Hash] The inverter's control data.
90
+ def inverter_control_data(inverter_id)
91
+ _inverter_api({
92
+ 'op': 'getMaxSetData',
93
+ 'serialNum': inverter_id
94
+ }).obj.maxSetBean
95
+ end
96
+
97
+ # Updates an inverter's setting.
98
+ #
99
+ # @param serial_number [String] The inverter's serial number.
100
+ # @param command [String] The command to execute.
101
+ # @param param_id [String] The parameter ID.
102
+ # @param parameters [Array, Hash] The parameters to send.
103
+ # @return [Boolean] `true` if the update was successful, `false` otherwise.
104
+ def update_inverter_setting(serial_number, command, param_id, parameters)
105
+ command_parameters = {
106
+ 'op': command,
107
+ 'serialNum': serial_number,
108
+ 'paramId': param_id
109
+ }
110
+
111
+ parameters = parameters.map.with_index { |value, index| ["param#{index + 1}", value] }.to_h if parameters.is_a? Array
112
+ self.format = 'x-www-form-urlencoded'
113
+ data = JSON.parse(post('newTcpsetAPI.do', command_parameters.merge(parameters)).body)
114
+ self.format = :json
115
+ data['success']
116
+ end
117
+
118
+ # Turns an inverter on or off.
119
+ #
120
+ # @param serial_number [String] The inverter's serial number.
121
+ # @param on [Boolean] `true` to turn on, `false` to turn off.
122
+ # @return [Boolean] `true` if the operation was successful.
123
+ def turn_inverter(serial_number, on = true)
124
+ onoff = (on ? Inverter::ON : Inverter::OFF)
125
+ update_inverter_setting(serial_number, 'maxSetApi', 'max_cmd_on_off', [onoff])
126
+ end
127
+
128
+ # Checks if an inverter is turned on.
129
+ #
130
+ # @param serial_number [String] The inverter's serial number.
131
+ # @return [Boolean] `true` if the inverter is on, `false` otherwise.
132
+ def inverter_on?(serial_number)
133
+ status = inverter_control_data(serial_number)
134
+ Inverter::ON.eql? status.max_cmd_on_off
135
+ end
136
+
137
+ # Sets export limit for an inverter.
138
+ #
139
+ # @param serial_number [String] The inverter's serial number.
140
+ # @param enable [String] `ExportLimit::DISABLE`, `ExportLimit::WATT`, or `ExportLimit::PERCENTAGE`.
141
+ # @param value [Numeric, nil] The export limit value (required unless disabled).
142
+ # @return [Boolean] `true` if the setting update was successful.
143
+ def export_limit(serial_number, enable, value = nil)
144
+ if ExportLimit::DISABLE.eql? enable
145
+ params = [0]
146
+ else
147
+ validate_export_parameters(enable, value)
148
+ params = [1, value, enable]
149
+ end
150
+ update_inverter_setting(serial_number, 'maxSetApi', 'backflow_setting', params)
151
+ end
152
+
153
+ # Utility function to get a formatted date based on timespan.
154
+ #
155
+ # @param timespan [String] The timespan type (`Timespan::DAY`, `Timespan::MONTH`, `Timespan::YEAR`).
156
+ # @param date [Time] The date (default: `Time.now`).
157
+ # @return [String] The formatted date.
158
+ def timespan_date(timespan = Timespan::DAY, date = Time.now)
159
+ case timespan
160
+ when Timespan::YEAR
161
+ date.strftime("%Y")
162
+ when Timespan::MONTH
163
+ date.strftime("%Y-%m")
164
+ when Timespan::DAY
165
+ date.strftime("%Y-%m-%d")
166
+ end
167
+ end
168
+
169
+ # Retrieves inverter data based on timespan.
170
+ #
171
+ # @param inverter_id [String] The inverter's ID.
172
+ # @param type [String] The timespan type.
173
+ # @param date [Time] The date (default: `Time.now`).
174
+ # @return [Hash] The inverter data.
175
+ def inverter_data(inverter_id, type = Timespan::DAY, date = Time.now)
176
+ operator =
177
+ case type
178
+ when Timespan::DAY then 'getInverterData_max'
179
+ when Timespan::MONTH then 'getMaxMonthPac'
180
+ when Timespan::YEAR then 'getMaxYearPac'
181
+ end
182
+
183
+ _inverter_api({
184
+ 'op': operator,
185
+ 'id': inverter_id,
186
+ 'type': 1,
187
+ 'date': timespan_date(type, date)
188
+ })
189
+ end
190
+
191
+ private
192
+
193
+ # Defines API endpoints dynamically.
194
+ #
195
+ # @param method [Symbol] The method name.
196
+ # @param path [String] The API endpoint.
197
+ def self.api_endpoint(method, path)
198
+ define_method(method) do |params = {}|
199
+ get(path, params) do |request|
200
+ request.headers['Accept'] = "application/#{format}"
201
+ end
202
+ end
203
+ end
204
+
205
+ api_endpoint :_plant_list, 'PlantListAPI.do'
206
+ api_endpoint :_plant_detail, 'PlantDetailAPI.do'
207
+ api_endpoint :_inverter_api, 'newInverterAPI.do'
208
+ api_endpoint :_plant_info, 'newTwoPlantAPI.do'
209
+
210
+ # Validates export limitation parameters.
211
+ def validate_export_parameters(enable, value)
212
+ unless [ExportLimit::WATT, ExportLimit::PERCENTAGE].include?(enable)
213
+ raise ArgumentError, "Export limitation must be ExportLimit::WATT or ExportLimit::PERCENTAGE"
214
+ end
215
+ raise ArgumentError, "Value is required" unless value
216
+ raise ArgumentError, "Value must be numeric" unless value.is_a? Numeric
217
+ end
218
+ end
219
+ end
@@ -1,26 +1,45 @@
1
- require 'faraday'
2
- require 'faraday-cookie_jar'
3
-
4
- module Growatt
5
- # Create connection and use cookies for authentication tokens
6
- module Connection
7
- def connection
8
- raise ConfigurationError, "Option for endpoint is not defined" unless endpoint
9
-
10
- options = setup_options
11
- @connection ||= Faraday::Connection.new(options) do |connection|
12
- connection.use :cookie_jar
13
-
14
- connection.use Faraday::Response::RaiseError
15
- connection.adapter Faraday.default_adapter
16
- setup_authorization(connection)
17
- setup_headers(connection)
18
- connection.response :json, content_type: /\bjson$/
19
- connection.use Faraday::Request::UrlEncoded
20
-
21
- setup_logger_filtering(connection, logger) if logger
22
- end
23
- end
24
-
25
- end
26
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday-cookie_jar'
5
+
6
+ module Growatt
7
+ # Handles HTTP connection setup and authentication management.
8
+ #
9
+ # This module establishes a Faraday connection to interact with the Growatt API,
10
+ # ensuring proper authentication via cookies and setting up required headers.
11
+ module Connection
12
+ # Establishes a Faraday connection with appropriate middleware and settings.
13
+ #
14
+ # @return [Faraday::Connection] The configured Faraday connection instance.
15
+ # @raise [ConfigurationError] If the API endpoint is not defined.
16
+ def connection
17
+ raise ConfigurationError, "Option for endpoint is not defined" unless endpoint
18
+
19
+ options = setup_options
20
+ @connection ||= Faraday::Connection.new(options) do |connection|
21
+ # Enable cookie-based authentication
22
+ connection.use :cookie_jar
23
+
24
+ # Handle HTTP response errors
25
+ connection.use Faraday::Response::RaiseError
26
+
27
+ # Set up default Faraday adapter
28
+ connection.adapter Faraday.default_adapter
29
+
30
+ # Configure authentication and request headers
31
+ setup_authorization(connection)
32
+ setup_headers(connection)
33
+
34
+ # Parse JSON responses automatically
35
+ connection.response :json, content_type: /\bjson$/
36
+
37
+ # Ensure requests are URL-encoded
38
+ connection.use Faraday::Request::UrlEncoded
39
+
40
+ # Configure logging if a logger is present
41
+ setup_logger_filtering(connection, logger) if logger
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/growatt/const.rb CHANGED
@@ -1,29 +1,47 @@
1
-
2
- module Growatt
3
- class Enum
4
- def self.enum(array)
5
- array.each do |c|
6
- const_set c,c
7
- end
8
- end
9
- end
10
-
11
- class Timespan
12
- HOUR = 0
13
- DAY = 1
14
- MONTH = 2
15
- YEAR = 3
16
- end
17
-
18
- class Inverter
19
- ON = "0101"
20
- OFF = "0000"
21
- end
22
-
23
- class ExportLimit
24
- DISABLE = -1
25
- WATT = 1
26
- PERCENTAGE = 0
27
- end
28
-
29
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Growatt
4
+ # A base class for defining enumerations using constants.
5
+ #
6
+ # This class dynamically defines constants from an array of strings.
7
+ class Enum
8
+ # Defines constants based on the provided array.
9
+ #
10
+ # @param array [Array<String>] The array of strings to be converted into constants.
11
+ def self.enum(array)
12
+ array.each do |c|
13
+ const_set c, c
14
+ end
15
+ end
16
+ end
17
+
18
+ # Represents different timespan options for data retrieval.
19
+ class Timespan
20
+ # Hourly data timespan
21
+ HOUR = 0
22
+ # Daily data timespan
23
+ DAY = 1
24
+ # Monthly data timespan
25
+ MONTH = 2
26
+ # Yearly data timespan
27
+ YEAR = 3
28
+ end
29
+
30
+ # Represents possible states for an inverter.
31
+ class Inverter
32
+ # Inverter is turned on
33
+ ON = "0101"
34
+ # Inverter is turned off
35
+ OFF = "0000"
36
+ end
37
+
38
+ # Represents export limit settings for an inverter.
39
+ class ExportLimit
40
+ # Disables export limitation
41
+ DISABLE = -1
42
+ # Export limit is set in watts
43
+ WATT = 1
44
+ # Export limit is set in percentage
45
+ PERCENTAGE = 0
46
+ end
47
+ end