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.
- checksums.yaml +4 -4
- data/.env +4 -1
- data/.env.test +1 -1
- data/.rspec_status +64 -69
- data/.ruby-lsp/Gemfile.lock +27 -6
- data/.ruby-lsp/last_updated +1 -0
- data/.ruby-lsp/main_lockfile_hash +1 -1
- data/README.md +8 -51
- data/coverage/.last_run.json +1 -1
- data/coverage/.resultset.json +211 -202
- data/coverage/coverage.json +249 -240
- data/coverage/index.html +3594 -3642
- data/lib/senec/cloud/connection.rb +141 -78
- data/lib/senec/local/connection.rb +1 -0
- data/lib/senec/version.rb +1 -1
- data/lib/senec.rb +1 -2
- data/pkg/senec-0.19.0.gem +0 -0
- metadata +17 -3
- data/lib/senec/cloud/stats_overview.rb +0 -52
- data/lib/senec/cloud/wallboxes.rb +0 -52
@@ -1,129 +1,192 @@
|
|
1
|
-
require '
|
2
|
-
require 'json'
|
1
|
+
require 'oauth2'
|
3
2
|
|
4
3
|
module Senec
|
5
4
|
module Cloud
|
6
|
-
|
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
|
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
|
-
|
23
|
-
|
24
|
-
cookies.key?('sso.senec.com_KEYCLOAK_IDENTITY')
|
54
|
+
!!oauth_token
|
25
55
|
end
|
26
56
|
|
27
|
-
def
|
28
|
-
|
57
|
+
def systems
|
58
|
+
fetch_payload "#{SYSTEMS_HOST}/v1/systems"
|
59
|
+
end
|
29
60
|
|
30
|
-
|
31
|
-
|
32
|
-
|
61
|
+
def system_details(system_id)
|
62
|
+
fetch_payload "#{SYSTEMS_HOST}/systems/#{system_id}/details"
|
63
|
+
end
|
33
64
|
|
34
|
-
|
35
|
-
|
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
|
45
|
-
|
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
|
-
|
49
|
-
uri = URI(url)
|
50
|
-
redirect_count = 0
|
51
|
-
response = nil
|
73
|
+
private
|
52
74
|
|
53
|
-
|
54
|
-
response = perform_request(uri.to_s, data)
|
55
|
-
store_cookies(response)
|
75
|
+
attr_accessor :oauth_token
|
56
76
|
|
57
|
-
|
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
|
-
|
60
|
-
|
83
|
+
def extract_form_action_url(html)
|
84
|
+
forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
|
61
85
|
|
62
|
-
|
63
|
-
|
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
|
-
|
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
|
-
|
93
|
+
# :nocov:
|
94
|
+
raise 'Login form not found'
|
95
|
+
# :nocov:
|
70
96
|
end
|
71
97
|
|
72
|
-
|
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
|
-
|
75
|
-
|
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
|
-
|
81
|
-
|
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
|
-
|
123
|
+
warn "Token refresh failed: #{e.message}"
|
124
|
+
false
|
85
125
|
# :nocov:
|
86
126
|
end
|
87
127
|
|
88
|
-
def
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
req.
|
104
|
-
req.headers['
|
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
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
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
|
data/lib/senec/version.rb
CHANGED
data/lib/senec.rb
CHANGED
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.
|
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
|