stekker_zaptec 1.1.1 → 1.2.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: b93472771162eced218695ad47612b8d001d0b562669077af092de5ac5733d81
4
- data.tar.gz: 78d184fb7979d966be2944c575a70444372555ea78101163f78c8565b5a90cec
3
+ metadata.gz: 033251f5675b4433989cfed337cc22e1271d37e5590b2a375964a3d207a0b5b4
4
+ data.tar.gz: f486808fc05a7d926cde6cbbb534d76ab5e74c93173f469a065fa5d5bbeedee6
5
5
  SHA512:
6
- metadata.gz: d9930219d2b59aea5968498626b3289e40762a83afdfbd177605e4909ec9424cf588ca62f29b6162bce63803f4a4dd4a1ee06631451251cc547fb8678712bfc4
7
- data.tar.gz: 5674b39478d6d644036f4ea5e85794e5331ef0440775d83ee21c46e36fd98e0f77e5b09d5e112f9c0238a7269fbf784a52449f08a55a05c9e8738bf870af2b07
6
+ metadata.gz: 0242033c65f7e6b86e4c24b82692b01697e07a3ac667a7aa551a9a61c1b195cdfc3b6d74e40c7883ea372321119aef753245a1bd63d755f9dbd40806ea3e1205
7
+ data.tar.gz: d54c276c0f1a24ef8dd8a9a76c9458cf1e3589481f2b627c575a68f238d580b3eda8d12cd51bcf3d9be23c325d8567d38d7d942206b1d1898dfa00417f3f9456
@@ -15,7 +15,7 @@ jobs:
15
15
  uses: actions/checkout@v3
16
16
 
17
17
  - name: Install Ruby and gems
18
- uses: ruby/setup-ruby@ee2113536afb7f793eed4ce60e8d3b26db912da4
18
+ uses: ruby/setup-ruby@v1
19
19
  with:
20
20
  bundler-cache: true
21
21
 
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.0.2
1
+ 3.2.1
data/Gemfile CHANGED
@@ -3,6 +3,7 @@ source "https://rubygems.org"
3
3
  # Specify your gem's dependencies in zaptec.gemspec
4
4
  gemspec
5
5
 
6
+ gem "gem-release", "~> 2.2"
6
7
  gem "jwt", "~> 2.6"
7
8
  gem "rake", "~> 13.0"
8
9
  gem "rspec", "~> 3.0"
data/Gemfile.lock CHANGED
@@ -49,6 +49,7 @@ GEM
49
49
  faraday-retry (1.0.3)
50
50
  faraday_middleware (1.2.0)
51
51
  faraday (~> 1.0)
52
+ gem-release (2.2.2)
52
53
  hashdiff (1.0.1)
53
54
  i18n (1.12.0)
54
55
  concurrent-ruby (~> 1.0)
@@ -118,6 +119,7 @@ PLATFORMS
118
119
  x86_64-linux
119
120
 
120
121
  DEPENDENCIES
122
+ gem-release (~> 2.2)
121
123
  jwt (~> 2.6)
122
124
  rake (~> 13.0)
123
125
  rspec (~> 3.0)
@@ -4,11 +4,15 @@ require "active_model"
4
4
  require "active_support/all"
5
5
 
6
6
  require "zaptec/charger"
7
+ require "zaptec/circuit"
7
8
  require "zaptec/client"
8
9
  require "zaptec/constants"
9
10
  require "zaptec/credentials"
10
11
  require "zaptec/errors"
12
+ require "zaptec/installation"
13
+ require "zaptec/installation_hierarchy"
11
14
  require "zaptec/meter_reading"
15
+ require "zaptec/null_encryptor"
12
16
  require "zaptec/state"
13
17
  require "zaptec/version"
14
18
 
@@ -1,38 +1,14 @@
1
1
  module Zaptec
2
2
  class Charger
3
- attr_reader :id,
4
- :name,
5
- :device_id,
6
- :device_type,
7
- :installation_name,
8
- :installation_id
9
-
10
- def initialize(
11
- id:,
12
- name:,
13
- device_id:,
14
- device_type:,
15
- installation_name:,
16
- installation_id:
17
- )
18
-
19
- @id = id
20
- @name = name
21
- @device_id = device_id
22
- @device_type = device_type
23
- @installation_name = installation_name
24
- @installation_id = installation_id
3
+ def initialize(data)
4
+ @data = data.symbolize_keys
25
5
  end
26
6
 
27
- def self.parse(data)
28
- new(
29
- id: data.fetch("Id"),
30
- name: data.fetch("Name"),
31
- device_id: data.fetch("DeviceId"),
32
- device_type: data.fetch("DeviceType"),
33
- installation_name: data.fetch("InstallationName"),
34
- installation_id: data.fetch("InstallationId")
35
- )
36
- end
7
+ def id = @data.fetch(:Id)
8
+ def name = @data.fetch(:Name)
9
+ def device_id = @data.fetch(:DeviceId)
10
+ def device_type = @data.fetch(:DeviceType)
11
+ def installation_name = @data.fetch(:InstallationName)
12
+ def installation_id = @data.fetch(:InstallationId)
37
13
  end
