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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2393be5a611f5e463679c73cea88426b38797d16a1678864d794164ebf4b2186
4
- data.tar.gz: d10a2d220bd8594fc8f22721c97290a0d1e1390e3f6a06621ad1a5f8965433da
3
+ metadata.gz: adfa786a844f0a43a8e3a87763249da55e2e6165e80600ada958fe7aed674d32
4
+ data.tar.gz: ff9d52cc6e3fd5be19d49d474ee145526cbe0930438859b05423c92163b95274
5
5
  SHA512:
6
- metadata.gz: 909689c326e6dcd09bc46f43e24c9e0019279af93b6b665ba9b859b62592010ccea49d3a6a5603d4970df799e7fb57908ad7c8b69b457f00452ea6ffe9c52153
7
- data.tar.gz: 82101ee5a0f8e982cfeb7cc3ad576dfe74febd229b483157d21aaa1b4fe08d5e6708c8bb4a705fcbad10885361a91f75e530de095138f4972bc8aa2151414795
6
+ metadata.gz: c7b507db12c1b6712da5ba93b2780ac34b31c0ff5c108714d0b540f1e43a270ee30f4f4de234c5948e29a1b2d28f872aed1590f87945b6ac56e7e32b80ee7d43
7
+ data.tar.gz: c2739c6d4ef96c3d359e1d0a1398a5ce07363517db15c6032fb339aadbe802758751149af0f4d4d1b58e3d9a649367ed82667905a68259b7bce0ae3fc373f29a
data/.env.test ADDED
@@ -0,0 +1,3 @@
1
+ SENEC_USERNAME=mail@example.com
2
+ SENEC_PASSWORD=topsecret
3
+ SENEC_SYSTEM_ID=123456
@@ -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@v3
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 rake
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
@@ -6,6 +6,7 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ .env
9
10
 
10
11
  # rspec failure tracking
11
12
  .rspec_status
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
@@ -12,7 +12,7 @@
12
12
  },
13
13
 
14
14
  "[ruby]": {
15
- "editor.defaultFormatter": "esbenp.prettier-vscode",
15
+ "editor.defaultFormatter": "Shopify.ruby-lsp",
16
16
  "editor.formatOnSave": true
17
17
  }
18
18
  }
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
- # Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests. (https://relishapp.com/vcr/vcr/docs)
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,12 @@
1
+ module Senec
2
+ module Cloud
3
+ class Error < StandardError
4
+ attr_reader :status
5
+
6
+ def initialize(message, status)
7
+ super(message)
8
+ @status = status
9
+ end
10
+ end
11
+ end
12
+ 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,6 @@
1
+ module Senec
2
+ module Local
3
+ class Error < StandardError; end
4
+ class DecodingError < StandardError; end
5
+ end
6
+ 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
@@ -1,3 +1,3 @@
1
1
  module Senec
2
- VERSION = '0.13.0'.freeze
2
+ VERSION = '0.14.0'.freeze
3
3
  end
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
- module Senec
7
- class Error < StandardError; end
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.13.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-09-29 00:00:00.000000000 Z
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/constants.rb
77
- - lib/senec/request.rb
78
- - lib/senec/state.rb
79
- - lib/senec/value.rb
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.20
110
+ rubygems_version: 3.4.22
106
111
  signing_key:
107
112
  specification_version: 4
108
113
  summary: Unofficial Ruby Client for SENEC Home
@@ -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
@@ -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