senec 0.13.0 → 0.14.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.test +3 -0
- data/.github/workflows/push.yml +11 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +13 -0
- data/.vscode/settings.json +1 -1
- data/Gemfile +13 -1
- data/README.md +56 -3
- data/lib/senec/cloud/connection.rb +72 -0
- data/lib/senec/cloud/dashboard.rb +69 -0
- data/lib/senec/cloud/error.rb +12 -0
- data/lib/senec/local/connection.rb +34 -0
- data/lib/senec/local/constants.rb +58 -0
- data/lib/senec/local/error.rb +6 -0
- data/lib/senec/local/request.rb +138 -0
- data/lib/senec/local/state.rb +53 -0
- data/lib/senec/local/value.rb +43 -0
- data/lib/senec/version.rb +1 -1
- data/lib/senec.rb +6 -7
- metadata +13 -8
- data/lib/senec/connection.rb +0 -30
- data/lib/senec/constants.rb +0 -34
- data/lib/senec/request.rb +0 -130
- data/lib/senec/state.rb +0 -51
- data/lib/senec/value.rb +0 -45
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: adfa786a844f0a43a8e3a87763249da55e2e6165e80600ada958fe7aed674d32
|
4
|
+
data.tar.gz: ff9d52cc6e3fd5be19d49d474ee145526cbe0930438859b05423c92163b95274
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c7b507db12c1b6712da5ba93b2780ac34b31c0ff5c108714d0b540f1e43a270ee30f4f4de234c5948e29a1b2d28f872aed1590f87945b6ac56e7e32b80ee7d43
|
7
|
+
data.tar.gz: c2739c6d4ef96c3d359e1d0a1398a5ce07363517db15c6032fb339aadbe802758751149af0f4d4d1b58e3d9a649367ed82667905a68259b7bce0ae3fc373f29a
|
data/.env.test
ADDED
data/.github/workflows/push.yml
CHANGED
@@ -11,9 +11,12 @@ jobs:
|
|
11
11
|
matrix:
|
12
12
|
ruby: ['3.2']
|
13
13
|
|
14
|
+
env:
|
15
|
+
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
|
16
|
+
|
14
17
|
steps:
|
15
18
|
- name: Checkout the code
|
16
|
-
uses: actions/checkout@
|
19
|
+
uses: actions/checkout@v4
|
17
20
|
|
18
21
|
- name: Set up Ruby
|
19
22
|
uses: ruby/setup-ruby@v1
|
@@ -25,4 +28,10 @@ jobs:
|
|
25
28
|
run: bundle exec rubocop
|
26
29
|
|
27
30
|
- name: Run tests
|
28
|
-
run: bundle exec
|
31
|
+
run: bundle exec dotenv -f ".env.test" rspec
|
32
|
+
|
33
|
+
- name: Send test coverage to CodeClimate
|
34
|
+
uses: paambaati/codeclimate-action@v5.0.0
|
35
|
+
if: ${{ env.CC_TEST_REPORTER_ID }}
|
36
|
+
with:
|
37
|
+
coverageCommand: true
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
@@ -2,6 +2,7 @@ require:
|
|
2
2
|
- rubocop-performance
|
3
3
|
- rubocop-rspec
|
4
4
|
- rubocop-rake
|
5
|
+
- rubocop-thread_safety
|
5
6
|
|
6
7
|
AllCops:
|
7
8
|
TargetRubyVersion: 3.2
|
@@ -13,8 +14,20 @@ Style/Documentation:
|
|
13
14
|
Style/FrozenStringLiteralComment:
|
14
15
|
Enabled: false
|
15
16
|
|
17
|
+
Style/TrailingCommaInArguments:
|
18
|
+
EnforcedStyleForMultiline: consistent_comma
|
19
|
+
|
20
|
+
Style/IfUnlessModifier:
|
21
|
+
Enabled: false
|
22
|
+
|
16
23
|
Metrics/MethodLength:
|
17
24
|
Max: 15
|
18
25
|
|
19
26
|
Layout/LineLength:
|
20
27
|
AllowedPatterns: ['\A#'] # Allow long comments
|
28
|
+
|
29
|
+
RSpec/ExampleLength:
|
30
|
+
Max: 15
|
31
|
+
|
32
|
+
RSpec/NestedGroups:
|
33
|
+
Max: 4
|
data/.vscode/settings.json
CHANGED
data/Gemfile
CHANGED
@@ -21,5 +21,17 @@ gem 'rubocop-rspec'
|
|
21
21
|
# A RuboCop plugin for Rake (https://github.com/rubocop/rubocop-rake)
|
22
22
|
gem 'rubocop-rake'
|
23
23
|
|
24
|
-
#
|
24
|
+
# Thread-safety checks via static analysis (https://github.com/rubocop/rubocop-thread_safety)
|
25
|
+
gem 'rubocop-thread_safety', require: false
|
26
|
+
|
27
|
+
# Loads environment variables from `.env`. (https://github.com/bkeepers/dotenv)
|
28
|
+
gem 'dotenv'
|
29
|
+
|
30
|
+
# Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests. (https://benoittgt.github.io/vcr)
|
25
31
|
gem 'vcr'
|
32
|
+
|
33
|
+
# Code coverage for Ruby (https://github.com/simplecov-ruby/simplecov)
|
34
|
+
gem 'simplecov'
|
35
|
+
|
36
|
+
# Library for stubbing HTTP requests in Ruby. (https://github.com/bblimke/webmock)
|
37
|
+
gem 'webmock'
|
data/README.md
CHANGED
@@ -1,9 +1,11 @@
|
|
1
1
|
[](https://github.com/solectrus/senec/actions/workflows/push.yml)
|
2
2
|
[](https://wakatime.com/badge/user/697af4f5-617a-446d-ba58-407e7f3e0243/project/84ac7dc2-9288-497c-bb20-9c6123d3de66)
|
3
|
+
[](https://codeclimate.com/github/solectrus/senec/maintainability)
|
4
|
+
[](https://codeclimate.com/github/solectrus/senec/test_coverage)
|
3
5
|
|
4
6
|
# Unofficial Ruby Client for SENEC Home
|
5
7
|
|
6
|
-
Access your local SENEC Solar Battery Storage System
|
8
|
+
Access your local SENEC Solar Battery Storage System or the SENEC Cloud from Ruby.
|
7
9
|
|
8
10
|
**WARNING:** I'm not affiliated in any way with the SENEC company.
|
9
11
|
|
@@ -11,6 +13,7 @@ Inspired by:
|
|
11
13
|
|
12
14
|
- https://github.com/mchwalisz/pysenec
|
13
15
|
- https://gist.github.com/smashnet/82ad0b9d7f0ba2e5098e6649ba08f88a
|
16
|
+
- https://documenter.getpostman.com/view/932140/2s9YXib2td
|
14
17
|
|
15
18
|
## Installation
|
16
19
|
|
@@ -20,11 +23,61 @@ $ gem install senec
|
|
20
23
|
|
21
24
|
## Usage
|
22
25
|
|
26
|
+
### Cloud access (V2.1, V3 and V4)
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
require 'senec'
|
30
|
+
|
31
|
+
# Login to the SENEC cloud
|
32
|
+
connection = Senec::Cloud::Connection.new(username: 'me@example.com', password: 'my-secret-senec-password')
|
33
|
+
|
34
|
+
# List all available systems
|
35
|
+
puts connection.systems
|
36
|
+
|
37
|
+
# => [{"id"=>"123456", "steuereinheitnummer"=>"S123XXX", "gehaeusenummer"=>"DE-V3-XXXX", "strasse"=>"Musterstraße", "hausnummer"=>"27a", "postleitzahl"=>"99999", "ort"=>"Musterort", "laendercode"=>"DE", "zeitzone"=>"Europe/Berlin", "wallboxIds"=>["1"], "systemType"=>"V3"}]
|
38
|
+
|
39
|
+
# Get the data of first systems (without knowing the ID):
|
40
|
+
puts Senec::Cloud::Dashboard[connection].first.data
|
41
|
+
|
42
|
+
# => {"aktuell"=>
|
43
|
+
# {"stromerzeugung"=>{"wert"=>0.01, "einheit"=>"W"},
|
44
|
+
# "stromverbrauch"=>{"wert"=>860.0, "einheit"=>"W"},
|
45
|
+
# "netzeinspeisung"=>{"wert"=>0.01, "einheit"=>"W"},
|
46
|
+
# "netzbezug"=>{"wert"=>852.6270000000001, "einheit"=>"W"},
|
47
|
+
# "speicherbeladung"=>{"wert"=>0.01, "einheit"=>"W"},
|
48
|
+
# "speicherentnahme"=>{"wert"=>11.68, "einheit"=>"W"},
|
49
|
+
# "speicherfuellstand"=>{"wert"=>1.0e-05, "einheit"=>"%"},
|
50
|
+
# "autarkie"=>{"wert"=>1.35, "einheit"=>"%"},
|
51
|
+
# "wallbox"=>{"wert"=>0.01, "einheit"=>"W"}},
|
52
|
+
# "heute"=>
|
53
|
+
# {"stromerzeugung"=>{"wert"=>3339.84375, "einheit"=>"Wh"},
|
54
|
+
# "stromverbrauch"=>{"wert"=>21000.0, "einheit"=>"Wh"},
|
55
|
+
# "netzeinspeisung"=>{"wert"=>13.671875, "einheit"=>"Wh"},
|
56
|
+
# "netzbezug"=>{"wert"=>17546.38671875, "einheit"=>"Wh"},
|
57
|
+
# "speicherbeladung"=>{"wert"=>119.140625, "einheit"=>"Wh"},
|
58
|
+
# "speicherentnahme"=>{"wert"=>254.39453125, "einheit"=>"Wh"},
|
59
|
+
# "speicherfuellstand"=>{"wert"=>0.0, "einheit"=>"%"},
|
60
|
+
# "autarkie"=>{"wert"=>16.47, "einheit"=>"%"},
|
61
|
+
# "wallbox"=>{"wert"=>0.0, "einheit"=>"Wh"}},
|
62
|
+
# "zeitstempel"=>"2023-11-26T18:45:23Z",
|
63
|
+
# "electricVehicleConnected"=>false}
|
64
|
+
|
65
|
+
|
66
|
+
# Get the data of a specific system (by ID):
|
67
|
+
puts Senc::Cloud::Dashboard[connection].find("123456").data
|
68
|
+
|
69
|
+
# => {"aktuell"=>
|
70
|
+
# {"stromerzeugung"=>{"wert"=>0.01, "einheit"=>"W"},
|
71
|
+
# ....
|
72
|
+
```
|
73
|
+
|
74
|
+
### Local access (V2.1 and V3 only)
|
75
|
+
|
23
76
|
```ruby
|
24
77
|
require 'senec'
|
25
78
|
|
26
|
-
connection = Senec::Connection.new(host: '192.168.178.123', schema: 'https')
|
27
|
-
request = Senec::Request.new(connection:)
|
79
|
+
connection = Senec::Local::Connection.new(host: '192.168.178.123', schema: 'https')
|
80
|
+
request = Senec::Local::Request.new(connection:)
|
28
81
|
|
29
82
|
puts "PV production: #{request.inverter_power} W"
|
30
83
|
puts "House power consumption: #{request.house_power} W"
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module Senec
|
2
|
+
module Cloud
|
3
|
+
class Connection
|
4
|
+
def initialize(username:, password:)
|
5
|
+
@username = username
|
6
|
+
@password = password
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :username, :password
|
10
|
+
|
11
|
+
def authenticated?
|
12
|
+
!token.nil?
|
13
|
+
end
|
14
|
+
|
15
|
+
def systems
|
16
|
+
get('/v1/senec/systems')
|
17
|
+
end
|
18
|
+
|
19
|
+
def default_system_id
|
20
|
+
raise Error.new('No systems found!', :not_found) if systems.nil?
|
21
|
+
|
22
|
+
systems[0]['id']
|
23
|
+
end
|
24
|
+
|
25
|
+
def get(path, params: nil)
|
26
|
+
return_body do
|
27
|
+
connection.get(path, params, { authorization: token })
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def post(path, data)
|
32
|
+
return_body do
|
33
|
+
connection.post(path, data)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def token
|
40
|
+
@token ||= login['token']
|
41
|
+
end
|
42
|
+
|
43
|
+
def login
|
44
|
+
post('/v1/senec/login', { username:, password: })
|
45
|
+
end
|
46
|
+
|
47
|
+
def connection
|
48
|
+
@connection ||=
|
49
|
+
Faraday.new(url: 'https://app-gateway.prod.senec.dev') do |f|
|
50
|
+
f.request :json
|
51
|
+
f.response :json
|
52
|
+
|
53
|
+
f.adapter :net_http_persistent, pool_size: 5 do |http|
|
54
|
+
# :nocov:
|
55
|
+
http.idle_timeout = 120
|
56
|
+
# :nocov:
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def return_body(&)
|
62
|
+
response = yield
|
63
|
+
|
64
|
+
unless response.success?
|
65
|
+
raise Error.new("Error #{response.status}", response.status)
|
66
|
+
end
|
67
|
+
|
68
|
+
response.body
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative 'connection'
|
2
|
+
|
3
|
+
# Model for the Senec dashboard data.
|
4
|
+
#
|
5
|
+
# Example use:
|
6
|
+
#
|
7
|
+
# connection = Senec::Cloud::Connection.new(username: '...', password: '...')
|
8
|
+
#
|
9
|
+
# # Get the data of a specific system:
|
10
|
+
# Dashboard[connection].find('123456')
|
11
|
+
#
|
12
|
+
# # Get the data of the default system:
|
13
|
+
# Dashboard[connection].first
|
14
|
+
#
|
15
|
+
module Senec
|
16
|
+
module Cloud
|
17
|
+
class Dashboard
|
18
|
+
class Finder
|
19
|
+
def initialize(connection)
|
20
|
+
@connection = connection
|
21
|
+
end
|
22
|
+
attr_reader :connection
|
23
|
+
|
24
|
+
def find(system_id)
|
25
|
+
Dashboard.new(connection:, system_id:).tap(&:load_data)
|
26
|
+
end
|
27
|
+
|
28
|
+
def first
|
29
|
+
find(connection.default_system_id)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.[](connection)
|
34
|
+
Finder.new(connection)
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize(connection: nil, system_id: nil, data: nil)
|
38
|
+
raise ArgumentError unless connection.nil? ^ data.nil?
|
39
|
+
|
40
|
+
@connection = connection
|
41
|
+
@system_id = system_id
|
42
|
+
|
43
|
+
# Useful for testing only
|
44
|
+
@data = data
|
45
|
+
end
|
46
|
+
|
47
|
+
def load_data
|
48
|
+
raise 'Data already present!' if @data
|
49
|
+
|
50
|
+
@system_id ||= connection.default_system_id
|
51
|
+
@data = fetch_data
|
52
|
+
end
|
53
|
+
|
54
|
+
attr_reader :system_id, :data
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def get(path, params: nil)
|
59
|
+
@connection.get(path, params:)
|
60
|
+
end
|
61
|
+
|
62
|
+
def fetch_data
|
63
|
+
return unless system_id
|
64
|
+
|
65
|
+
get("/v1/senec/systems/#{system_id}/dashboard")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'faraday/net_http_persistent'
|
3
|
+
require 'faraday-request-timer'
|
4
|
+
require 'forwardable'
|
5
|
+
|
6
|
+
module Senec
|
7
|
+
module Local
|
8
|
+
class Connection
|
9
|
+
def initialize(host:, schema: 'http')
|
10
|
+
@url = "#{schema}://#{host}"
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :url
|
14
|
+
|
15
|
+
extend Forwardable
|
16
|
+
def_delegators :faraday, :get, :post
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def faraday
|
21
|
+
@faraday ||= Faraday.new @url,
|
22
|
+
ssl: { verify: false },
|
23
|
+
headers: {
|
24
|
+
'Connection' => 'keep-alive'
|
25
|
+
} do |f|
|
26
|
+
f.request :timer
|
27
|
+
f.adapter :net_http_persistent, pool_size: 5 do |http|
|
28
|
+
http.idle_timeout = 30
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module Senec
|
2
|
+
module Local
|
3
|
+
# For a full list of available vars, see https://[IP-of-your-SENEC]//Vars.html
|
4
|
+
# Comments taken from https://gist.github.com/smashnet/82ad0b9d7f0ba2e5098e6649ba08f88a
|
5
|
+
BASIC_REQUEST = {
|
6
|
+
ENERGY: {
|
7
|
+
STAT_STATE: '', # Current state of the system (int, see SYSTEM_STATE_NAME)
|
8
|
+
GUI_BAT_DATA_CURRENT: '', # Battery charge current: negative if discharging, positiv if charging (A)
|
9
|
+
GUI_BAT_DATA_FUEL_CHARGE: '', # Remaining battery (percent)
|
10
|
+
GUI_BAT_DATA_POWER: '', # Battery charge power: negative if discharging, positiv if charging (W)
|
11
|
+
GUI_BAT_DATA_VOLTAGE: '', # Battery voltage (V)
|
12
|
+
GUI_GRID_POW: '', # Grid power: negative if exporting, positiv if importing (W)
|
13
|
+
GUI_HOUSE_POW: '', # House power consumption (W)
|
14
|
+
GUI_INVERTER_POWER: '', # PV production (W)
|
15
|
+
STAT_HOURS_OF_OPERATION: '' # Appliance hours of operation
|
16
|
+
},
|
17
|
+
WIZARD: {
|
18
|
+
APPLICATION_VERSION: ''
|
19
|
+
},
|
20
|
+
RTC: {
|
21
|
+
UTC_OFFSET: '',
|
22
|
+
WEB_TIME: ''
|
23
|
+
},
|
24
|
+
PV1: {
|
25
|
+
MPP_POWER: '', # List: MPP power (W)
|
26
|
+
POWER_RATIO: '' # Grid export limit (percent)
|
27
|
+
},
|
28
|
+
TEMPMEASURE: {
|
29
|
+
CASE_TEMP: ''
|
30
|
+
},
|
31
|
+
WALLBOX: {
|
32
|
+
APPARENT_CHARGING_POWER: ''
|
33
|
+
}
|
34
|
+
}.freeze
|
35
|
+
|
36
|
+
SAFETY_CHARGE = {
|
37
|
+
ENERGY: {
|
38
|
+
SAFE_CHARGE_FORCE: 'u8_01',
|
39
|
+
SAFE_CHARGE_PROHIBIT: '',
|
40
|
+
SAFE_CHARGE_RUNNING: '',
|
41
|
+
LI_STORAGE_MODE_START: '',
|
42
|
+
LI_STORAGE_MODE_STOP: '',
|
43
|
+
LI_STORAGE_MODE_RUNNING: ''
|
44
|
+
}
|
45
|
+
}.freeze
|
46
|
+
|
47
|
+
ALLOW_DISCHARGE = {
|
48
|
+
ENERGY: {
|
49
|
+
SAFE_CHARGE_FORCE: '',
|
50
|
+
SAFE_CHARGE_PROHIBIT: 'u8_01',
|
51
|
+
SAFE_CHARGE_RUNNING: '',
|
52
|
+
LI_STORAGE_MODE_START: '',
|
53
|
+
LI_STORAGE_MODE_STOP: '',
|
54
|
+
LI_STORAGE_MODE_RUNNING: ''
|
55
|
+
}
|
56
|
+
}.freeze
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require_relative 'value'
|
2
|
+
require_relative 'constants'
|
3
|
+
|
4
|
+
module Senec
|
5
|
+
module Local
|
6
|
+
class Request
|
7
|
+
def initialize(connection:, body: BASIC_REQUEST, state_names: nil)
|
8
|
+
@connection = connection
|
9
|
+
@body = body
|
10
|
+
@state_names = state_names
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :connection, :body, :state_names
|
14
|
+
|
15
|
+
def perform!
|
16
|
+
parsed_response
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
def house_power
|
21
|
+
get('ENERGY', 'GUI_HOUSE_POW')
|
22
|
+
end
|
23
|
+
|
24
|
+
def inverter_power
|
25
|
+
get('ENERGY', 'GUI_INVERTER_POWER')
|
26
|
+
end
|
27
|
+
|
28
|
+
def mpp_power
|
29
|
+
get('PV1', 'MPP_POWER')
|
30
|
+
end
|
31
|
+
|
32
|
+
def power_ratio
|
33
|
+
get('PV1', 'POWER_RATIO')
|
34
|
+
end
|
35
|
+
|
36
|
+
def bat_power
|
37
|
+
get('ENERGY', 'GUI_BAT_DATA_POWER')
|
38
|
+
end
|
39
|
+
|
40
|
+
def bat_fuel_charge
|
41
|
+
get('ENERGY', 'GUI_BAT_DATA_FUEL_CHARGE')
|
42
|
+
end
|
43
|
+
|
44
|
+
def bat_charge_current
|
45
|
+
get('ENERGY', 'GUI_BAT_DATA_CURRENT')
|
46
|
+
end
|
47
|
+
|
48
|
+
def bat_voltage
|
49
|
+
get('ENERGY', 'GUI_BAT_DATA_VOLTAGE')
|
50
|
+
end
|
51
|
+
|
52
|
+
def grid_power
|
53
|
+
get('ENERGY', 'GUI_GRID_POW')
|
54
|
+
end
|
55
|
+
|
56
|
+
def wallbox_charge_power
|
57
|
+
get('WALLBOX', 'APPARENT_CHARGING_POWER')
|
58
|
+
end
|
59
|
+
|
60
|
+
def case_temp
|
61
|
+
get('TEMPMEASURE', 'CASE_TEMP')
|
62
|
+
end
|
63
|
+
|
64
|
+
def application_version
|
65
|
+
get('WIZARD', 'APPLICATION_VERSION')
|
66
|
+
end
|
67
|
+
|
68
|
+
def current_state_code
|
69
|
+
get('ENERGY', 'STAT_STATE')
|
70
|
+
end
|
71
|
+
|
72
|
+
def current_state_name
|
73
|
+
throw RuntimeError, 'No state names provided!' unless state_names
|
74
|
+
|
75
|
+
state_names[current_state_code]
|
76
|
+
end
|
77
|
+
|
78
|
+
def measure_time
|
79
|
+
web_time = get('RTC', 'WEB_TIME')
|
80
|
+
utc_offset = get('RTC', 'UTC_OFFSET')
|
81
|
+
|
82
|
+
web_time - (utc_offset * 60)
|
83
|
+
end
|
84
|
+
|
85
|
+
def response_duration
|
86
|
+
raw_response.env[:duration]
|
87
|
+
end
|
88
|
+
|
89
|
+
def get(*keys)
|
90
|
+
return unless parsed_response
|
91
|
+
|
92
|
+
value = parsed_response.dig(*keys)
|
93
|
+
|
94
|
+
if value.is_a?(Array)
|
95
|
+
value.map do |v|
|
96
|
+
Value.new(v).decoded
|
97
|
+
end
|
98
|
+
elsif value
|
99
|
+
Value.new(value).decoded
|
100
|
+
else
|
101
|
+
raise Error, "Value missing for #{keys.join('.')}"
|
102
|
+
end
|
103
|
+
rescue Senec::Local::DecodingError => e
|
104
|
+
raise Error, "Decoding failed for #{keys.join('.')}: #{e.message}"
|
105
|
+
end
|
106
|
+
|
107
|
+
def path
|
108
|
+
'/lala.cgi'
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def parsed_response
|
114
|
+
@parsed_response ||= JSON.parse(raw_response.body)
|
115
|
+
end
|
116
|
+
|
117
|
+
def raw_response
|
118
|
+
@raw_response ||= begin
|
119
|
+
response = connection.post(path, request_body, request_header)
|
120
|
+
raise Error, response.status unless response.success?
|
121
|
+
|
122
|
+
response
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def request_body
|
127
|
+
JSON.generate(body)
|
128
|
+
end
|
129
|
+
|
130
|
+
def request_header
|
131
|
+
{
|
132
|
+
'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8',
|
133
|
+
'Accept' => 'application/json, text/javascript, */*; q=0.01'
|
134
|
+
}
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Senec
|
2
|
+
module Local
|
3
|
+
class State
|
4
|
+
def initialize(connection:)
|
5
|
+
@connection = connection
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :connection
|
9
|
+
|
10
|
+
# Extract state names from JavaScript file, which is formatted like this:
|
11
|
+
#
|
12
|
+
# var system_state_name = {
|
13
|
+
# 0: "FIRST STATE",
|
14
|
+
# 1: "SECOND STATE",
|
15
|
+
# ...
|
16
|
+
# };
|
17
|
+
def names(language: :de)
|
18
|
+
response(language:).match(FILE_REGEX)[0].split("\n").each_with_object({}) do |line, hash|
|
19
|
+
key, value = line.match(LINE_REGEX)&.captures
|
20
|
+
next unless key && value
|
21
|
+
|
22
|
+
hash[key.to_i] = value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
FILE_REGEX = /var system_state_name = \{(.*?)\};/m
|
29
|
+
LINE_REGEX = /(\d+)\s*:\s*"(.*)"/
|
30
|
+
|
31
|
+
def response(language:)
|
32
|
+
res = connection.get url(language:)
|
33
|
+
raise Error, res.message unless res.success?
|
34
|
+
|
35
|
+
res.body
|
36
|
+
end
|
37
|
+
|
38
|
+
# Use the JavaScript file containing English/German/Italian names from the SENEC web interface
|
39
|
+
def url(language:)
|
40
|
+
case language
|
41
|
+
when :en
|
42
|
+
'/js/EN-en.js'
|
43
|
+
when :de
|
44
|
+
'/js/DE-de.js'
|
45
|
+
when :it
|
46
|
+
'/js/IT-it.js'
|
47
|
+
else
|
48
|
+
raise Error, "Language #{language} not supported"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Senec
|
2
|
+
module Local
|
3
|
+
class Value
|
4
|
+
def initialize(raw)
|
5
|
+
@raw = raw
|
6
|
+
@prefix, @value = raw&.split('_')
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :prefix, :value, :raw
|
10
|
+
|
11
|
+
def decoded
|
12
|
+
case prefix
|
13
|
+
when 'fl'
|
14
|
+
decoded_float(value)
|
15
|
+
when 'i3', 'u1', 'u3', 'u6', 'u8'
|
16
|
+
decoded_int(value)
|
17
|
+
when 'st'
|
18
|
+
value
|
19
|
+
# TODO: There are some more prefixes to handle
|
20
|
+
else
|
21
|
+
raise DecodingError, "Unknown value '#{@raw}'"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
alias to_i decoded
|
26
|
+
alias to_f decoded
|
27
|
+
alias to_s decoded
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
PREFIXES = %w[fl i3 u1 u3 u6 u8 st].freeze
|
32
|
+
private_constant :PREFIXES
|
33
|
+
|
34
|
+
def decoded_float(hex)
|
35
|
+
["0x#{hex}".to_i(16)].pack('L').unpack1('F').round(1)
|
36
|
+
end
|
37
|
+
|
38
|
+
def decoded_int(hex)
|
39
|
+
"0x#{hex}".to_i(16)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/senec/version.rb
CHANGED
data/lib/senec.rb
CHANGED
@@ -1,9 +1,8 @@
|
|
1
1
|
require 'senec/version'
|
2
|
-
require 'senec/connection'
|
3
|
-
require 'senec/state'
|
4
|
-
require 'senec/request'
|
2
|
+
require 'senec/local/connection'
|
3
|
+
require 'senec/local/state'
|
4
|
+
require 'senec/local/request'
|
5
|
+
require 'senec/local/error'
|
5
6
|
|
6
|
-
|
7
|
-
|
8
|
-
class DecodingError < StandardError; end
|
9
|
-
end
|
7
|
+
require 'senec/cloud/dashboard'
|
8
|
+
require 'senec/cloud/error'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: senec
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.14.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Georg Ledermann
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -59,6 +59,7 @@ executables: []
|
|
59
59
|
extensions: []
|
60
60
|
extra_rdoc_files: []
|
61
61
|
files:
|
62
|
+
- ".env.test"
|
62
63
|
- ".github/workflows/push.yml"
|
63
64
|
- ".gitignore"
|
64
65
|
- ".rspec"
|
@@ -72,11 +73,15 @@ files:
|
|
72
73
|
- bin/console
|
73
74
|
- bin/setup
|
74
75
|
- lib/senec.rb
|
75
|
-
- lib/senec/connection.rb
|
76
|
-
- lib/senec/
|
77
|
-
- lib/senec/
|
78
|
-
- lib/senec/
|
79
|
-
- lib/senec/
|
76
|
+
- lib/senec/cloud/connection.rb
|
77
|
+
- lib/senec/cloud/dashboard.rb
|
78
|
+
- lib/senec/cloud/error.rb
|
79
|
+
- lib/senec/local/connection.rb
|
80
|
+
- lib/senec/local/constants.rb
|
81
|
+
- lib/senec/local/error.rb
|
82
|
+
- lib/senec/local/request.rb
|
83
|
+
- lib/senec/local/state.rb
|
84
|
+
- lib/senec/local/value.rb
|
80
85
|
- lib/senec/version.rb
|
81
86
|
- senec.gemspec
|
82
87
|
homepage: https://github.com/solectrus/senec
|
@@ -102,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
107
|
- !ruby/object:Gem::Version
|
103
108
|
version: '0'
|
104
109
|
requirements: []
|
105
|
-
rubygems_version: 3.4.
|
110
|
+
rubygems_version: 3.4.22
|
106
111
|
signing_key:
|
107
112
|
specification_version: 4
|
108
113
|
summary: Unofficial Ruby Client for SENEC Home
|
data/lib/senec/connection.rb
DELETED
@@ -1,30 +0,0 @@
|
|
1
|
-
require 'faraday'
|
2
|
-
require 'faraday/net_http_persistent'
|
3
|
-
require 'faraday-request-timer'
|
4
|
-
require 'forwardable'
|
5
|
-
|
6
|
-
module Senec
|
7
|
-
class Connection
|
8
|
-
def initialize(host:, schema: 'http')
|
9
|
-
@url = "#{schema}://#{host}"
|
10
|
-
end
|
11
|
-
|
12
|
-
extend Forwardable
|
13
|
-
def_delegators :faraday, :get, :post
|
14
|
-
|
15
|
-
private
|
16
|
-
|
17
|
-
def faraday
|
18
|
-
@faraday ||= Faraday.new @url,
|
19
|
-
ssl: { verify: false },
|
20
|
-
headers: {
|
21
|
-
'Connection' => 'keep-alive'
|
22
|
-
} do |f|
|
23
|
-
f.request :timer
|
24
|
-
f.adapter :net_http_persistent, pool_size: 5 do |http|
|
25
|
-
http.idle_timeout = 30
|
26
|
-
end
|
27
|
-
end
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
data/lib/senec/constants.rb
DELETED
@@ -1,34 +0,0 @@
|
|
1
|
-
module Senec
|
2
|
-
# For a full list of available vars, see http://[IP-of-your-SENEC]/vars.html
|
3
|
-
# Comments taken from https://gist.github.com/smashnet/82ad0b9d7f0ba2e5098e6649ba08f88a
|
4
|
-
BASIC_REQUEST = {
|
5
|
-
ENERGY: {
|
6
|
-
STAT_STATE: '', # Current state of the system (int, see SYSTEM_STATE_NAME)
|
7
|
-
GUI_BAT_DATA_CURRENT: '', # Battery charge current: negative if discharging, positiv if charging (A)
|
8
|
-
GUI_BAT_DATA_FUEL_CHARGE: '', # Remaining battery (percent)
|
9
|
-
GUI_BAT_DATA_POWER: '', # Battery charge power: negative if discharging, positiv if charging (W)
|
10
|
-
GUI_BAT_DATA_VOLTAGE: '', # Battery voltage (V)
|
11
|
-
GUI_GRID_POW: '', # Grid power: negative if exporting, positiv if importing (W)
|
12
|
-
GUI_HOUSE_POW: '', # House power consumption (W)
|
13
|
-
GUI_INVERTER_POWER: '', # PV production (W)
|
14
|
-
STAT_HOURS_OF_OPERATION: '' # Appliance hours of operation
|
15
|
-
},
|
16
|
-
WIZARD: {
|
17
|
-
APPLICATION_VERSION: ''
|
18
|
-
},
|
19
|
-
RTC: {
|
20
|
-
UTC_OFFSET: '',
|
21
|
-
WEB_TIME: ''
|
22
|
-
},
|
23
|
-
PV1: {
|
24
|
-
MPP_POWER: '', # List: MPP power (W)
|
25
|
-
POWER_RATIO: '' # Grid export limit (percent)
|
26
|
-
},
|
27
|
-
TEMPMEASURE: {
|
28
|
-
CASE_TEMP: ''
|
29
|
-
},
|
30
|
-
WALLBOX: {
|
31
|
-
APPARENT_CHARGING_POWER: ''
|
32
|
-
}
|
33
|
-
}.freeze
|
34
|
-
end
|
data/lib/senec/request.rb
DELETED
@@ -1,130 +0,0 @@
|
|
1
|
-
require 'senec/value'
|
2
|
-
require 'senec/constants'
|
3
|
-
|
4
|
-
module Senec
|
5
|
-
class Request
|
6
|
-
def initialize(connection:, state_names: nil)
|
7
|
-
@connection = connection
|
8
|
-
@state_names = state_names
|
9
|
-
end
|
10
|
-
|
11
|
-
attr_reader :connection, :state_names
|
12
|
-
|
13
|
-
def house_power
|
14
|
-
get('ENERGY', 'GUI_HOUSE_POW')
|
15
|
-
end
|
16
|
-
|
17
|
-
def inverter_power
|
18
|
-
get('ENERGY', 'GUI_INVERTER_POWER')
|
19
|
-
end
|
20
|
-
|
21
|
-
def mpp_power
|
22
|
-
get('PV1', 'MPP_POWER')
|
23
|
-
end
|
24
|
-
|
25
|
-
def power_ratio
|
26
|
-
get('PV1', 'POWER_RATIO')
|
27
|
-
end
|
28
|
-
|
29
|
-
def bat_power
|
30
|
-
get('ENERGY', 'GUI_BAT_DATA_POWER')
|
31
|
-
end
|
32
|
-
|
33
|
-
def bat_fuel_charge
|
34
|
-
get('ENERGY', 'GUI_BAT_DATA_FUEL_CHARGE')
|
35
|
-
end
|
36
|
-
|
37
|
-
def bat_charge_current
|
38
|
-
get('ENERGY', 'GUI_BAT_DATA_CURRENT')
|
39
|
-
end
|
40
|
-
|
41
|
-
def bat_voltage
|
42
|
-
get('ENERGY', 'GUI_BAT_DATA_VOLTAGE')
|
43
|
-
end
|
44
|
-
|
45
|
-
def grid_power
|
46
|
-
get('ENERGY', 'GUI_GRID_POW')
|
47
|
-
end
|
48
|
-
|
49
|
-
def wallbox_charge_power
|
50
|
-
get('WALLBOX', 'APPARENT_CHARGING_POWER')
|
51
|
-
end
|
52
|
-
|
53
|
-
def case_temp
|
54
|
-
get('TEMPMEASURE', 'CASE_TEMP')
|
55
|
-
end
|
56
|
-
|
57
|
-
def application_version
|
58
|
-
get('WIZARD', 'APPLICATION_VERSION')
|
59
|
-
end
|
60
|
-
|
61
|
-
def current_state_code
|
62
|
-
get('ENERGY', 'STAT_STATE')
|
63
|
-
end
|
64
|
-
|
65
|
-
def current_state_name
|
66
|
-
throw RuntimeError, 'No state names provided!' unless state_names
|
67
|
-
|
68
|
-
state_names[current_state_code]
|
69
|
-
end
|
70
|
-
|
71
|
-
def measure_time
|
72
|
-
web_time = get('RTC', 'WEB_TIME')
|
73
|
-
utc_offset = get('RTC', 'UTC_OFFSET')
|
74
|
-
|
75
|
-
web_time - (utc_offset * 60)
|
76
|
-
end
|
77
|
-
|
78
|
-
def response_duration
|
79
|
-
raw_response.env[:duration]
|
80
|
-
end
|
81
|
-
|
82
|
-
private
|
83
|
-
|
84
|
-
def get(*keys)
|
85
|
-
return unless parsed_response
|
86
|
-
|
87
|
-
value = parsed_response.dig(*keys)
|
88
|
-
|
89
|
-
if value.is_a?(Array)
|
90
|
-
value.map do |v|
|
91
|
-
Senec::Value.new(v).decoded
|
92
|
-
end
|
93
|
-
elsif value
|
94
|
-
Senec::Value.new(value).decoded
|
95
|
-
else
|
96
|
-
raise Senec::Error, "Value missing for #{keys.join('.')}"
|
97
|
-
end
|
98
|
-
rescue DecodingError => e
|
99
|
-
raise Senec::Error, "Decoding failed for #{keys.join('.')}: #{e.message}"
|
100
|
-
end
|
101
|
-
|
102
|
-
def parsed_response
|
103
|
-
@parsed_response ||= JSON.parse(raw_response.body)
|
104
|
-
end
|
105
|
-
|
106
|
-
def raw_response
|
107
|
-
@raw_response ||= begin
|
108
|
-
response = connection.post(url, request_body, request_header)
|
109
|
-
raise Senec::Error, response.status unless response.success?
|
110
|
-
|
111
|
-
response
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
def url
|
116
|
-
'/lala.cgi'
|
117
|
-
end
|
118
|
-
|
119
|
-
def request_body
|
120
|
-
JSON.generate(Senec::BASIC_REQUEST)
|
121
|
-
end
|
122
|
-
|
123
|
-
def request_header
|
124
|
-
{
|
125
|
-
'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8',
|
126
|
-
'Accept' => 'application/json, text/javascript, */*; q=0.01'
|
127
|
-
}
|
128
|
-
end
|
129
|
-
end
|
130
|
-
end
|
data/lib/senec/state.rb
DELETED
@@ -1,51 +0,0 @@
|
|
1
|
-
module Senec
|
2
|
-
class State
|
3
|
-
def initialize(connection:)
|
4
|
-
@connection = connection
|
5
|
-
end
|
6
|
-
|
7
|
-
attr_reader :connection
|
8
|
-
|
9
|
-
# Extract state names from JavaScript file, which is formatted like this:
|
10
|
-
#
|
11
|
-
# var system_state_name = {
|
12
|
-
# 0: "FIRST STATE",
|
13
|
-
# 1: "SECOND STATE",
|
14
|
-
# ...
|
15
|
-
# };
|
16
|
-
def names(language: :de)
|
17
|
-
response(language:).match(FILE_REGEX)[0].split("\n").each_with_object({}) do |line, hash|
|
18
|
-
key, value = line.match(LINE_REGEX)&.captures
|
19
|
-
next unless key && value
|
20
|
-
|
21
|
-
hash[key.to_i] = value
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
private
|
26
|
-
|
27
|
-
FILE_REGEX = /var system_state_name = \{(.*?)\};/m
|
28
|
-
LINE_REGEX = /(\d+)\s*:\s*"(.*)"/
|
29
|
-
|
30
|
-
def response(language:)
|
31
|
-
res = connection.get url(language:)
|
32
|
-
raise Senec::Error, res.message unless res.success?
|
33
|
-
|
34
|
-
res.body
|
35
|
-
end
|
36
|
-
|
37
|
-
# Use the JavaScript file containing English/German/Italian names from the SENEC web interface
|
38
|
-
def url(language:)
|
39
|
-
case language
|
40
|
-
when :en
|
41
|
-
'/js/EN-en.js'
|
42
|
-
when :de
|
43
|
-
'/js/DE-de.js'
|
44
|
-
when :it
|
45
|
-
'/js/IT-it.js'
|
46
|
-
else
|
47
|
-
raise Senec::Error, "Language #{language} not supported"
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
51
|
-
end
|
data/lib/senec/value.rb
DELETED
@@ -1,45 +0,0 @@
|
|
1
|
-
module Senec
|
2
|
-
class Value
|
3
|
-
def initialize(raw)
|
4
|
-
@raw = raw
|
5
|
-
@prefix, @value = raw&.split('_')
|
6
|
-
end
|
7
|
-
|
8
|
-
attr_reader :prefix, :value, :raw
|
9
|
-
|
10
|
-
def valid?
|
11
|
-
prefix && PREFIXES.include?(prefix)
|
12
|
-
end
|
13
|
-
|
14
|
-
def decoded
|
15
|
-
case prefix
|
16
|
-
when 'fl'
|
17
|
-
decoded_float(value)
|
18
|
-
when 'i3', 'u1', 'u3', 'u6', 'u8'
|
19
|
-
decoded_int(value)
|
20
|
-
when 'st'
|
21
|
-
value
|
22
|
-
# TODO: There are some more prefixes to handle
|
23
|
-
else
|
24
|
-
raise Senec::DecodingError, "Unknown value '#{@raw}'"
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
alias to_i decoded
|
29
|
-
alias to_f decoded
|
30
|
-
alias to_s decoded
|
31
|
-
|
32
|
-
private
|
33
|
-
|
34
|
-
PREFIXES = %w[fl i3 u1 u3 u6 u8 st].freeze
|
35
|
-
private_constant :PREFIXES
|
36
|
-
|
37
|
-
def decoded_float(hex)
|
38
|
-
["0x#{hex}".to_i(16)].pack('L').unpack1('F').round(1)
|
39
|
-
end
|
40
|
-
|
41
|
-
def decoded_int(hex)
|
42
|
-
"0x#{hex}".to_i(16)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|