stekker_zaptec 1.1.1 → 1.2.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: 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