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.
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
- fetch_payload "#{SYSTEMS_HOST}/v1/systems"
60
+ get "#{SYSTEMS_HOST}/v1/systems"
59
61
  end
60
62
 
61
63
  def system_details(system_id)
62
- fetch_payload "#{SYSTEMS_HOST}/systems/#{system_id}/details"
64
+ get "#{SYSTEMS_HOST}/systems/#{system_id}/details"
63
65
  end
64
66
 
65
67
  def dashboard(system_id)
66
- fetch_payload "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
68
+ get "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard"
67
69
  end
68
70
 
69
71
  def wallbox(system_id, wallbox_id)
70
- fetch_payload "#{WALLBOX_HOST}/v1/systems/#{system_id}/wallboxes/#{wallbox_id}"
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
- extract_form_action_url(response.body)
86
+ extract_login_form_action_url(response.body) || raise('Login form not found')
81
87
  end
82
88
 
83
- def extract_form_action_url(html)
84
- forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
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
- return CGI.unescapeHTML(action_url) if has_username && has_password
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
- # :nocov:
94
- raise 'Login form not found'
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 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
111
+ def submit_totp_form(form_url)
112
+ totp = build_totp_from_uri(totp_uri)
102
113
 
103
- response.headers['location'] || raise('No redirect location')
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 fetch_payload(url, default = nil)
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
@@ -1,3 +1,3 @@
1
1
  module Senec
2
- VERSION = '0.20.0'.freeze
2
+ VERSION = '0.22.0'.freeze
3
3
  end
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.20.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
@@ -1 +0,0 @@
1
- 2025-07-25T14:28:48+02:00