ojelectronics 0.0.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4efc56c71825ce41000181e47bc73ba623038635d937247421189b39ba909de4
4
+ data.tar.gz: 9aaa6318abc98289c21a657f82edaea20a65c6e9c695d73bbe28c6ddeb923041
5
+ SHA512:
6
+ metadata.gz: aee4126a865ff834204d5e861da08989cc95388464c33e078a7ce940553496d783ef8a844b88a8471c7e6f5c82959f78570fe48749ebb544c27e6f36abeef9d5
7
+ data.tar.gz: 724a221232dd8a1dcc4a1623f1247920c488901dcfedddb946fc9a7f0ff080e68ca4d795295ab1b3441c82d44fd22ca5052cc57e6bfee817459e641277c2ef8e
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ojelectronics"
5
+ require "homie-mqtt"
6
+ require "optparse"
7
+
8
+ brand = nil
9
+
10
+ options = OptionParser.new do |opts|
11
+ opts.banner = "Usage: oj_electronics_mqtt_bridge USERNAME PASSWORD MQTT_URI [options]"
12
+
13
+ opts.on("--brand=BRAND", "Provide the brand. Either oj_electronics or schluter") do |v|
14
+ brand = v.to_sym
15
+ end
16
+ opts.on("-h", "--help", "Prints this help") do
17
+ puts opts
18
+ exit
19
+ end
20
+ end
21
+
22
+ options.parse!
23
+
24
+ unless ARGV.length == 3
25
+ puts options
26
+ exit 1
27
+ end
28
+
29
+ client = OJElectronics::Client.new(ARGV[0], ARGV[1], brand: brand)
30
+
31
+ homie = MQTT::Homie::Device.new(MQTT::Homie.escape_id(brand&.to_s || "oj_electronics"),
32
+ "OJ Electronics Thermostats",
33
+ mqtt: ARGV[2])
34
+
35
+ client.thermostats.each_value do |t|
36
+ homie.node(t.serial_number, t.room, "Thermostat") do |n|
37
+ n.property("temperature", "Current Temperature", :float, t.temperature, unit: "°C")
38
+ n.property("set-point-temperature",
39
+ "Set Point Temperature",
40
+ :float,
41
+ t.set_point_temperature,
42
+ unit: "°C",
43
+ format: 5..40) do |v|
44
+ t.set_point_temperature = v
45
+ end
46
+ n.property("online", "Online", :boolean, t.online)
47
+ n.property("heating", "Heating", :boolean, t.heating)
48
+ n.property("regulation-mode", "Regulation Mode", :enum, t.regulation_mode,
49
+ format: OJElectronics::Thermostat::REGULATION_MODES)
50
+ end
51
+ end
52
+ homie.publish
53
+
54
+ PROPERTIES = %i[temperature set_point_temperature online heating regulation_mode].freeze
55
+
56
+ loop do
57
+ t = client.long_poll
58
+ next unless t
59
+
60
+ homie.mqtt.batch_publish do
61
+ n = homie[t.serial_number]
62
+ PROPERTIES.each do |prop|
63
+ n[prop.to_s.tr("_", "-")].value = t.send(prop)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/enumerable"
5
+ require "faraday_middleware"
6
+
7
+ module OJElectronics
8
+ class Client
9
+ class AuthenticationError < RuntimeError
10
+ class << self
11
+ def create(error_code)
12
+ klass, message = case error_code
13
+ when 1 then [InvalidUsernameError, "Invalid Username"]
14
+ when 2 then [IncorrectPasswordError, "Incorrect password"]
15
+ else
16
+ [self, nil]
17
+ end
18
+ klass.new(error_code, message)
19
+ end
20
+ end
21
+
22
+ attr_reader :error_code
23
+
24
+ def initialize(error_code, message = nil)
25
+ @error_code = error_code
26
+ super(message || "Unable to authenticate: ErrorCode #{error_code}")
27
+ end
28
+ end
29
+
30
+ class IncorrectPasswordError < AuthenticationError; end
31
+ class InvalidUsernameError < AuthenticationError; end
32
+
33
+ BRANDS = {
34
+ oj_electronics: 0,
35
+ schluter: 8
36
+ }.freeze
37
+
38
+ attr_reader(*%i[username session_id brand expires thermostats])
39
+
40
+ def initialize(username, password, session_id: nil, expires: nil, brand: :oj_electronics)
41
+ raise ArgumentError, "unrecognized brand #{brand.inspect}" unless BRANDS.key?(brand)
42
+
43
+ @brand = brand
44
+ @username = username
45
+ @password = password
46
+ @session_id = session_id
47
+ @expires = expires
48
+ @api = Faraday.new(url: "https://mythermostat.info/api/") do |f|
49
+ f.request :json
50
+ f.request :retry
51
+ f.response :raise_error
52
+ f.response :json
53
+ f.adapter :net_http_persistent
54
+ end
55
+ @thermostats = {}
56
+
57
+ refresh
58
+ end
59
+
60
+ def expired?
61
+ @session_id.nil? || @expires.nil? || @expires < Time.now
62
+ end
63
+
64
+ # refresh all thermostats
65
+ def refresh
66
+ thermostats = api.get("thermostats", sessionid: session_id)
67
+ .body["Groups"]
68
+ .flat_map { |g| g["Thermostats"] }
69
+ .index_by { |t| t["SerialNumber"] }
70
+ missing = @thermostats.keys - thermostats.keys
71
+ missing.each { |sn| @thermostats.delete(sn) }
72
+
73
+ additional = thermostats.keys - @thermostats.keys
74
+ additional.each do |sn|
75
+ @thermostats[sn] = Thermostat.new(self, sn)
76
+ end
77
+
78
+ thermostats.each do |sn, json|
79
+ @thermostats[sn].refresh(json)
80
+ end
81
+ end
82
+
83
+ # returns nil if nothing changed; otherwise the thermostat that changed
84
+ def long_poll
85
+ response = api.get("notification", sessionid: session_id).body
86
+ json = response["Thermostat"]
87
+ return if json.nil?
88
+
89
+ @thermostats[json["SerialNumber"]].tap { |t| t.refresh(json) }
90
+ end
91
+
92
+ # !@visibility private
93
+ def api
94
+ reauth
95
+ @api
96
+ end
97
+
98
+ private
99
+
100
+ def reauth
101
+ return unless expired?
102
+
103
+ auth = @api.post("authenticate/user",
104
+ Email: username,
105
+ Password: @password,
106
+ Application: BRANDS[brand]).body
107
+ raise AuthenticationError.create(auth["ErrorCode"]) unless auth["ErrorCode"] == 0 # rubocop:disable Style/NumericPredicate
108
+
109
+ @session_id = auth["SessionId"]
110
+ @expires = Time.now + 3600
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OJElectronics
4
+ class Thermostat
5
+ REGULATION_MODES = %i[schedule temporary_hold permanent_hold vacation_hold].freeze
6
+ attr_reader(*%i[serial_number temperature set_point_temperature room online heating regulation_mode])
7
+
8
+ def initialize(client, serial_number)
9
+ @client = client
10
+ @serial_number = serial_number
11
+ end
12
+
13
+ def refresh(json)
14
+ @temperature = json["Temperature"].to_f / 100
15
+ @set_point_temperature = json["SetPointTemp"].to_f / 100
16
+ @room = json["Room"]
17
+ @online = json["Online"]
18
+ @heating = json["Heating"]
19
+ @regulation_mode = REGULATION_MODES[json["RegulationMode"] - 1]
20
+ end
21
+
22
+ def set_point_temperature=(value) # rubocop:disable Naming/AccessorMethodName
23
+ @client.api.post("thermostat?sessionid=#{@client.session_id}&serialnumber=#{serial_number}",
24
+ RegulationMode: 3,
25
+ VacationEnabled: false,
26
+ ManualTemperature: (value * 100).to_i)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OJElectronics
4
+ VERSION = "0.0.2"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj_electronics/client"
4
+ require "oj_electronics/thermostat"
5
+
6
+ module OJElectronics
7
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj_electronics"
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ojelectronics
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Cody Cutrer
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-07-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday_middleware
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: homie-mqtt
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.6'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: net-http-persistent
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '4.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '4.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '9.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '9.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.23'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.23'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-performance
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.12'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.12'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rake
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.6'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.6'
139
+ description:
140
+ email: cody@cutrer.com'
141
+ executables:
142
+ - oj_electronics_mqtt_bridge
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - exe/oj_electronics_mqtt_bridge
147
+ - lib/oj_electronics.rb
148
+ - lib/oj_electronics/client.rb
149
+ - lib/oj_electronics/thermostat.rb
150
+ - lib/oj_electronics/version.rb
151
+ - lib/ojelectronics.rb
152
+ homepage: https://github.com/ccutrer/ruby-ojelectronics
153
+ licenses:
154
+ - MIT
155
+ metadata:
156
+ rubygems_mfa_required: 'true'
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '2.5'
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 3.3.5
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: Interact with OJ Electronics/DITRA HEAT floor thermostats via MQTT
176
+ test_files: []