senec 0.19.0 → 0.21.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.
Binary file
@@ -1,129 +1,214 @@
1
- require 'faraday'
2
- require 'json'
1
+ require 'oauth2'
3
2
 
4
3
  module Senec
5
4
  module Cloud
6
- BASE_URL = 'https://mein-senec.de'.freeze
5
+ CONFIG_URL =
6
+ 'https://sso.senec.com/realms/senec/.well-known/openid-configuration'.freeze
7
+
8
+ CLIENT_ID = 'endcustomer-app-frontend'.freeze
9
+ REDIRECT_URI = 'senec-app-auth://keycloak.prod'.freeze
10
+ SCOPE = 'roles meinsenec'.freeze
11
+
12
+ SYSTEMS_HOST = 'https://senec-app-systems-proxy.prod.senec.dev'.freeze
13
+ MEASUREMENTS_HOST = 'https://senec-app-measurements-proxy.prod.senec.dev'.freeze
14
+ WALLBOX_HOST = 'https://senec-app-wallbox-proxy.prod.senec.dev'.freeze
7
15
 
8
16
  class Connection
9
17
  DEFAULT_USER_AGENT = "ruby-senec/#{Senec::VERSION} (+https://github.com/solectrus/senec)".freeze
10
- MAX_REDIRECTS = 10
11
18
 
12
19
  def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT)
13
20
  @username = username
14
21
  @password = password
15
22
  @user_agent = user_agent
16
- @cookies = {}
17
23
  end
18
24
 
19
- attr_reader :username, :password, :user_agent, :cookies
25
+ attr_reader :username, :password, :user_agent
26
+
27
+ def authenticate!
28
+ code_verifier = SecureRandom.alphanumeric(43)
29
+ digest = Digest::SHA256.digest(code_verifier)
30
+ code_challenge = Base64.urlsafe_encode64(digest).delete('=')
31
+
32
+ auth_url =
33
+ oauth_client.auth_code.authorize_url(
34
+ redirect_uri: REDIRECT_URI,
35
+ scope: SCOPE,
36
+ code_challenge:,
37
+ code_challenge_method: 'S256',
38
+ )
39
+
40
+ # Manual HTTP needed for Keycloak cross-domain form handling
41
+ login_form_url = fetch_login_form_url(auth_url)
42
+ redirect_url = submit_credentials(login_form_url)
43
+ authorization_code = extract_authorization_code(redirect_url)
44
+
45
+ self.oauth_token =
46
+ oauth_client.auth_code.get_token(
47
+ authorization_code,
48
+ redirect_uri: REDIRECT_URI,
49
+ code_verifier:,
50
+ )
51
+ end
20
52
 
21
53
  def authenticated?
22
- authenticate if cookies.empty?
54
+ !!oauth_token
55
+ end
23
56
 
24
- cookies.key?('sso.senec.com_KEYCLOAK_IDENTITY')
57
+ def systems
58
+ get "#{SYSTEMS_HOST}/v1/systems"
25
59
  end
26
60
 
27
- def authenticate
28
- response = request_with_redirects(Cloud::BASE_URL)
61
+ def system_details(system_id)
62
+ get "#{SYSTEMS_HOST}/systems/#{system_id}/details"
63
+ end
29
64
 
30
- # Find form with username and password inputs
31
- form_match = find_login_form(response.body)
32
- raise Error, 'Login form not found!' unless form_match
65
+ def dashboard(system_id)
66
+ get "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
67
+ end
33
68
 
34
- # Perform the login request with the extracted form action URL
35
- form_action = form_match.gsub('&', '&')
36
- request_with_redirects(
37
- form_action, {
38
- 'username' => username,
39
- 'password' => password
40
- },
41
- )
69
+ def wallbox(system_id, wallbox_id)
70
+ get "#{WALLBOX_HOST}/v1/systems/#{system_id}/wallboxes/#{wallbox_id}"
42
71
  end
43
72
 
44
- def simple_request(url)
45
- perform_request(url)
73
+ def wallbox_search(system_id)
74
+ post "#{WALLBOX_HOST}/v1/systems/wallboxes/search", { systemIds: [system_id] }
46
75
  end
47
76
 
