maxcube-client 0.4.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/Gemfile +5 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +35 -0
  6. data/Rakefile +6 -0
  7. data/bin/console +8 -0
  8. data/bin/maxcube-client +31 -0
  9. data/bin/sample_server +13 -0
  10. data/bin/sample_socket +13 -0
  11. data/bin/setup +6 -0
  12. data/data/load/del +6 -0
  13. data/data/load/meta +20 -0
  14. data/data/load/ntp +6 -0
  15. data/data/load/set_temp +13 -0
  16. data/data/load/set_temp_mode +12 -0
  17. data/data/load/set_valve +11 -0
  18. data/data/load/url +4 -0
  19. data/data/load/wake +4 -0
  20. data/lib/maxcube/messages.rb +148 -0
  21. data/lib/maxcube/messages/handler.rb +154 -0
  22. data/lib/maxcube/messages/parser.rb +34 -0
  23. data/lib/maxcube/messages/serializer.rb +59 -0
  24. data/lib/maxcube/messages/tcp.rb +18 -0
  25. data/lib/maxcube/messages/tcp/handler.rb +70 -0
  26. data/lib/maxcube/messages/tcp/parser.rb +46 -0
  27. data/lib/maxcube/messages/tcp/serializer.rb +47 -0
  28. data/lib/maxcube/messages/tcp/type/a.rb +32 -0
  29. data/lib/maxcube/messages/tcp/type/c.rb +248 -0
  30. data/lib/maxcube/messages/tcp/type/f.rb +33 -0
  31. data/lib/maxcube/messages/tcp/type/h.rb +70 -0
  32. data/lib/maxcube/messages/tcp/type/l.rb +131 -0
  33. data/lib/maxcube/messages/tcp/type/m.rb +185 -0
  34. data/lib/maxcube/messages/tcp/type/n.rb +44 -0
  35. data/lib/maxcube/messages/tcp/type/q.rb +18 -0
  36. data/lib/maxcube/messages/tcp/type/s.rb +246 -0
  37. data/lib/maxcube/messages/tcp/type/t.rb +38 -0
  38. data/lib/maxcube/messages/tcp/type/u.rb +19 -0
  39. data/lib/maxcube/messages/tcp/type/z.rb +36 -0
  40. data/lib/maxcube/messages/udp.rb +9 -0
  41. data/lib/maxcube/messages/udp/handler.rb +40 -0
  42. data/lib/maxcube/messages/udp/parser.rb +50 -0
  43. data/lib/maxcube/messages/udp/serializer.rb +30 -0
  44. data/lib/maxcube/messages/udp/type/h.rb +24 -0
  45. data/lib/maxcube/messages/udp/type/i.rb +23 -0
  46. data/lib/maxcube/messages/udp/type/n.rb +21 -0
  47. data/lib/maxcube/network.rb +14 -0
  48. data/lib/maxcube/network/tcp.rb +11 -0
  49. data/lib/maxcube/network/tcp/client.rb +174 -0
  50. data/lib/maxcube/network/tcp/client/commands.rb +286 -0
  51. data/lib/maxcube/network/tcp/sample_server.rb +96 -0
  52. data/lib/maxcube/network/udp.rb +11 -0
  53. data/lib/maxcube/network/udp/client.rb +52 -0
  54. data/lib/maxcube/network/udp/sample_socket.rb +65 -0
  55. data/lib/maxcube/version.rb +4 -0
  56. data/maxcube-client.gemspec +29 -0
  57. metadata +155 -0
