Skip to content

Commit 12a8bcc

Browse files
committed
Use AppAPI again, but with oauth2
Belongs to solectrus/senec-collector#639
1 parent 34191d2 commit 12a8bcc

26 files changed

Lines changed: 2484 additions & 4185 deletions

.env.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
SENEC_USERNAME=mail@example.com
22
SENEC_PASSWORD=topsecret
3-
SENEC_SYSTEM_ID=0
3+
SENEC_SYSTEM_ID=999999
44

55
SENEC_HOST=senec
66
SENEC_SCHEMA=https

README.md

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# Unofficial Ruby Client for SENEC Home
77

8-
Access your local SENEC Solar Battery Storage System or the SENEC Cloud (mein-senec.de) from Ruby.
8+
Access your local SENEC Solar Battery Storage System or the SENEC Cloud (App API) from Ruby.
99

1010
**WARNING:** I'm not affiliated in any way with the SENEC company.
1111

@@ -28,59 +28,16 @@ $ gem install senec
2828
````ruby
2929
require 'senec'
3030

31-
# Login to the SENEC cloud (mein-senec.de):
31+
# Build connection with your credentials
3232
connection = Senec::Cloud::Connection.new(username: 'me@example.com', password: 'my-secret-senec-password')
3333

34-
# Get the Dashboard data of first systems (without knowing the ID):
35-
puts Senec::Cloud::Dashboard[connection].first.data
36-
37-
# => {
38-
# 'wartungsplan' => {
39-
# 'possibleMaintenanceTypes' => [],
40-
# 'applicable' => false,
41-
# 'maintenanceDueSoon' => false,
42-
# 'maintenanceOverdue' => false,
43-
# 'minorMaintenancePossible' => false
44-
# },
45-
# 'suppressedNotificationIds' => [],
46-
# 'steuereinheitState' => 'AKKU_VOLL',
47-
# 'wartungNotwendig' => false,
48-
# 'firmwareVersion' => 826,
49-
# 'gridimport' => {
50-
# 'today' => 0.0302734375,
51-
# 'now' => 0.0
52-
# },
53-
# 'powergenerated' => {
54-
# 'today' => 30.94140625,
55-
# 'now' => 2.382683
56-
# },
57-
# 'consumption' => {
58-
# 'today' => 5.501953125,
59-
# 'now' => 0.327035
60-
# },
61-
# 'gridexport' => {
62-
# 'today' => 24.779296875,
63-
# 'now' => 2.032288
64-
# },
65-
# 'accuexport' => {
66-
# 'today' => 2.55419921875,
67-
# 'now' => 0.0
68-
# },
69-
# 'accuimport' => {
70-
# 'today' => 1.84619140625,
71-
# 'now' => 0.01752
72-
# },
73-
# 'acculevel' => {
74-
# 'today' => 89.47079467773438,
75-
# 'now' => 100.0
76-
# },
77-
# 'mcuOperationalModeId' => 2,
78-
# 'senecBatteryStorageGeneration' => 'V3',
79-
# 'machine' => 'MCU',
80-
# 'lastupdated' => 1_753_277_119,
81-
# 'state' => 13
82-
# }
34+
# List available systems (with their IDs)
35+
puts connection.systems
36+
# => {"id" => 999999, "controlUnitNumber" => "1234567890", ...
8337

38+
# Get the current dashboard data for a specific system
39+
puts connection.dashboard(999999)
40+
# => {"currently" => {"powerGenerationInW" => 8539.603515625, "powerConsumptionInW" => 765.77, "gridFeedInInW" => 6451.11376953125, "gridDrawInW" => 0.0, "batteryChargeInW" => 1316.9090576171875, "batteryDischargeInW" => 0.0, "batteryLevelInPercent" => 88.88888549804688, "selfSufficiencyInPercent" => 100.0, "wallboxInW" => 0.0}, "today" => {"powerGenerationInWh" => 19687.5, "powerConsumptionInWh" => 4960.93, "gridFeedInInWh" => 15310.546875, "gridDrawInWh" => 28.3203125, "batteryChargeInWh" => 1175.78125, "batteryDischargeInWh" => 1732.421875, "batteryLevelInPercent" => 88.88888549804688, "selfSufficiencyInPercent" => 99.43, "wallboxInWh" => 0.0}, "timestamp" => "2025-07-26T10:55:07Z", "electricVehicleConnected" => false, "numberOfWallboxes" => 0, "systemId" => 999999, "systemType" => "V123", "storageDeviceState" => "CHARGING"}
8441

8542
### Local access (V2.1 and V3 only)
8643

lib/senec.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,5 @@
44
require 'senec/local/request'
55
require 'senec/local/error'
66

7-
require 'senec/cloud/stats_overview'
8-
require 'senec/cloud/wallboxes'
7+
require 'senec/cloud/connection'
98
require 'senec/cloud/error'

lib/senec/cloud/connection.rb

Lines changed: 141 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,192 @@
1-
require 'faraday'
2-
require 'json'
1+
require 'oauth2'
32

43
module Senec
54
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
715

816
class Connection
917
DEFAULT_USER_AGENT = "ruby-senec/#{Senec::VERSION} (+https://github.com/solectrus/senec)".freeze
10-
MAX_REDIRECTS = 10
1118

1219
def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT)
1320
@username = username
1421
@password = password
1522
@user_agent = user_agent
16-
@cookies = {}
1723
end
1824

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
2052

2153
def authenticated?
22-
authenticate if cookies.empty?
23-
24-
cookies.key?('sso.senec.com_KEYCLOAK_IDENTITY')
54+
!!oauth_token
2555
end
2656

27-
def authenticate
28-
response = request_with_redirects(Cloud::BASE_URL)
57+
def systems
58+
fetch_payload "#{SYSTEMS_HOST}/v1/systems"
59+
end
2960

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
3364

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"
4267
end
4368

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}"
4671
end
4772

48-
def request_with_redirects(url, data = nil)
49-
uri = URI(url)
50-
redirect_count = 0
51-
response = nil
73+
private
5274

53-
loop do
54-
response = perform_request(uri.to_s, data)
55-
store_cookies(response)
75+
attr_accessor :oauth_token
5676

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
5882

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)
6185

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)
6489

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
6791
end
6892

69-
response
93+
# :nocov:
94+
raise 'Login form not found'
95+
# :nocov:
7096
end
7197

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
73102

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
79105

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?
82118

119+
self.oauth_token = oauth_token.refresh!
120+
true
121+
rescue StandardError => e
83122
# :nocov:
84-
nil
123+
warn "Token refresh failed: #{e.message}"
124+
false
85125
# :nocov:
86126
end
87127

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:
96140
end
97141

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
105150
end
106-
end
107151
end
108152

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('; ')
113178
end
114179

115180
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
125189
end
126-
end
127190
end
128191
end
129192
end

0 commit comments

Comments
 (0)