38
14
  end
@@ -0,0 +1,14 @@
1
+ module Zaptec
2
+ class Circuit
3
+ def initialize(data)
4
+ @data = data.symbolize_keys
5
+ end
6
+
7
+ def id = @data.fetch(:Id)
8
+ def max_current = @data.fetch(:MaxCurrent)
9
+
10
+ def chargers
11
+ @chargers ||= @data.fetch(:Chargers).map { |data| Charger.new(data) }
12
+ end
13
+ end
14
+ end
data/lib/zaptec/client.rb CHANGED
@@ -1,10 +1,11 @@
1
1
  module Zaptec
2
2
  class Client
3
- BASE_URI = "https://api.zaptec.com".freeze
3
+ BASE_URL = "https://api.zaptec.com".freeze
4
4
  USER_ROLE = 1
5
5
  OWNER_ROLE = 2
6
+ TOKENS_CACHE_KEY = "zaptec.auth.tokens".freeze
6
7
 
7
- attr_reader :http_client, :credentials
8
+ attr_reader :credentials
8
9
 
9
10
  delegate :expired?,
10
11
  :access_token,
@@ -12,27 +13,35 @@ module Zaptec
12
13
  to: :credentials,
13
14
  prefix: true
14
15
 
15
- def initialize(credentials: nil)
16
- @credentials = credentials
16
+ def initialize(
17
+ username:,
18
+ password:,
19
+ token_cache: ActiveSupport::Cache::MemoryStore.new,
20
+ encryptor: NullEncryptor.new
21
+ )
22
+ @username = username
23
+ @password = password
24
+ @token_cache = token_cache
25
+ @encryptor = encryptor
26
+ end
17
27
 
18
- @http_client = Faraday.new(url: BASE_URI) do |conn|
19
- conn.request :json
20
- conn.response :json
21
- conn.response :raise_error
22
- end
28
+ # https://zendesk.zaptec.com/hc/en-001/articles/6062673456657-Access-to-Installations-Authentication-for-Third-Parties#lookup-key-0-0
29
+ def grant_access_url(lookup_key:, partner_name:, redirect_url: nil, language: "en")
30
+ query = URI.encode_www_form(partnerName: partner_name, returnUrl: redirect_url, lang: language)
31
+ "https://portal.zaptec.com/#!/access/request/#{lookup_key}?#{query}"
23
32
  end
24
33
 
25
34
  # https://zaptec.com/downloads/ZapChargerPro_Integration.pdf
26
35
  def authorize(username:, password:)
27
36
  raise Errors::ParameterMissing if username.blank? || password.blank?
28
37
 
29
- start = Time.zone.now
38
+ start = Time.current
30
39
 
