senec 0.13.0 → 0.14.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Continuous integration](https://github.com/solectrus/senec/actions/workflows/push.yml/badge.svg)](https://github.com/solectrus/senec/actions/workflows/push.yml)
|
2
2
|
[![wakatime](https://wakatime.com/badge/user/697af4f5-617a-446d-ba58-407e7f3e0243/project/84ac7dc2-9288-497c-bb20-9c6123d3de66.svg)](https://wakatime.com/badge/user/697af4f5-617a-446d-ba58-407e7f3e0243/project/84ac7dc2-9288-497c-bb20-9c6123d3de66)
|
3
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/7f87c569e806d4f19368/maintainability)](https://codeclimate.com/github/solectrus/senec/maintainability)
|
4
|
+
[![Test Coverage](https://api.codeclimate.com/v1/badges/7f87c569e806d4f19368/test_coverage)](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
|