senec 0.19.0 → 0.20.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.
@@ -1,129 +1,192 @@
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?
23
-
24
- cookies.key?('sso.senec.com_KEYCLOAK_IDENTITY')
54
+ !!oauth_token
25
55
  end
26
56
 
27
- def authenticate
28
- response = request_with_redirects(Cloud::BASE_URL)
57
+ def systems
58
+ fetch_payload "#{SYSTEMS_HOST}/v1/systems"
59
+ end
29
60
 
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
61
+ def system_details(system_id)
62
+ fetch_payload "#{SYSTEMS_HOST}/systems/#{system_id}/details"
63
+ end
33
64
 
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
- )
65
+ def dashboard(system_id)
66
+ fetch_payload "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
42
67
  end
43
68
 
44
- def simple_request(url)
45
- perform_request(url)
69
+ def wallbox(system_id, wallbox_id)
70
+ fetch_payload "#{WALLBOX_HOST}/v1/systems/#{system_id}/wallboxes/#{wallbox_id}"
46
71
  end
47
72
 
48
- def request_with_redirects(url, data = nil)
49
- uri = URI(url)
50
- redirect_count = 0
51
- response = nil
73
+ private
52
74
 
53
- loop do
54
- response = perform_request(uri.to_s, data)
55
- store_cookies(response)
75
+ attr_accessor :oauth_token
56
76
 
57
- break unless (300..399).cover?(response.status)
77
+ def fetch_login_form_url(auth_url)
78
+ response = http_request(:get, auth_url)
79
+ store_cookies(response) # Required for Keycloak CSRF protection
80
+ extract_form_action_url(response.body)
81
+ end
58
82
 
59
- location = response.headers['location']
60
- break unless location
83
+ def extract_form_action_url(html)
84
+ forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
61
85
 
62
- redirect_count += 1
63
- raise 'Too many redirects!' if redirect_count > MAX_REDIRECTS
86
+ forms.each do |action_url, form_content|
87
+ has_username = form_content.match(/name=["']?username["']?/i)
88
+ has_password = form_content.match(/name=["']?password["']?/i)
64
89
 
65
- uri = location.start_with?('http') ? URI(location) : URI.join(uri, location)
66
- data = nil # Clear data after first request (no POST redirects)
90
+ return CGI.unescapeHTML(action_url) if has_username && has_password
67
91
  end
68
92
 
69
- response
93
+ # :nocov:
94
+ raise 'Login form not found'
95
+ # :nocov:
70
96
  end
71
97
 
72
- private
98
+ def submit_credentials(form_url)
99
+ credentials = { username:, password: }
100
+ response = http_request(:post, form_url, data: credentials)
101
+ raise 'Login failed' unless response.status == 302
73
102
 
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["'][^>]*/)
103
+ response.headers['location'] || raise('No redirect location')
104
+ end
79
105
 
80
- return action if has_username && has_password
81
- end
106
+ def extract_authorization_code(redirect_url)
107
+ raise 'Invalid redirect URL' unless redirect_url&.start_with?(REDIRECT_URI)
108
+
109
+ uri = URI(redirect_url)
110
+ params = URI.decode_www_form(uri.query).to_h
111
+
112
+ params['code'] || raise('No authorization code found')
113
+ end
114
+
115
+ def ensure_token_valid
116
+ authenticate! unless authenticated?
117
+ return true unless oauth_token.expired?
82
118
 
119
+ self.oauth_token = oauth_token.refresh!
120
+ true
121
+ rescue StandardError => e
83
122
  # :nocov:
84
- nil
123
+ warn "Token refresh failed: #{e.message}"
124
+ false
85
125
  # :nocov:
86
126
  end
87
127
 
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
128
+ def fetch_payload(url, default = nil)
129
+ return default unless ensure_token_valid
130
+
131
+ response = oauth_token.get(url)
132
+ return default unless response.status == 200
133
+
134
+ JSON.parse(response.body)
135
+ rescue StandardError => e
136
+ # :nocov:
137
+ warn "API error: #{e.message}"
138
+ default
139
+ # :nocov:
96
140
  end
97
141
 
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'
142
+ def http_request(method, url, data: nil)
143
+ Faraday
144
+ .new
145
+ .send(method, url) do |req|
146
+ req.headers['user-agent'] = user_agent
147
+ req.headers['connection'] = 'keep-alive'
148
+ req.headers['cookie'] = cookie_string if cookies.any?
149
+ req.body = URI.encode_www_form(data) if data
105
150
  end
106
- end
107
151
  end
108
152
 
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?
153
+ def oauth_client
154
+ @oauth_client ||=
155
+ OAuth2::Client.new(
156
+ CLIENT_ID,
157
+ nil,
158
+ site: openid_config['issuer'],
159
+ authorize_url: openid_config['authorization_endpoint'],
160
+ token_url: openid_config['token_endpoint'],
161
+ )
162
+ end
163
+
164
+ def openid_config
165
+ @openid_config ||= JSON.parse(http_request(:get, CONFIG_URL).body)
166
+ rescue StandardError => e
167
+ # :nocov:
168
+ raise "Failed to load OpenID configuration: #{e.message}"
169
+ # :nocov:
170
+ end
171
+
172
+ def cookies
173
+ @cookies ||= {}
174
+ end
175
+
176
+ def cookie_string
177
+ cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
113
178
  end
114
179
 
115
180
  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
181
+ set_cookie = response.headers['set-cookie']
182
+ return unless set_cookie
183
+
184
+ set_cookie
185
+ .split(', ')
186
+ .each do |cookie_header|
187
+ name, value = cookie_header.split(';').first.split('=', 2)
188
+ cookies[name] = value if name && value
125
189
  end
126
- end
127
190
  end
128
191
  end
129
192
  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.20.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
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.20.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"
@@ -113,8 +128,6 @@ files:
113
128
  - lib/senec.rb
114
129
  - lib/senec/cloud/connection.rb
115
130
  - lib/senec/cloud/error.rb
116
- - lib/senec/cloud/stats_overview.rb
117
- - lib/senec/cloud/wallboxes.rb
118
131
  - lib/senec/local/connection.rb
119
132
  - lib/senec/local/constants.rb
120
133
  - lib/senec/local/error.rb
@@ -133,6 +146,7 @@ files:
133
146
  - pkg/senec-0.17.1.gem
134
147
  - pkg/senec-0.17.2.gem
135
148
  - pkg/senec-0.18.0.gem
149
+ - pkg/senec-0.19.0.gem
136
150
  - pkg/senec-0.2.0.gem
137
151
  - pkg/senec-0.3.0.gem
138
152
  - pkg/senec-0.4.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