@@ -0,0 +1,34 @@
1
+ require_relative 'handler'
2
+
3
+ module MaxCube
4
+ module Messages
5
+ module Parser
6
+ include Handler
7
+
8
+ def read(count = 0, unpack = false)
9
+ str = if count.zero?
10
+ @io.read
11
+ else
12
+ raise IOError if @io.size - @io.pos < count
13
+ @io.read(count)
14
+ end
15
+ return str unless unpack
16
+ str = "\x00".b + str if count == 3
17
+ unpack = PACK_FORMAT[count] unless unpack.is_a?(String)
18
+ str.unpack1(unpack)
19
+ end
20
+
21
+ def parse_msg_body(body, hash, parser_type_str)
22
+ method_str = "parse_#{parser_type_str}_#{@msg_type.downcase}"
23
+ if respond_to?(method_str, true)
24
+ return hash.merge!(send(method_str, body))
25
+ end
26
+ hash[:data] = body
27
+ nil
28
+ rescue IOError
29
+ raise InvalidMessageBody
30
+ .new(@msg_type, 'unexpected EOF reached')
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ require_relative 'handler'
2
+
3
+ module MaxCube
4
+ module Messages
5
+ module Serializer
6
+ include Handler
7
+
8
+ def serialize(*args, esize: 0, size: 0, ocount: 0)
9
+ return args.join if size.zero? && esize.zero?
10
+
11
+ ocount, subcount, subsize = serialize_bounds(args,
12
+ esize: esize,
13
+ size: size,
14
+ ocount: ocount)
15
+ str = ''
16
+ args.reverse!
17
+ ocount.times do
18
+ str << args.pop while args.last.is_a?(String)
19
+ substr = args.pop(subcount).pack(PACK_FORMAT[subsize])
20
+ substr = substr[1..-1] if subsize == 3
21
+ str << substr
22
+ end
23
+ str << args.pop until args.empty?
24
+
25
+ str
26
+ end
27
+
28
+ def write(*args, esize: 0, size: 0, ocount: 0)
29
+ @io.write(serialize(*args, esize: esize, size: size, ocount: ocount))
30
+ end
31
+
32
+ def serialize_hash_body(hash, serializer_type_str)
33
+ method_str = "serialize_#{serializer_type_str}_#{@msg_type.downcase}"
34
+ return send(method_str, hash) if respond_to?(method_str, true)
35
+ raise InvalidMessageType
36
+ .new(@msg_type, 'serialization of message type' \
37
+ ' is not implemented (yet)')
38
+ end
39
+
40
+ private
41
+
42
+ def serialize_bounds(args, esize: 0, size: 0, ocount: 0)
43
+ icount = args.size - args.count { |a| a.is_a?(String) }
44
+ return 0 if icount.zero?
45
+ if esize.zero?
46
+ ocount = icount if ocount.zero?
47
+ subsize = size / ocount
48
+ else
49
+ size = icount * esize
50
+ ocount = size / esize
51
+ subsize = esize
52
+ end
53
+ subcount = icount / ocount
54
+
55
+ [ocount, subcount, subsize]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ require 'maxcube/messages'
2
+
3
+ module MaxCube
4
+ module Messages
5
+ # Structure of message:
6
+ # * Starts with single letter followed by ':'
7
+ # * Ends with "\r\n"
8
+ # Example (unencoded):
9
+ # X:message\r\n
10
+ # As all messages are being split by "\r\n",
11
+ # it does not occur in single message processing,
12
+ # only in raw data processing.
13
+ module TCP
14
+ # Without "\r\n", with it it is 1900
15
+ MSG_MAX_LEN = 1898
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,70 @@
1
+ require 'maxcube/messages/tcp'
2
+ require 'maxcube/messages/handler'
3
+
4
+ module MaxCube
5
+ module Messages
6
+ module TCP
7
+ module Handler
8
+ include Messages::Handler
9
+
10
+ def valid_tcp_msg_length(msg)
11
+ msg.length.between?(2, MSG_MAX_LEN)
12
+ end
13
+
14
+ def check_tcp_msg_length(msg)
15
+ raise InvalidMessageLength unless valid_tcp_msg_length(msg)
16
+ msg.length
17
+ end
18
+
19
+ def valid_tcp_msg_format(msg)
20
+ msg =~ /\A[[:alpha:]]:[[:print:]]*\z/
21
+ end
22
+
23
+ def check_tcp_msg_format(msg)
24
+ raise InvalidMessageFormat unless valid_tcp_msg_format(msg)
25
+ msg
26
+ end
27
+
28
+ # Check single message validity, already without "\r\n" at the end
29
+ def valid_tcp_msg(msg)
30
+ valid_tcp_msg_length(msg) &&
31
+ valid_tcp_msg_format(msg) &&
32
+ valid_msg(msg)
33
+ end
34
+
35
+ def check_tcp_msg(msg)
36
+ check_tcp_msg_length(msg)
37
+ check_tcp_msg_format(msg)
38
+ check_msg(msg)
39
+ msg
40
+ end
41
+
42
+ def valid_tcp_hash(hash)
43
+ valid_hash(hash)
44
+ end
45
+
46
+ def check_tcp_hash(hash)
47
+ check_hash(hash)
48
+ hash
49
+ end
50
+
51
+ def valid_tcp_data(raw_data)
52
+ return true if raw_data.empty?
53
+ raw_data[0..1] != "\r\n" && raw_data.chars.last(2).join == "\r\n"
54
+ end
55
+
56
+ def check_tcp_data(raw_data)
57
+ # check_data_type(raw_data)
58
+ raise InvalidMessageFormat unless valid_tcp_data(raw_data)
59
+ raw_data
60
+ end
61
+
62
+ private
63
+
64
+ def msg_msg_type(msg)
65
+ msg.chr
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,46 @@
1
+ require_relative 'handler'
2
+ require 'maxcube/messages/parser'
3
+
4
+ module MaxCube
5
+ module Messages
6
+ module TCP
7
+ class Parser
8
+ include Handler
9
+ include Messages::Parser
10
+
11
+ %w[a c f h l m n s].each { |f| require_relative 'type/' << f }
12
+
13
+ MSG_TYPES = %w[H F L C M N A E D b g j p o v w S].freeze
14
+
15
+ include MessageA
16
+ include MessageC
17
+ include MessageF
18
+ include MessageH
19
+ include MessageL
20
+ include MessageM
21
+ include MessageN
22
+ include MessageS
23
+
24
+ # Process set of messages - raw data separated by "\r\n"
25
+ # @param [String, #read] raw data from a Cube
26
+ # @return [Array<Hash>] particular message contents
27
+ def parse_tcp_data(raw_data)
28
+ check_tcp_data(raw_data)
29
+ raw_data.split("\r\n").map(&method(:parse_tcp_msg))
30
+ end
31
+
32
+ # Parse single message already without "\r\n"
33
+ # @param [String, #read] single message data without "\r\n"
34
+ # @return [Hash] particular message parts separated into hash,
35
+ # which should be human readable
36
+ def parse_tcp_msg(msg)
37
+ check_tcp_msg(msg)
38
+ body = msg.split(':')[1] || ''
39
+ hash = { type: @msg_type }
40
+ return hash unless parse_msg_body(body, hash, 'tcp')
41
+ check_tcp_hash(hash)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ require_relative 'handler'
2
+ require 'maxcube/messages/serializer'
3
+
4
+ module MaxCube
5
+ module Messages
6
+ module TCP
7
+ class Serializer
8
+ include Handler
9
+ include Messages::Serializer
10
+
11
+ %w[a c f l m n q s t u z].each { |f| require_relative 'type/' << f }
12
+
13
+ MSG_TYPES = %w[u i s m n x g q e d B G J P O V W a r t l c v f z].freeze
14
+
15
+ include MessageA
16
+ include MessageC
17
+ include MessageF
18
+ include MessageL
19
+ include MessageM
20
+ include MessageN
21
+ include MessageQ
22
+ include MessageS
23
+ include MessageT
24
+ include MessageU
25
+ include MessageZ
26
+
27
+ # Send set of messages separated by "\r\n"
28
+ # @param [Array<Hash>] particular message contents
29
+ # @return [String] raw data for a Cube
30
+ def serialize_tcp_hashes(hashes)
31
+ raw_data = hashes.map(&method(:serialize_tcp_hash)).join
32
+ check_tcp_data(raw_data)
33
+ end
34
+
35
+ # Serialize data from hash into message with "\r\n" at the end
36
+ # @param [Hash, #read] particular human readable message parts
37
+ # (it is assumed to contain valid data)
38
+ # @return [String] single message data with "\r\n" at the end
39
+ def serialize_tcp_hash(hash)
40
+ check_tcp_hash(hash)
41
+ msg = "#{@msg_type}:" << serialize_hash_body(hash, 'tcp')
42
+ check_tcp_msg(msg) << "\r\n"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageA
7
+ private
8
+
9
+ # Acknowledgement message to previous command
10
+ # e.g. factory reset (a), delete a device (t), wake up (z)
11
+ # Ignore all contents of the message
12
+ def parse_tcp_a(_body)
13
+ {}
14
+ end
15
+ end
16
+ end
17
+
18
+ class Serializer
19
+ module MessageA
20
+ private
21
+
22
+ # Factory reset command
23
+ # Does not contain any data
24
+ # Acknowledgement (A) follows
25
+ def serialize_tcp_a(_hash)
26
+ ''
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,248 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageC
7
+ private
8
+
9
+ KEYS = %i[length address rf_address device_type
10
+ test_result serial_number].freeze
11
+ OPT_KEYS = %i[
12
+ firmware_version _firmware_version room_id
13
+
14
+ portal_enabled button_up_mode button_down_mode portal_url
15
+
16
+ comfort_temperature eco_temperature
17
+ max_setpoint_temperature min_setpoint_temperature
18
+ temperature_offset window_open_temperature window_open_duration
19
+ boost_duration valve_opening
20
+ decalcification_day decalcification_hour
21
+ max_valve_setting valve_offset
22
+
23
+ unknown unknown1 unknown2 unknown3 unknown4 weekly_program
24
+ ].freeze
25
+
26
+ LENGTHS = [6].freeze
27
+
28
+ # Configuration message
29
+ def parse_tcp_c(body)
30
+ addr, enc_data = parse_tcp_c_split(body)
31
+
32
+ @io = StringIO.new(decode(enc_data), 'rb')
33
+
34
+ hash = parse_tcp_c_head(addr)
35
+ parse_tcp_c_device_type(hash)
36
+
37
+ hash
38
+ end
39
+
40
+ ########################
41
+
42
+ def parse_tcp_c_split(body)
43
+ addr, enc_data = body.split(',')
44
+ check_msg_part_lengths(LENGTHS, addr)
45
+ to_ints(16, 'device address', addr)
46
+ [addr, enc_data]
47
+ end
48
+
49
+ def parse_tcp_c_head(addr)
50
+ @length = read(1, true)
51
+ # 'rf_address' should correspond with 'addr',
52
+ # but it is not checked (yet)
53
+ rf_address = read(3, true)
54
+ device_type = device_type(read(1, true))
55
+ hash = {
56
+ address: addr,
57
+ length: @length,
58
+ rf_address: rf_address,
59
+ device_type: device_type,
60
+ }
61
+
62
+ if device_type == :cube
63
+ # For 'cube' type, both fiels seem to be combined
64
+ # into 'firmware_version' string
65
+ room_id__fw_v = read(2, 'H*')
66
+ hash[:firmware_version] = room_id__fw_v[2..3] +
67
+ room_id__fw_v[0..1]
68
+ else
69
+ # For other types, both 'room_id' and 'firmware_version'
70
+ # are unpacked as numbers
71
+ # How should be 'firmware_version' interpreted ?
72
+ hash[:room_id] = read(1, true)
73
+ hash[:_firmware_version] = read(1, true)
74
+ end
75
+
76
+ hash.merge!(
77
+ test_result: read(1, true),
78
+ serial_number: read(10),
79
+ )
80
+ rescue IOError
81
+ raise InvalidMessageBody
82
+ .new(@msg_type,
83
+ 'unexpected EOF reached at head of decoded message data')
84
+ end
85
+
86
+ def parse_tcp_c_cube_button_mode_temp(value, base)
87
+ (value - base).to_f / 2 + 4.5
88
+ end
89
+
90
+ def parse_tcp_c_cube_button_mode(hash, up, down)
91
+ { 'up' => up, 'down' => down }.each do |k, v|
92
+ mode_key = "button_#{k}_mode".to_sym
93
+ case v
94
+ when 0x00
95
+ hash[mode_key] = :auto
96
+ when 0x41
97
+ hash[mode_key] = :eco
98
+ when 0x42
99
+ hash[mode_key] = :comfort
100
+ else
101
+ temp_key = "button_#{k}_temperature".to_sym
102
+ if v.between?(0x09, 0x3d)
103
+ hash[mode_key] = :auto_temp
104
+ hash[temp_key] = parse_tcp_c_cube_button_mode_temp(v, 0x09)
105
+ elsif v.between?(0x49, 0x7d)
106
+ hash[mode_key] = :manual
107
+ hash[temp_key] = parse_tcp_c_cube_button_mode_temp(v, 0x49)
108
+ else
109
+ hash[mode_key] = :unknown
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ def parse_tcp_c_cube
116
+ hash = {
117
+ portal_enabled: !read(1, true).zero?,
118
+ unknown1: read(11),
119
+ }
120
+
121
+ pushbutton_up_config = read(1, true)
122
+ hash[:unknown2] = read(32)
123
+ pushbutton_down_config = read(1, true)
124
+ parse_tcp_c_cube_button_mode(hash,
125
+ pushbutton_up_config,
126
+ pushbutton_down_config)
127
+
128
+ # ! Exact decoding of time zones is not clear yet
129
+ hash.merge!(
130
+ unknown3: read(21),
131
+ portal_url: read(128),
132
+ # _timezone_winter: read(5),
133
+ # timezone_winter_month: read(1, true),
134
+ # timezone_winter_day: DAYS_OF_WEEK[read(1, true)],
135
+ # timezone_winter_hour: read(1, true),
136
+ # _timezone_winter_offset: read(4),
137
+ # _timezone_daylight: read(5),
138
+ # timezone_daylight_month: read(1, true),
139
+ # timezone_daylight_day: DAYS_OF_WEEK[read(1, true)],
140
+ # timezone_daylight_hour: read(1, true),
141
+ # _timezone_daylight_offset: read(4),
142
+ # unknown4: read(1),
143
+ unknown4: read,
144
+ )
145
+ end
146
+
147
+ def parse_tcp_c_thermostat_1
148
+ {
149
+ comfort_temperature: read(1, true).to_f / 2,
150
+ eco_temperature: read(1, true).to_f / 2,
151
+ max_setpoint_temperature: read(1, true).to_f / 2,
152
+ min_setpoint_temperature: read(1, true).to_f / 2,
153
+ }
154
+ end
155
+
156
+ def parse_tcp_c_program(subhash)
157
+ program = DAYS_OF_WEEK.zip([]).to_h
158
+ program.each_key do |day|
159
+ setpoints = []
160
+ 13.times do
161
+ setpoint = read(2, true)
162
+ temperature = ((setpoint & 0xfe00) >> 9).to_f / 2
163
+ time_until = (setpoint & 0x01ff) * 5
164
+ setpoints << {
165
+ temperature: temperature,
166
+ hours_until: time_until / 60,
167
+ minutes_until: time_until % 60,
168
+ }
169
+ end
170
+ program[day] = setpoints
171
+ end
172
+ subhash[:weekly_program] = program
173
+ end
174
+
175
+ def parse_tcp_c_radiator
176
+ subhash = parse_tcp_c_thermostat_1.merge!(
177
+ temperature_offset: read(1, true).to_f / 2 - 3.5,
178
+ window_open_temperature: read(1, true).to_f / 2,
179
+ window_open_duration: read(1, true) * 5,
180
+ )
181
+
182
+ boost = read(1, true)
183
+ boost_duration = ((boost & 0xe0) >> 5) * 5
184
+ boost_duration = 60 if boost_duration > 30
185
+
186
+ decalcification = read(1, true)
187
+
188
+ subhash.merge!(
189
+ boost_duration: boost_duration,
190
+ valve_opening: (boost & 0x1f) * 5,
191
+ decalcification_day: day_of_week((decalcification & 0xe0) >> 5),
192
+ decalcification_hour: decalcification & 0x1f,
193
+ max_valve_setting: read(1, true) * (100.0 / 255),
194
+ valve_offset: read(1, true) * (100.0 / 255),
195
+ )
196
+
197
+ parse_tcp_c_program(subhash)
198
+
199
+ subhash
200
+ end
201
+
202
+ def parse_tcp_c_wall
203
+ subhash = parse_tcp_c_thermostat_1
204
+ parse_tcp_c_program(subhash)
205
+ subhash[:unknown] = read(3)
206
+
207
+ subhash
208
+ end
209
+
210
+ def parse_tcp_c_device_type(hash)
211
+ device_type = hash[:device_type]
212
+ hash.merge!(
213
+ case device_type
214
+ when :cube
215
+ parse_tcp_c_cube
216
+ when :radiator_thermostat, :radiator_thermostat_plus
217
+ parse_tcp_c_radiator
218
+ when :wall_thermostat
219
+ parse_tcp_c_wall
220
+ else
221
+ {}
222
+ end
223
+ )
224
+ rescue IOError
225
+ device_type_str = device_type.to_s.split('_')
226
+ .map(&:capitalize).join(' ')
227
+ raise InvalidMessageBody
228
+ .new(@msg_type,
229
+ 'unexpected EOF reached in decoded message data of ' \
230
+ "'#{device_type_str}' device type")
231
+ end
232
+ end
233
+ end
234
+
235
+ class Serializer
236
+ module MessageC
237
+ private
238
+
239
+ # Request for configuration message (C)
240
+ # Does not contain any data
241
+ def serialize_tcp_c(_hash)
242
+ ''
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end