sma_api 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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 568631fddbba2a58f8967bcc1e794502c2d79fef112429b48b98ce8120ece476
4
+ data.tar.gz: 1cf5bfdb6f873b24e6364abacde7adea8886415f58e0c5629fcddfd7d8ccef90
5
+ SHA512:
6
+ metadata.gz: 6c3d0d6bd0f85f2cbd16ee090951bf0482cfedd13496d1852f3022ef15ca6d343fc566ca8ac12810c90f208cce3182db6815db74c6fb04f1959b763fee6bf7f7
7
+ data.tar.gz: 5d3db35181b73f5e64e61108f82aa46839f1ad92cad958ed5e56567c6c7be4a977013ad1fd14cec511b26a29bbbf9dc260728b1305dcf7b06293ae277c58b0e4
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Rutger Wessels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,110 @@
1
+ # SmaApi
2
+
3
+ This gem provides an API for the web interface of SMA inverters.
4
+
5
+ The gem is in early development and should not be considered stable. Everything might change.
6
+
7
+ ## Supported inverters
8
+
9
+ This gem has been developed using a SMA Sunny Boy 3.0 (SB3.0-1AV-41 902).
10
+ Firmware version is 3.10.18.R
11
+
12
+ It will probably work with other SMA products that have a recent firmware. But
13
+ it has not been tested.
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'sma_api', git: 'https://github.com/rutgerw/sma_api'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ $ bundle
26
+
27
+ Or install it yourself as:
28
+
29
+ $ gem install sma_api
30
+
31
+ ## Usage
32
+
33
+ The web interface of the inverter does not allow an unlimited number of sessions.
34
+ There seems to be a limit of 4 sessions. Another attempt to login will result into
35
+ an error message from the web interface, which is turned into a `SmaApi::Error`
36
+ that has the `Creating session failed` message. The software in the inverter will
37
+ free up a session after a 5 minute inactivity.
38
+
39
+ There are different ways of handling the session:
40
+ - Create the `SmaApi::Client` instance just once and use it multiple times
41
+ - Store the session id in a cache (file, Redis or another solution)
42
+ - Use `client.destroy_session` to explicitly remove the session
43
+
44
+ ### Create client once
45
+
46
+ ```ruby
47
+ require 'sma_api'
48
+
49
+ client = SmaApi::Client.new(host: 'inverter address', password: 'password')
50
+
51
+ while true do
52
+ # Current production
53
+ puts client.get_values(['6100_40263F00'])
54
+ sleep 5
55
+ end
56
+ ```
57
+
58
+ ### Cache the session id
59
+
60
+ In case the `sid` is not valid anymore, the client will try to create a new session.
61
+
62
+ ```ruby
63
+ require 'sma_api'
64
+
65
+ # Cache the sid in this file
66
+ sid_file = '/tmp/sma_sid.txt'
67
+
68
+ sid = File.read(sid_file).chop rescue ''
69
+
70
+ client = SmaApi::Client.new(host: ENV['SMA_API_HOST'], password: ENV['SMA_API_WEB_PASSWORD'], sid: sid)
71
+
72
+ while true do
73
+ current_yield = client.get_values(['6100_40263F00'])
74
+
75
+ # If sid has been changed, save it to the sid file
76
+ if client.sid != sid
77
+ File.open(sid_file, 'w') { |f| f.puts sid }
78
+ end
79
+
80
+ puts "#{Time.now}\tCurrent yield: #{current_yield}"
81
+
82
+ sleep 2
83
+ end
84
+ ```
85
+
86
+ ### Use destroy_session
87
+
88
+ This is the same as logging out from the web interface.
89
+
90
+ ```ruby
91
+ require 'sma_api'
92
+
93
+ client = SmaApi::Client.new(host: 'inverter address', password: 'password', sid: sid)
94
+
95
+ # Current production
96
+ puts client.get_values(['6100_40263F00'])
97
+
98
+ client.destroy_session
99
+
100
+ # or:
101
+ at_exit { client.destroy_session }
102
+ ```
103
+
104
+ ## Contributing
105
+
106
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rutgerw/sma_api.
107
+
108
+ ## License
109
+
110
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,9 @@
1
+ require "sma_api/client"
2
+ require "sma_api/http"
3
+ require "sma_api/version"
4
+
5
+
6
+ module SmaApi
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmaApi
4
+ # Ruby client for communicating with SMA Inverter web interface
5
+ class Client
6
+ def initialize(host:, password:, sid: nil)
7
+ @host = host
8
+ @password = password
9
+ @client = Http.new(host: host, password: password, sid: sid)
10
+ end
11
+
12
+ # The current session id. If empty, it will create a new session
13
+ #
14
+ # @return [String] the session id that will be used in subsequent requests.
15
+ def sid
16
+ @client.sid
17
+ end
18
+
19
+ # Logout and clear the session. This is the same as using Logout from the web interface
20
+ #
21
+ # @return [String] An empty session id
22
+ def destroy_session
23
+ @client.destroy_session
24
+ end
25
+
26
+ # Retrieve values specified by the keys using getValues.json endpoint.
27
+ #
28
+ # @param keys [Array<String>] List of keys
29
+ # @return [Hash] Key-value pairs
30
+ def get_values(keys)
31
+ result = @client.post('/dyn/getValues.json', { destDev: [], keys: keys })
32
+ return nil unless result['result']
33
+
34
+ keys.each_with_object({}) do |k, h|
35
+ h[k] = scalar_value(result['result'].first[1][k])
36
+ end
37
+ end
38
+
39
+ # Retrieve list of files and directories for the path.
40
+ #
41
+ # @return [Array] List of directories and files
42
+ def get_fs(path)
43
+ result = @client.post('/dyn/getFS.json', { destDev: [], path: path })
44
+
45
+ result['result'].first[1][path].map do |f|
46
+ type = f.key?('f') ? 'f' : 'd'
47
+ {
48
+ name: f['d'] || f['f'],
49
+ type: type,
50
+ last_modified: Time.at(f['tm']),
51
+ size: f['s']
52
+ }
53
+ end
54
+ end
55
+
56
+ # Download the file specified by url and store it in a file on the local file system.
57
+ #
58
+ # @param url [String] URL without /fs prefix. It should start with a '/'
59
+ # @param path [String] Path of the local file
60
+ def download(path, target)
61
+ @client.download(path, target)
62
+ end
63
+
64
+ # ObjectMetadata_Istl endpoint
65
+ #
66
+ # @return [Array] List of available object metadata
67
+ def object_metadata
68
+ @client.post('/data/ObjectMetadata_Istl.json')
69
+ end
70
+
71
+ private
72
+
73
+ def scalar_value(value)
74
+ value['1'].first['val']
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+
6
+ module SmaApi
7
+ # Net::HTTP wrapper for the SMA Inverter web interface
8
+ class Http
9
+ def initialize(host:, password:, sid: nil)
10
+ @host = host
11
+ @password = password
12
+ @sid = sid || ''
13
+ end
14
+
15
+ # Perform a HTTP POST.
16
+ #
17
+ # @param url [String] URL. It should start with a '/'
18
+ # @param payload [Hash] The payload that will be used in the post
19
+ # @return [Hash] The response
20
+ def post(url, payload = {})
21
+ create_session if @sid.empty?
22
+
23
+ response = JSON.parse(http.post(url_with_sid(url), payload.to_json).body)
24
+
25
+ return response unless response.key? 'err'
26
+
27
+ raise SmaApi::Error, "Error #{response['err']} during request" unless response['err'] == 401
28
+
29
+ create_session
30
+ post(url, payload)
31
+ end
32
+
33
+ # Download the file specified by url and store it in a file on the local file system.
34
+ #
35
+ # @param url [String] URL without /fs prefix. It should start with a '/'
36
+ # @param path [String] Path of the local file
37
+ def download(url, path)
38
+ create_session if @sid.empty?
39
+
40
+ file = File.open(path, 'wb')
41
+
42
+ begin
43
+ res = retrieve_file(url)
44
+
45
+ file.write(res.body)
46
+ ensure
47
+ file.close
48
+ end
49
+ end
50
+
51
+ # Creates a session using the supplied password
52
+ #
53
+ # @raise [SmaApi::Error] Creating session failed, for example if the password is wrong
54
+ # @return [String] the session id that will be used in subsequent requests.
55
+ def create_session
56
+ payload = {
57
+ right: 'usr', pass: @password
58
+ }
59
+ result = JSON.parse(http.post('/dyn/login.json', payload.to_json).body).fetch('result', {})
60
+
61
+ raise SmaApi::Error, 'Creating session failed' unless result['sid']
62
+
63
+ @sid = result['sid']
64
+ end
65
+
66
+ # The current session id. If empty, it will create a new session
67
+ #
68
+ # @return [String] the session id that will be used in subsequent requests.
69
+ def sid
70
+ create_session if @sid.empty?
71
+
72
+ @sid
73
+ end
74
+
75
+ # Logout and clear the session. This is the same as using Logout from the web interface
76
+ #
77
+ # @return [String] An empty session id
78
+ def destroy_session
79
+ post('/dyn/logout.json', {})
80
+
81
+ @sid = ''
82
+ end
83
+
84
+ private
85
+
86
+ def url_with_sid(url)
87
+ url + "?sid=#{@sid}"
88
+ end
89
+
90
+ def retrieve_file(url)
91
+ res = http.get('/fs' + url_with_sid(url))
92
+
93
+ unless res.code == '200'
94
+ # Try again because invalid sid does not result in a 401
95
+ create_session
96
+ res = http.get('/fs' + url_with_sid(url))
97
+
98
+ raise "Error retrieving file (#{res.code} #{res.message})" unless res.code == '200'
99
+ end
100
+
101
+ res
102
+ end
103
+
104
+ def http
105
+ @http ||= configure_http_client
106
+ end
107
+
108
+ def configure_http_client
109
+ client = Net::HTTP.new(@host, 443)
110
+ client.use_ssl = true
111
+ client.verify_mode = OpenSSL::SSL::VERIFY_NONE
112
+
113
+ client
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,3 @@
1
+ module SmaApi
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,173 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sma_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rutger Wessels
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.17'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.17'
27
+ - !ruby/object:Gem::Dependency
28
+ name: byebug
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '11.1'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 11.1.3
37
+ type: :development
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '11.1'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 11.1.3
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '10.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '10.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.9'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.9'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rubocop
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.82.0
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.82.0
89
+ - !ruby/object:Gem::Dependency
90
+ name: rubocop-rspec
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.39'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.39'
103
+ - !ruby/object:Gem::Dependency
104
+ name: vcr
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '5.1'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '5.1'
117
+ - !ruby/object:Gem::Dependency
118
+ name: webmock
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '3.8'
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: 3.8.3
127
+ type: :development
128
+ prerelease: false
129
+ version_requirements: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - "~>"
132
+ - !ruby/object:Gem::Version
133
+ version: '3.8'
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: 3.8.3
137
+ description: Extract data from a SMA Inverter web interface.
138
+ email:
139
+ - rutger@rutgerwessels.nl
140
+ executables: []
141
+ extensions: []
142
+ extra_rdoc_files: []
143
+ files:
144
+ - License.txt
145
+ - README.md
146
+ - lib/sma_api.rb
147
+ - lib/sma_api/client.rb
148
+ - lib/sma_api/http.rb
149
+ - lib/sma_api/version.rb
150
+ homepage: https://github.com/rutgerw/sma_api
151
+ licenses:
152
+ - MIT
153
+ metadata: {}
154
+ post_install_message:
155
+ rdoc_options: []
156
+ require_paths:
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ required_rubygems_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ requirements: []
169
+ rubygems_version: 3.1.2
170
+ signing_key:
171
+ specification_version: 4
172
+ summary: Extract data from a SMA Inverter web interface.
173
+ test_files: []