48
- def request_with_redirects(url, data = nil)
49
- uri = URI(url)
50
- redirect_count = 0
51
- response = nil
77
+ private
52
78
 
53
- loop do
54
- response = perform_request(uri.to_s, data)
55
- store_cookies(response)
79
+ attr_accessor :oauth_token
56
80
 
57
- break unless (300..399).cover?(response.status)
81
+ def fetch_login_form_url(auth_url)
82
+ response = http_request(:get, auth_url)
83
+ store_cookies(response) # Required for Keycloak CSRF protection
84
+ extract_form_action_url(response.body)
85
+ end
58
86
 
59
- location = response.headers['location']
60
- break unless location
87
+ def extract_form_action_url(html)
88
+ forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
61
89
 
62
- redirect_count += 1
63
- raise 'Too many redirects!' if redirect_count > MAX_REDIRECTS
90
+ forms.each do |action_url, form_content|
91
+ has_username = form_content.match(/name=["']?username["']?/i)
92
+ has_password = form_content.match(/name=["']?password["']?/i)
64
93
 
65
- uri = location.start_with?('http') ? URI(location) : URI.join(uri, location)
66
- data = nil # Clear data after first request (no POST redirects)
94
+ return CGI.unescapeHTML(action_url) if has_username && has_password
67
95
  end
68
96
 
69
- response
97
+ # :nocov:
98
+ raise 'Login form not found'
99
+ # :nocov:
70
100
  end
71
101
 
72
- private
102
+ def submit_credentials(form_url)
103
+ credentials = { username:, password: }
104
+ response = http_request(:post, form_url, data: credentials)
105
+ raise 'Login failed' unless response.status == 302
73
106
 
74
- def find_login_form(html_body)
75
- # Find all forms and check if they contain both username and password inputs
76
- html_body.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}m).each do |action, form_content|
77
- has_username = form_content.match(/input[^>]*name=["']username["'][^>]*/)
78
- has_password = form_content.match(/input[^>]*name=["']password["'][^>]*/)
107
+ response.headers['location'] || raise('No redirect location')
108
+ end
79
109
 
80
- return action if has_username && has_password
81
- end
110
+ def extract_authorization_code(redirect_url)
111
+ raise 'Invalid redirect URL' unless redirect_url&.start_with?(REDIRECT_URI)
112
+
113
+ uri = URI(redirect_url)
114
+ params = URI.decode_www_form(uri.query).to_h
115
+
116
+ params['code'] || raise('No authorization code found')
117
+ end
118
+
119
+ def ensure_token_valid
120
+ authenticate! unless authenticated?
121
+ return true unless oauth_token.expired?
82
122
 
123
+ self.oauth_token = oauth_token.refresh!
124
+ true
125
+ rescue StandardError => e
83
126
  # :nocov:
84
- nil
127
+ warn "Token refresh failed: #{e.message}"
128
+ false
85
129
  # :nocov:
86
130
  end
87
131
 
88
- def faraday
89
- @faraday ||= Faraday.new do |f|
90
- f.adapter :net_http_persistent, pool_size: 1 do |http|
91
- # :nocov:
92
- http.idle_timeout = 400
93
- # :nocov:
94
- end
95
- end
132
+ def get(url, default: nil)
133
+ return default unless ensure_token_valid
134
+
135
+ response = oauth_token.get(url)
136
+ return default unless response.status == 200
137
+
138
+ JSON.parse(response.body)
139
+ rescue StandardError => e
140
+ # :nocov:
141
+ warn "API error: #{e.message}"
142
+ default
143
+ # :nocov:
144
+ end
145
+
146
+ def post(url, data, default: nil)
147
+ return default unless ensure_token_valid
148
+
149
+ response = oauth_token.post(
150
+ url,
151
+ body: data.to_json,
152
+ headers: { 'Content-Type' => 'application/json' },
153
+ )
154
+ return default unless response.status == 200
155
+
156
+ JSON.parse(response.body)
157
+ rescue StandardError => e
158
+ # :nocov:
159
+ warn "API error: #{e.message}"
160
+ default
161
+ # :nocov:
96
162
  end
97
163
 
98
- def perform_request(url, data = nil)
99
- method = data ? :post : :get
100
- faraday.public_send(method, url) do |req|
101
- configure_request_headers(req)
102
- if method == :post
103
- req.body = URI.encode_www_form(data)
104
- req.headers['content-type'] = 'application/x-www-form-urlencoded'
164
+ def http_request(method, url, data: nil)
165
+ Faraday
166
+ .new
167
+ .send(method, url) do |req|
168
+ req.headers['user-agent'] = user_agent
169
+ req.headers['connection'] = 'keep-alive'
170
+ req.headers['cookie'] = cookie_string if cookies.any?
171
+ req.body = URI.encode_www_form(data) if data
105
172
  end
106
- end
107
173
  end
108
174
 
109
- def configure_request_headers(request)
110
- request.headers['user-agent'] = user_agent
111
- request.headers['connection'] = 'keep-alive'
112
- request.headers['cookie'] = cookies.values.join('; ') unless cookies.empty?
175
+ def oauth_client
176
+ @oauth_client ||=
177
+ OAuth2::Client.new(
178
+ CLIENT_ID,
179
+ nil,
180
+ site: openid_config['issuer'],
181
+ authorize_url: openid_config['authorization_endpoint'],
182
+ token_url: openid_config['token_endpoint'],
183
+ )
184
+ end
185
+
186
+ def openid_config
187
+ @openid_config ||= JSON.parse(http_request(:get, CONFIG_URL).body)
188
+ rescue StandardError => e
189
+ # :nocov:
190
+ raise "Failed to load OpenID configuration: #{e.message}"
191
+ # :nocov:
192
+ end
193
+
194
+ def cookies
195
+ @cookies ||= {}
196
+ end
197
+
198
+ def cookie_string
199
+ cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
113
200
  end
114
201
 
115
202
  def store_cookies(response)
116
- host = URI(response.env.url).host
117
- cookie_header = response.headers['set-cookie']
118
- return unless cookie_header
119
-
120
- Array(cookie_header).each do |cookie_string|
121
- cookie_string.split(', ').each do |cookie|
122
- cookie_name = cookie.split('=').first
123
- cookie_value = cookie.split(';').first
124
- @cookies["#{host}_#{cookie_name}"] = cookie_value
203
+ set_cookie = response.headers['set-cookie']
204
+ return unless set_cookie
205
+
206
+ set_cookie
207
+ .split(', ')
208
+ .each do |cookie_header|
209
+ name, value = cookie_header.split(';').first.split('=', 2)
210
+ cookies[name] = value if name && value
125
211
  end
126
- end
127
212
  end
128
213
  end
129
214
  end
@@ -13,6 +13,7 @@ module Senec
13
13
  attr_reader :url
14
14
 
15
15
  extend Forwardable
16
+
16
17
  def_delegators :faraday, :get, :post
17
18
 
18
19
  private
data/lib/senec/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Senec
2
- VERSION = '0.19.0'.freeze
2
+ VERSION = '0.21.0'.freeze
3
3
  end
data/lib/senec.rb CHANGED
@@ -4,6 +4,5 @@ require 'senec/local/state'
4
4
  require 'senec/local/request'
5
5
  require 'senec/local/error'
6
6
 
7
- require 'senec/cloud/stats_overview'
8
- require 'senec/cloud/wallboxes'
7
+ require 'senec/cloud/connection'
9
8
  require 'senec/cloud/error'
Binary file
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: senec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Georg Ledermann
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: oauth2
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  description: Access your local SENEC Solar Battery Storage System
55
69
  email:
56
70
  - georg@ledermann.dev
@@ -68,6 +82,7 @@ files:
68
82
  - ".ruby-lsp/.gitignore"
69
83
  - ".ruby-lsp/Gemfile"
70
84
  - ".ruby-lsp/Gemfile.lock"
85
+ - ".ruby-lsp/last_updated"
71
86
  - ".ruby-lsp/main_lockfile_hash"
72
87
  - ".ruby-lsp/needs_update"
73
88
  - ".vscode/settings.json"
@@ -111,10 +126,9 @@ files:
111
126
  - coverage/index.html
112
127
  - lib/.DS_Store
113
128
  - lib/senec.rb
129
+ - lib/senec/.DS_Store
114
130
  - lib/senec/cloud/connection.rb
115
131
  - lib/senec/cloud/error.rb
116
- - lib/senec/cloud/stats_overview.rb
117
- - lib/senec/cloud/wallboxes.rb
118
132
  - lib/senec/local/connection.rb
119
133
  - lib/senec/local/constants.rb
120
134
  - lib/senec/local/error.rb
@@ -133,7 +147,9 @@ files:
133
147
  - pkg/senec-0.17.1.gem
134
148
  - pkg/senec-0.17.2.gem
135
149
  - pkg/senec-0.18.0.gem
150
+ - pkg/senec-0.19.0.gem
136
151
  - pkg/senec-0.2.0.gem
152
+ - pkg/senec-0.20.0.gem
137
153
  - pkg/senec-0.3.0.gem
138
154
  - pkg/senec-0.4.0.gem
139
155
  - pkg/senec-0.5.0.gem
@@ -1,52 +0,0 @@
1
- require_relative 'connection'
2
- require 'net/http'
3
- require 'json'
4
-
5
- # Model for the Senec statistics overview data.
6
- #
7
- # Example use:
8
- #
9
- # connection = Senec::Cloud::Connection.new(username: '...', password: '...')
10
- #
11
- # # Get the data of a specific system:
12
- # Senec::Cloud::StatsOverview.new(connection:, system_id: 1).data
13
- #
14
- # # Get the data of the default system (system_id 0):
15
- # Senec::Cloud::StatsOverview.new(connection:).data
16
- #
17
- module Senec
18
- module Cloud
19
- class StatsOverview
20
- PATH = '/endkunde/api/status/getstatusoverview.php'.freeze
21
-
22
- def initialize(connection: nil, system_id: 0)
23
- raise ArgumentError unless connection
24
-
25
- @connection = connection
26
- @system_id = system_id
27
- end
28
-
29
- attr_reader :connection, :system_id
30
-
31
- def data
32
- @data ||= fetch_data
33
- end
34
-
35
- private
36
-
37
- def fetch_data
38
- connection.authenticate unless connection.authenticated?
39
-
40
- uri = URI("#{Cloud::BASE_URL}#{PATH}")
41
- uri.query = URI.encode_www_form(anlageNummer: system_id)
42
-
43
- response = connection.simple_request(uri.to_s)
44
- JSON.parse(response.body)
45
- rescue JSON::ParserError
46
- # :nocov:
47
- raise Error, "Failed to parse response from #{PATH}"
48
- # :nocov:
49
- end
50
- end
51
- end
52
- end
@@ -1,52 +0,0 @@
1
- require_relative 'connection'
2
- require 'net/http'
3
- require 'json'
4
-
5
- # Model for the Senec wallboxes data.
6
- #
7
- # Example use:
8
- #
9
- # connection = Senec::Cloud::Connection.new(username: '...', password: '...')
10
- #
11
- # # Get the data of a specific system:
12
- # Senec::Cloud::Wallboxes.new(connection:, system_id: 1).data
13
- #
14
- # # Get the data of the default system (system_id 0):
15
- # Senec::Cloud::Wallboxes.new(connection:).data
16
- #
17
- module Senec
18
- module Cloud
19
- class Wallboxes
20
- PATH = '/endkunde/api/wallboxes'.freeze
21
-
22
- def initialize(connection: nil, system_id: 0)
23
- raise ArgumentError unless connection
24
-
25
- @connection = connection
26
- @system_id = system_id
27
- end
28
-
29
- attr_reader :connection, :system_id
30
-
31
- def data
32
- @data ||= fetch_data
33
- end
34
-
35
- private
36
-
37
- def fetch_data
38
- connection.authenticate unless connection.authenticated?
39
-
40
- uri = URI("#{Cloud::BASE_URL}#{PATH}")
41
- uri.query = URI.encode_www_form(anlageNummer: system_id)
42
-
43
- response = connection.simple_request(uri.to_s)
44
- JSON.parse(response.body)
45
- rescue JSON::ParserError
46
- # :nocov:
47
- raise Error, "Failed to parse response from #{PATH}"
48
- # :nocov:
49
- end
50
- end
51
- end
52
- end