senec 0.20.0 → 0.22.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 +3 -1
- data/.env.test +1 -0
- data/.rspec_status +57 -52
- data/.ruby-lsp/Gemfile.lock +7 -7
- data/.ruby-lsp/main_lockfile_hash +1 -1
- data/coverage/.last_run.json +1 -1
- data/coverage/.resultset.json +199 -68
- data/coverage/coverage.json +200 -69
- data/coverage/index.html +3393 -1975
- data/lib/senec/.DS_Store +0 -0
- data/lib/senec/cloud/connection.rb +98 -23
- data/lib/senec/version.rb +1 -1
- data/pkg/senec-0.20.0.gem +0 -0
- data/pkg/senec-0.21.0.gem +0 -0
- metadata +18 -2
- data/.ruby-lsp/last_updated +0 -1
data/lib/senec/.DS_Store
ADDED
Binary file
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'oauth2'
|
2
|
+
require 'rotp'
|
2
3
|
|
3
4
|
module Senec
|
4
5
|
module Cloud
|
@@ -16,13 +17,14 @@ module Senec
|
|
16
17
|
class Connection
|
17
18
|
DEFAULT_USER_AGENT = "ruby-senec/#{Senec::VERSION} (+https://github.com/solectrus/senec)".freeze
|
18
19
|
|
19
|
-
def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT)
|
20
|
+
def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT, totp_uri: nil)
|
20
21
|
@username = username
|
21
22
|
@password = password
|
22
23
|
@user_agent = user_agent
|
24
|
+
@totp_uri = totp_uri
|
23
25
|
end
|
24
26
|
|
25
|
-
attr_reader :username, :password, :user_agent
|
27
|
+
attr_reader :username, :password, :user_agent, :totp_uri
|
26
28
|
|
27
29
|
def authenticate!
|
28
30
|
code_verifier = SecureRandom.alphanumeric(43)
|
@@ -55,19 +57,23 @@ module Senec
|
|
55
57
|
end
|
56
58
|
|
57
59
|
def systems
|
58
|
-
|
60
|
+
get "#{SYSTEMS_HOST}/v1/systems"
|
59
61
|
end
|
60
62
|
|
61
63
|
def system_details(system_id)
|
62
|
-
|
64
|
+
get "#{SYSTEMS_HOST}/systems/#{system_id}/details"
|
63
65
|
end
|
64
66
|
|
65
67
|
def dashboard(system_id)
|
66
|
-
|
68
|
+
get "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
|
67
69
|
end
|
68
70
|
|
69
71
|
def wallbox(system_id, wallbox_id)
|
70
|
-
|
72
|
+
get "#{WALLBOX_HOST}/v1/systems/#{system_id}/wallboxes/#{wallbox_id}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def wallbox_search(system_id)
|
76
|
+
post "#{WALLBOX_HOST}/v1/systems/wallboxes/search", { systemIds: [system_id] }
|
71
77
|
end
|
72
78
|
|
73
79
|
private
|
@@ -77,30 +83,36 @@ module Senec
|
|
77
83
|
def fetch_login_form_url(auth_url)
|
78
84
|
response = http_request(:get, auth_url)
|
79
85
|
store_cookies(response) # Required for Keycloak CSRF protection
|
80
|
-
|
86
|
+
extract_login_form_action_url(response.body) || raise('Login form not found')
|
81
87
|
end
|
82
88
|
|
83
|
-
def
|
84
|
-
|
85
|
-
|
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)
|
89
|
+
def submit_credentials(form_url)
|
90
|
+
credentials = { username:, password: }
|
91
|
+
response = http_request(:post, form_url, data: credentials)
|
89
92
|
|
90
|
-
|
93
|
+
if response.status == 200
|
94
|
+
# Check if MFA is required
|
95
|
+
if (totp_form_url = extract_totp_form_action_url(response.body))
|
96
|
+
unless totp_uri
|
97
|
+
raise 'MFA required but no TOTP URI provided'
|
98
|
+
end
|
99
|
+
|
100
|
+
response = submit_totp_form(totp_form_url)
|
101
|
+
else
|
102
|
+
# No OTP form found, assume login failed
|
103
|
+
raise 'Login failed - invalid credentials or unexpected response'
|
104
|
+
end
|
91
105
|
end
|
92
106
|
|
93
|
-
#
|
94
|
-
|
95
|
-
# :nocov:
|
107
|
+
# Handle final redirect (same for both MFA and non-MFA flows)
|
108
|
+
handle_redirect_response(response)
|
96
109
|
end
|
97
110
|
|
98
|
-
def
|
99
|
-
|
100
|
-
response = http_request(:post, form_url, data: credentials)
|
101
|
-
raise 'Login failed' unless response.status == 302
|
111
|
+
def submit_totp_form(form_url)
|
112
|
+
totp = build_totp_from_uri(totp_uri)
|
102
113
|
|
103
|
-
|
114
|
+
totp_data = { otp: totp.now }
|
115
|
+
http_request(:post, form_url, data: totp_data)
|
104
116
|
end
|
105
117
|
|
106
118
|
def extract_authorization_code(redirect_url)
|
@@ -112,6 +124,51 @@ module Senec
|
|
112
124
|
params['code'] || raise('No authorization code found')
|
113
125
|
end
|
114
126
|
|
127
|
+
def extract_login_form_action_url(html)
|
128
|
+
find_form_action_url(html) do |form|
|
129
|
+
# Look for a form with username and password fields
|
130
|
+
form.match(/name=["']?username["']?/i) &&
|
131
|
+
form.match(/name=["']?password["']?/i)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def extract_totp_form_action_url(html)
|
136
|
+
find_form_action_url(html) do |form|
|
137
|
+
# Look for a form with an OTP field
|
138
|
+
form.match(/name=["']?otp["']?/i)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def find_form_action_url(html)
|
143
|
+
forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
|
144
|
+
|
145
|
+
forms.each do |action_url, form_content|
|
146
|
+
return CGI.unescapeHTML(action_url) if yield(form_content)
|
147
|
+
end
|
148
|
+
|
149
|
+
nil
|
150
|
+
end
|
151
|
+
|
152
|
+
def build_totp_from_uri(uri_string)
|
153
|
+
params = URI.decode_www_form(URI.parse(uri_string).query).to_h
|
154
|
+
secret = params['secret'] || raise('Missing secret in TOTP URI')
|
155
|
+
|
156
|
+
ROTP::TOTP.new(
|
157
|
+
secret,
|
158
|
+
digits: params['digits']&.to_i,
|
159
|
+
period: params['period']&.to_i,
|
160
|
+
algorithm: params['algorithm'],
|
161
|
+
)
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_redirect_response(response)
|
165
|
+
unless response.status == 302
|
166
|
+
raise "Authentication failed, got unexpected response: #{response.status} #{response.body}"
|
167
|
+
end
|
168
|
+
|
169
|
+
response.headers['location'] || raise('No redirect location')
|
170
|
+
end
|
171
|
+
|
115
172
|
def ensure_token_valid
|
116
173
|
authenticate! unless authenticated?
|
117
174
|
return true unless oauth_token.expired?
|
@@ -125,7 +182,7 @@ module Senec
|
|
125
182
|
# :nocov:
|
126
183
|
end
|
127
184
|
|
128
|
-
def
|
185
|
+
def get(url, default: nil)
|
129
186
|
return default unless ensure_token_valid
|
130
187
|
|
131
188
|
response = oauth_token.get(url)
|
@@ -139,6 +196,24 @@ module Senec
|
|
139
196
|
# :nocov:
|
140
197
|
end
|
141
198
|
|
199
|
+
def post(url, data, default: nil)
|
200
|
+
return default unless ensure_token_valid
|
201
|
+
|
202
|
+
response = oauth_token.post(
|
203
|
+
url,
|
204
|
+
body: data.to_json,
|
205
|
+
headers: { 'Content-Type' => 'application/json' },
|
206
|
+
)
|
207
|
+
return default unless response.status == 200
|
208
|
+
|
209
|
+
JSON.parse(response.body)
|
210
|
+
rescue StandardError => e
|
211
|
+
# :nocov:
|
212
|
+
warn "API error: #{e.message}"
|
213
|
+
default
|
214
|
+
# :nocov:
|
215
|
+
end
|
216
|
+
|
142
217
|
def http_request(method, url, data: nil)
|
143
218
|
Faraday
|
144
219
|
.new
|
data/lib/senec/version.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.22.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Georg Ledermann
|
@@ -65,6 +65,20 @@ dependencies:
|
|
65
65
|
- - ">="
|
66
66
|
- !ruby/object:Gem::Version
|
67
67
|
version: '0'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: rotp
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
type: :runtime
|
76
|
+
prerelease: false
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
68
82
|
description: Access your local SENEC Solar Battery Storage System
|
69
83
|
email:
|
70
84
|
- georg@ledermann.dev
|
@@ -82,7 +96,6 @@ files:
|
|
82
96
|
- ".ruby-lsp/.gitignore"
|
83
97
|
- ".ruby-lsp/Gemfile"
|
84
98
|
- ".ruby-lsp/Gemfile.lock"
|
85
|
-
- ".ruby-lsp/last_updated"
|
86
99
|
- ".ruby-lsp/main_lockfile_hash"
|
87
100
|
- ".ruby-lsp/needs_update"
|
88
101
|
- ".vscode/settings.json"
|
@@ -126,6 +139,7 @@ files:
|
|
126
139
|
- coverage/index.html
|
127
140
|
- lib/.DS_Store
|
128
141
|
- lib/senec.rb
|
142
|
+
- lib/senec/.DS_Store
|
129
143
|
- lib/senec/cloud/connection.rb
|
130
144
|
- lib/senec/cloud/error.rb
|
131
145
|
- lib/senec/local/connection.rb
|
@@ -148,6 +162,8 @@ files:
|
|
148
162
|
- pkg/senec-0.18.0.gem
|
149
163
|
- pkg/senec-0.19.0.gem
|
150
164
|
- pkg/senec-0.2.0.gem
|
165
|
+
- pkg/senec-0.20.0.gem
|
166
|
+
- pkg/senec-0.21.0.gem
|
151
167
|
- pkg/senec-0.3.0.gem
|
152
168
|
- pkg/senec-0.4.0.gem
|
153
169
|
- pkg/senec-0.5.0.gem
|
data/.ruby-lsp/last_updated
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
2025-07-25T14:28:48+02:00
|