31
- response = http_client.post(
32
- "#{BASE_URI}/oauth/token",
40
+ response = connection.post(
41
+ "#{BASE_URL}/oauth/token",
33
42
  {
34
- username: username,
35
- password: password,
43
+ username:,
44
+ password:,
36
45
  grant_type: "password"
37
46
  }.to_query,
38
47
  {
@@ -53,7 +62,19 @@ module Zaptec
53
62
  get("/api/chargers", { Roles: USER_ROLE | OWNER_ROLE })
54
63
  .body
55
64
  .fetch("Data")
56
- .map { |data| Charger.parse(data) }
65
+ .map { |data| Charger.new(data) }
66
+ end
67
+
68
+ # https://api.zaptec.com/help/index.html#/Installation/get_api_installation__id_
69
+ def get_installation(installation_id)
70
+ get("/api/installation/#{installation_id}")
71
+ .then { |response| Installation.new(response.body) }
72
+ end
73
+
74
+ # https://api.zaptec.com/help/index.html#/Installation/get_api_installation__id__hierarchy
75
+ def get_installation_hierarchy(installation_id)
76
+ get("/api/installation/#{installation_id}/hierarchy")
77
+ .then { |response| InstallationHierarchy.new(response.body) }
57
78
  end
58
79
 
59
80
  # https://api.zaptec.com/help/index.html#/Charger/get_api_chargers__id__state
@@ -62,7 +83,7 @@ module Zaptec
62
83
  .body
63
84
  .to_h do |state|
64
85
  [
65
- Constants.observation_state_id_to_name(state_id: state.fetch("StateId"), device_type: device_type),
86
+ Constants.observation_state_id_to_name(state_id: state.fetch("StateId"), device_type:),
66
87
  state.fetch("ValueAsString", nil)
67
88
  ]
68
89
  end
@@ -75,6 +96,22 @@ module Zaptec
75
96
 
76
97
  private
77
98
 
99
+ attr_reader :username, :password
100
+
101
+ def connection
102
+ Faraday.new(url: BASE_URL) do |conn|
103
+ conn.request :json
104
+ conn.response :json
105
+ conn.response :raise_error
106
+ end
107
+ end
108
+
109
+ def authenticated_connection
110
+ connection.tap do |conn|
111
+ conn.request :authorization, "Bearer", access_token
112
+ end
113
+ end
114
+
78
115
  # https://api.zaptec.com/help/index.html#/Charger/post_api_chargers__id__sendCommand__commandId_
79
116
  def send_command(charger_id, command)
80
117
  command_id = Constants.command_to_command_id(command)
@@ -83,29 +120,69 @@ module Zaptec
83
120
  end
84
121
 
85
122
  def get(endpoint, query = {})
86
- require_valid_credentials!
123
+ with_error_handling do
124
+ authenticated_connection.get("#{BASE_URL}#{endpoint}", query)
125
+ end
126
+ end
87
127
 
88
- http_client.get(
89
- "#{BASE_URI}#{endpoint}",
90
- query,
91
- { Authorization: "Bearer #{credentials.access_token}" }
92
- )
128
+ def post(endpoint, body: nil, query: nil)
129
+ with_error_handling do
130
+ authenticated_connection.post("#{BASE_URL}#{endpoint}", body) do |req|
131
+ req.params = query unless query.nil?
132
+ end
133
+ end
93
134
  end
94
135
 
95
- def post(endpoint, body = nil)
96
- require_valid_credentials!
136
+ def with_error_handling
137
+ token_refreshed ||= false
97
138
 
98
- http_client.post(
99
- "#{BASE_URI}#{endpoint}",
100
- body,
101
- { Authorization: "Bearer #{credentials.access_token}" }
102
- )
139
+ yield
140
+ rescue Faraday::UnauthorizedError => e
141
+ if token_refreshed
142
+ raise Errors::RequestFailed.new("Request returned status #{e.response_status}", e.response)
143
+ else
144
+ refresh_access_token!
145
+ token_refreshed = true
146
+
147
+ retry
148
+ end
103
149
  rescue Faraday::Error => e
104
- raise Errors::RequestFailed, "Request returned status #{e.response_status}"
150
+ raise Errors::RequestFailed.new("Request returned status #{e.response_status}", e.response)
151
+ end
152
+
153
+ def access_token
154
+ current_access_token
155
+ .then do |current|
156
+ if current.expired?
157
+ refresh_access_token!
158
+ current_access_token
159
+ else
160
+ current
161
+ end
162
+ end
163
+ .then(&:access_token)
105
164
  end
106
165
 
107
- def require_valid_credentials!
108
- raise Errors::Unauthorized if credentials.blank? || credentials.expired?
166
+ def current_access_token
167
+ encrypted_tokens = @token_cache.fetch(TOKENS_CACHE_KEY) do
168
+ @encryptor.encrypt(request_access_token.to_json, cipher_options: { deterministic: true })
169
+ end
170
+
171
+ plain_text_tokens = @encryptor.decrypt(encrypted_tokens)
172
+ Credentials.parse(JSON.parse(plain_text_tokens))
109
173
  end
174
+
175
+ def refresh_access_token!
176
+ @token_cache.write(
177
+ TOKENS_CACHE_KEY,
178
+ @encryptor.encrypt(request_access_token.to_json, cipher_options: { deterministic: true }),
179
+ expires_in: 1.day
180
+ )
181
+ rescue Faraday::Error => e
182
+ raise Errors::RequestFailed.new("Request returned status #{e.response_status}", e.response)
183
+ end
184
+
185
+ # https://developer.easee.cloud/reference/post_api-accounts-login
186
+ def request_access_token = authorize(username:, password:)
110
187
  end
111
188
  end
@@ -21,6 +21,22 @@ module Zaptec
21
21
  .fetch(command.to_s) { raise "Unknown command '#{command}'" }
22
22
  end
23
23
 
24
+ def country_id_to_country_code(country_id)
25
+ return if country_id.nil?
26
+
27
+ constants
28
+ .fetch("Countries")
29
+ .fetch(country_id)
30
+ .fetch("Code")
31
+ end
32
+
33
+ def network_type_to_name(network_type)
34
+ constants
35
+ .fetch("NetworkTypes")
36
+ .detect { |_name, type| type == network_type }
37
+ .then { |name, _type| name }
38
+ end
39
+
24
40
  private
25
41
 
26
42
  def device_type_observation_ids(device_type)
@@ -10,5 +10,16 @@ module Zaptec
10
10
  def expired?(at = Time.zone.now)
11
11
  expires_at.nil? || at >= expires_at
12
12
  end
13
+
14
+ def self.parse(data)
15
+ new(
16
+ data.fetch("access_token"),
17
+ Time.zone.at(data.fetch("expires_at"))
18
+ )
19
+ end
20
+
21
+ def as_json(*)
22
+ super.merge("expires_at" => expires_at.to_i)
23
+ end
13
24
  end
14
25
  end
data/lib/zaptec/errors.rb CHANGED
@@ -3,7 +3,14 @@ module Zaptec
3
3
  class Base < StandardError; end
4
4
  class ParameterMissing < Base; end
5
5
  class Unauthorized < Base; end
6
- class RequestFailed < Base; end
6
+ class RequestFailed < Base
7
+ attr_reader :response
8
+
9
+ def initialize(message, response = nil)
10
+ @response = response
11
+ super(message)
12
+ end
13
+ end
7
14
  class AuthorizationFailed < Base; end
8
15
  end
9
16
  end
@@ -0,0 +1,15 @@
1
+ module Zaptec
2
+ class Installation
3
+ def initialize(data)
4
+ @data = data.deep_symbolize_keys
5
+ end
6
+
7
+ def id = @data.fetch(:Id)
8
+ def address = @data[:Address]
9
+ def zip_code = @data[:ZipCode]
10
+ def city = @data[:City]
11
+ def latitude = @data[:Latitude]
12
+ def country_code = Constants.country_id_to_country_code(@data[:CountryId])
13
+ def longitude = @data[:Longitude]
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Zaptec
2
+ class InstallationHierarchy
3
+ def initialize(data)
4
+ @data = data.deep_symbolize_keys
5
+ end
6
+
7
+ def id = @data.fetch(:Id)
8
+ def name = @data.fetch(:Name)
9
+ def network_type = Constants.network_type_to_name(@data.fetch(:NetworkType))
10
+
11
+ def circuits
12
+ @circuits ||= @data.fetch(:Circuits).map { |data| Circuit.new(data) }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module Zaptec
2
+ # rubocop:disable Lint/UnusedMethodArgument
3
+ class NullEncryptor
4
+ def encrypt(clear_text, key_provider: nil, cipher_options: {})
5
+ clear_text
6
+ end
7
+
8
+ def decrypt(encrypted_text, key_provider: nil, cipher_options: {})
9
+ encrypted_text
10
+ end
11
+
12
+ def encrypted?(_text)
13
+ false
14
+ end
15
+ end
16
+ # rubocop:enable Lint/UnusedMethodArgument
17
+ end
data/lib/zaptec/state.rb CHANGED
@@ -20,7 +20,7 @@ module Zaptec
20
20
  def online? = @data.fetch(:IsOnline).to_i.positive?
21
21
 
22
22
  def meter_reading
23
- @meter_reading ||= MeterReading.new(reading_kwh: total_charge_power_session, timestamp: Time.zone.now)
23
+ @meter_reading ||= MeterReading.new(reading_kwh: total_charge_power, timestamp: Time.zone.now)
24
24
  end
25
25
 
26
26
  private
@@ -1,3 +1,3 @@
1
1
  module Zaptec
2
- VERSION = "1.1.1".freeze
2
+ VERSION = "1.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stekker_zaptec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Team Stekker
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-02-20 00:00:00.000000000 Z
11
+ date: 2023-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -87,11 +87,15 @@ files:
87
87
  - data/constants.json
88
88
  - lib/stekker_zaptec.rb
89
89
  - lib/zaptec/charger.rb
90
+ - lib/zaptec/circuit.rb
90
91
  - lib/zaptec/client.rb
91
92
  - lib/zaptec/constants.rb
92
93
  - lib/zaptec/credentials.rb
93
94
  - lib/zaptec/errors.rb
95
+ - lib/zaptec/installation.rb
96
+ - lib/zaptec/installation_hierarchy.rb
94
97
  - lib/zaptec/meter_reading.rb
98
+ - lib/zaptec/null_encryptor.rb
95
99
  - lib/zaptec/state.rb
96
100
  - lib/zaptec/version.rb
97
101
  - zaptec.gemspec
@@ -110,14 +114,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
110
114
  requirements:
111
115
  - - ">="
112
116
  - !ruby/object:Gem::Version
113
- version: 3.0.2
117
+ version: 3.2.1
114
118
  required_rubygems_version: !ruby/object:Gem::Requirement
115
119
  requirements:
116
120
  - - ">="
117
121
  - !ruby/object:Gem::Version
118
122
  version: '0'
119
123
  requirements: []
120
- rubygems_version: 3.2.22
124
+ rubygems_version: 3.4.6
121
125
  signing_key:
122
126
  specification_version: 4
123
127
  summary: Connect to your Zaptec charger