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