stekker_zaptec 1.0.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.
@@ -0,0 +1,38 @@
1
+ module Zaptec
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
25
+ end
26
+
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
37
+ end
38
+ end
@@ -0,0 +1,111 @@
1
+ module Zaptec
2
+ class Client
3
+ BASE_URI = "https://api.zaptec.com".freeze
4
+ USER_ROLE = 1
5
+ OWNER_ROLE = 2
6
+
7
+ attr_reader :http_client, :credentials
8
+
9
+ delegate :expired?,
10
+ :access_token,
11
+ :expires_at,
12
+ to: :credentials,
13
+ prefix: true
14
+
15
+ def initialize(credentials: nil)
16
+ @credentials = credentials
17
+
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
23
+ end
24
+
25
+ # https://zaptec.com/downloads/ZapChargerPro_Integration.pdf
26
+ def authorize(username:, password:)
27
+ raise Errors::ParameterMissing if username.blank? || password.blank?
28
+
29
+ start = Time.zone.now
30
+
31
+ response = http_client.post(
32
+ "#{BASE_URI}/oauth/token",
33
+ {
34
+ username: username,
35
+ password: password,
36
+ grant_type: "password"
37
+ }.to_query,
38
+ {
39
+ "Content-Type": "application/x-www-form-urlencoded"
40
+ }
41
+ )
42
+
43
+ @credentials = Zaptec::Credentials.new(
44
+ response.body["access_token"],
45
+ start + response.body["expires_in"].to_f
46
+ )
47
+ rescue Faraday::BadRequestError
48
+ raise Errors::AuthorizationFailed
49
+ end
50
+
51
+ # https://api.zaptec.com/help/index.html#/Charger/get_api_chargers
52
+ def chargers
53
+ get("/api/chargers", { Roles: USER_ROLE | OWNER_ROLE })
54
+ .body
55
+ .fetch("Data")
56
+ .map { |data| Charger.parse(data) }
57
+ end
58
+
59
+ # https://api.zaptec.com/help/index.html#/Charger/get_api_chargers__id__state
60
+ def state(charger_id, device_type)
61
+ get("/api/chargers/#{charger_id}/state")
62
+ .body
63
+ .to_h do |state|
64
+ [
65
+ Constants.observation_state_id_to_name(state_id: state.fetch("StateId"), device_type: device_type),
66
+ state.fetch("ValueAsString", nil)
67
+ ]
68
+ end
69
+ .then { |data| State.new(data) }
70
+ end
71
+
72
+ def pause_charging(charger_id) = send_command(charger_id, :StopChargingFinal)
73
+
74
+ def resume_charging(charger_id) = send_command(charger_id, :ResumeCharging)
75
+
76
+ private
77
+
78
+ # https://api.zaptec.com/help/index.html#/Charger/post_api_chargers__id__sendCommand__commandId_
79
+ def send_command(charger_id, command)
80
+ command_id = Constants.command_to_command_id(command)
81
+
82
+ post("/api/chargers/#{charger_id}/sendCommand/#{command_id}")
83
+ end
84
+
85
+ def get(endpoint, query = {})
86
+ require_valid_credentials!
87
+
88
+ http_client.get(
89
+ "#{BASE_URI}#{endpoint}",
90
+ query,
91
+ { Authorization: "Bearer #{credentials.access_token}" }
92
+ )
93
+ end
94
+
95
+ def post(endpoint, body = nil)
96
+ require_valid_credentials!
97
+
98
+ http_client.post(
99
+ "#{BASE_URI}#{endpoint}",
100
+ body,
101
+ { Authorization: "Bearer #{credentials.access_token}" }
102
+ )
103
+ rescue Faraday::Error => e
104
+ raise Errors::RequestFailed, "Request returned status #{e.response_status}"
105
+ end
106
+
107
+ def require_valid_credentials!
108
+ raise Errors::Unauthorized if credentials.blank? || credentials.expired?
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,57 @@
1
+ module Zaptec
2
+ class Constants
3
+ class << self
4
+ def observation_state_id_to_name(state_id:, device_type:)
5
+ device_type_observation_ids(device_type)
6
+ .fetch(state_id)
7
+ end
8
+
9
+ def charger_operation_mode_to_name(operation_mode)
10
+ constants
11
+ .fetch("ChargerOperationModes")
12
+ .detect { |_name, mode| mode == operation_mode }
13
+ .then { |name, _mode| name }
14
+ end
15
+
16
+ def command_to_command_id(command)
17
+ constants
18
+ .fetch("Commands")
19
+ .fetch(command.to_s) { raise "Unknown command '#{command}'" }
20
+ end
21
+
22
+ private
23
+
24
+ def device_type_observation_ids(device_type)
25
+ @device_type_observation_ids ||= {}
26
+
27
+ @device_type_observation_ids[device_type] ||=
28
+ begin
29
+ global_observation_ids = constants.fetch("Observations").invert.transform_values(&:to_sym)
30
+
31
+ device_specific_observations =
32
+ constants
33
+ .fetch("Schema")
34
+ .fetch(device_type_to_name(device_type))
35
+ .fetch("ObservationIds")
36
+ .invert
37
+ .transform_values(&:to_sym)
38
+
39
+ global_observation_ids.merge(device_specific_observations)
40
+ end
41
+ end
42
+
43
+ def device_type_to_name(device_type)
44
+ constants
45
+ .fetch("DeviceTypes")
46
+ .detect { |_name, type| type == device_type }
47
+ .then { |name, _type| name }
48
+ end
49
+
50
+ def constants
51
+ @constants ||= JSON.parse(constants_file.read)
52
+ end
53
+
54
+ def constants_file = Pathname.new(__dir__).join("../../data/constants.json")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ module Zaptec
2
+ class Credentials
3
+ attr_accessor :access_token, :expires_at
4
+
5
+ def initialize(access_token, expires_at)
6
+ @access_token = access_token
7
+ @expires_at = expires_at
8
+ end
9
+
10
+ def expired?(at = Time.zone.now)
11
+ expires_at.nil? || at >= expires_at
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ module Zaptec
2
+ module Errors
3
+ class Base < StandardError; end
4
+ class ParameterMissing < Base; end
5
+ class Unauthorized < Base; end
6
+ class RequestFailed < Base; end
7
+ class AuthorizationFailed < Base; end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module Zaptec
2
+ class MeterReading
3
+ attr_reader :reading_kwh, :timestamp
4
+
5
+ def initialize(reading_kwh:, timestamp:)
6
+ @reading_kwh = reading_kwh
7
+ @timestamp = timestamp
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,32 @@
1
+ module Zaptec
2
+ class State
3
+ CHARGING_MODES = %w[Connected_Requesting Connected_Charging].freeze
4
+ DISCONNECTED = "Disconnected".freeze
5
+
6
+ def initialize(data)
7
+ @data = data
8
+ end
9
+
10
+ def total_charge_power = @data.fetch(:TotalChargePower).to_f
11
+
12
+ def max_phases = @data.fetch(:MaxPhases).to_i
13
+
14
+ def total_charge_power_session = @data.fetch(:TotalChargePowerSession).to_f
15
+
16
+ def charging? = charger_operation_mode.in?(CHARGING_MODES)
17
+
18
+ def disconnected? = charger_operation_mode == DISCONNECTED
19
+
20
+ def online? = @data.fetch(:IsOnline).to_i.positive?
21
+
22
+ def meter_reading
23
+ @meter_reading ||= MeterReading.new(reading_kwh: total_charge_power_session, timestamp: Time.zone.now)
24
+ end
25
+
26
+ private
27
+
28
+ def charger_operation_mode
29
+ Constants.charger_operation_mode_to_name(@data.fetch(:ChargerOperationMode).to_i)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module Zaptec
2
+ VERSION = "1.0.0".freeze
3
+ end
data/lib/zaptec.rb ADDED
@@ -0,0 +1,18 @@
1
+ require "faraday"
2
+ require "faraday_middleware"
3
+ require "faraday/detailed_logger"
4
+ require "faraday-cookie_jar"
5
+ require "active_model"
6
+ require "active_support/all"
7
+
8
+ require "zaptec/charger"
9
+ require "zaptec/client"
10
+ require "zaptec/constants"
11
+ require "zaptec/credentials"
12
+ require "zaptec/errors"
13
+ require "zaptec/meter_reading"
14
+ require "zaptec/state"
15
+ require "zaptec/version"
16
+
17
+ module Zaptec
18
+ end
data/zaptec.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ require_relative "lib/zaptec/version"
2
+ ruby_version = File.read(".ruby-version").strip
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = "stekker_zaptec"
6
+ spec.version = Zaptec::VERSION
7
+ spec.authors = ["Team Stekker"]
8
+ spec.email = ["support@stekker.com"]
9
+
10
+ spec.summary = "Connect to your Zaptec charger"
11
+ spec.description = "Zaptec connector"
12
+ spec.homepage = "https://stekker.com"
13
+ spec.required_ruby_version = Gem::Requirement.new(">= #{ruby_version}")
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = "https://github.com/stekker/zaptec"
17
+ spec.metadata["changelog_uri"] = "https://github.com/stekker/zaptec"
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
+ end
24
+ spec.bindir = "exe"
25
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_runtime_dependency "activemodel"
29
+ spec.add_runtime_dependency "activesupport"
30
+ spec.add_runtime_dependency "faraday"
31
+ spec.add_runtime_dependency "faraday-cookie_jar"
32
+ spec.add_runtime_dependency "faraday-detailed_logger"
33
+ spec.add_runtime_dependency "faraday_middleware"
34
+
35
+ spec.metadata["rubygems_mfa_required"] = "true"
36
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stekker_zaptec
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Team Stekker
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-02-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: faraday-cookie_jar
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: faraday-detailed_logger
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: faraday_middleware
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Zaptec connector
98
+ email:
99
+ - support@stekker.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".github/workflows/ruby.yml"
105
+ - ".gitignore"
106
+ - ".rspec"
107
+ - ".rubocop.yml"
108
+ - ".ruby-version"
109
+ - Gemfile
110
+ - Gemfile.lock
111
+ - README.md
112
+ - Rakefile
113
+ - StekkerWeb/gems/zaptec/.gitignore
114
+ - StekkerWeb/gems/zaptec/.rspec
115
+ - StekkerWeb/gems/zaptec/.rubocop.yml
116
+ - StekkerWeb/gems/zaptec/.ruby-version
117
+ - bin/console
118
+ - bin/setup
119
+ - data/constants.json
120
+ - lib/zaptec.rb
121
+ - lib/zaptec/charger.rb
122
+ - lib/zaptec/client.rb
123
+ - lib/zaptec/constants.rb
124
+ - lib/zaptec/credentials.rb
125
+ - lib/zaptec/errors.rb
126
+ - lib/zaptec/meter_reading.rb
127
+ - lib/zaptec/state.rb
128
+ - lib/zaptec/version.rb
129
+ - zaptec.gemspec
130
+ homepage: https://stekker.com
131
+ licenses: []
132
+ metadata:
133
+ homepage_uri: https://stekker.com
134
+ source_code_uri: https://github.com/stekker/zaptec
135
+ changelog_uri: https://github.com/stekker/zaptec
136
+ rubygems_mfa_required: 'true'
137
+ post_install_message:
138
+ rdoc_options: []
139
+ require_paths:
140
+ - lib
141
+ required_ruby_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: 3.0.2
146
+ required_rubygems_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ requirements: []
152
+ rubygems_version: 3.2.22
153
+ signing_key:
154
+ specification_version: 4
155
+ summary: Connect to your Zaptec charger
156
+ test_files: []