ojelectronics 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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: []