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.
- checksums.yaml +4 -4
- data/.env +5 -1
- data/.env.test +1 -1
- data/.rspec_status +67 -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 +261 -189
- data/coverage/coverage.json +298 -226
- data/coverage/index.html +2361 -1727
- data/lib/senec/.DS_Store +0 -0
- data/lib/senec/cloud/connection.rb +162 -77
- 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
- data/pkg/senec-0.20.0.gem +0 -0
- metadata +19 -3
- data/lib/senec/cloud/stats_overview.rb +0 -52
- data/lib/senec/cloud/wallboxes.rb +0 -52
data/lib/senec/.DS_Store
ADDED
Binary file
|
@@ -1,129 +1,214 @@
|
|
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
|
-
|
54
|
+
!!oauth_token
|
55
|
+
end
|
23
56
|
|
24
|
-
|
57
|
+
def systems
|
58
|
+
get "#{SYSTEMS_HOST}/v1/systems"
|
25
59
|
end
|
26
60
|
|
27
|
-
def
|
28
|
-
|
61
|
+
def system_details(system_id)
|
62
|
+
get "#{SYSTEMS_HOST}/systems/#{system_id}/details"
|
63
|
+
end
|
29
64
|
|
30
|
-
|
31
|
-
|
32
|
-
|
65
|
+
def dashboard(system_id)
|
66
|
+
get "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
|
67
|
+
end
|
33
68
|
|
34
|
-
|
35
|
-
|
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
|
45
|
-
|
73
|
+
def wallbox_search(system_id)
|
74
|
+
post "#{WALLBOX_HOST}/v1/systems/wallboxes/search", { systemIds: [system_id] }
|
46
75
|
end
|
47
76
|
|
48
|
-
|
49
|
-
uri = URI(url)
|
50
|
-
redirect_count = 0
|
51
|
-
response = nil
|
77
|
+
private
|
52
78
|
|
53
|
-
|
54
|
-
response = perform_request(uri.to_s, data)
|
55
|
-
store_cookies(response)
|
79
|
+
attr_accessor :oauth_token
|
56
80
|
|
57
|
-
|
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
|
-
|
60
|
-
|
87
|
+
def extract_form_action_url(html)
|
88
|
+
forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
|
61
89
|
|
62
|
-
|
63
|
-
|
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
|
-
|
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
|
-
|
97
|
+
# :nocov:
|
98
|
+
raise 'Login form not found'
|
99
|
+
# :nocov:
|
70
100
|
end
|
71
101
|
|
72
|
-
|
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
|
-
|
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["'][^>]*/)
|
107
|
+
response.headers['location'] || raise('No redirect location')
|
108
|
+
end
|
79
109
|
|
80
|
-
|
81
|
-
|
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
|
-
|
127
|
+
warn "Token refresh failed: #{e.message}"
|
128
|
+
false
|
85
129
|
# :nocov:
|
86
130
|
end
|
87
131
|
|
88
|
-
def
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
req.
|
104
|
-
req.headers['
|
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
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
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
|
data/lib/senec/version.rb
CHANGED
data/lib/senec.rb
CHANGED
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.
|
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
|