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,33 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageF
7
+ private
8
+
9
+ KEYS = %i[ntp_servers].freeze
10
+
11
+ # NTP server message
12
+ def parse_tcp_f(body)
13
+ { ntp_servers: body.split(',') }
14
+ end
15
+ end
16
+ end
17
+
18
+ class Serializer
19
+ module MessageF
20
+ private
21
+
22
+ OPT_KEYS = %i[ntp_servers].freeze
23
+
24
+ # Request for NTP servers message (F)
25
+ # Optionally, updates can be done
26
+ def serialize_tcp_f(hash)
27
+ hash.key?(:ntp_servers) ? hash[:ntp_servers].join(',') : ''
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageH
7
+ private
8
+
9
+ LENGTHS = [10, 6, 4, 8, 8, 2, 2, 6, 4, 2, 4].freeze
10
+
11
+ KEYS = %i[
12
+ serial_number
13
+ rf_address
14
+ firmware_version
15
+ unknown
16
+ http_id
17
+ duty_cycle
18
+ free_memory_slots
19
+ cube_datetime
20
+ state_cube_time
21
+ ntp_counter
22
+ ].freeze
23
+
24
+ # Hello message
25
+ def parse_tcp_h(body)
26
+ values = body.split(',')
27
+ check_msg_part_lengths(LENGTHS, *values)
28
+ values[1], _, values[4], values[5], values[6], _, _,
29
+ values[9], values[10] =
30
+ to_ints(16, 'RF address, ' \
31
+ 'firmware version, ' \
32
+ 'HTTP connection ID, ' \
33
+ 'duty cycle, ' \
34
+ 'free memory slots, ' \
35
+ 'Cube date, ' \
36
+ 'Cube time, ' \
37
+ 'state Cube time (clock set), ' \
38
+ 'NTP counter',
39
+ values[1], values[2], values[4], values[5], values[6],
40
+ values[7], values[8], values[9], values[10])
41
+
42
+ parse_tcp_h_cube_datetime(values)
43
+
44
+ KEYS.zip(values).to_h
45
+ end
46
+
47
+ ########################
48
+
49
+ def parse_tcp_h_cube_datetime(values)
50
+ date, time = values[7..8]
51
+ year = 2000 + date[0..1].to_i(16)
52
+
53
+ month = date[2..3].to_i(16)
54
+ day = date[4..5].to_i(16)
55
+ hours = time[0..1].to_i(16)
56
+ minutes = time[2..3].to_i(16)
57
+
58
+ values[7] = DateTime.new(year, month, day, hours, minutes)
59
+ values.delete_at(8)
60
+ rescue ArgumentError
61
+ raise InvalidMessageBody
62
+ .new(@msg_type, 'invalid datetime format (YYMMDD HHMM): ' \
63
+ "#{date} #{time} " \
64
+ "-> #{year}-#{month}-#{day} #{hours}:#{minutes}")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,131 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageL
7
+ private
8
+
9
+ LENGTHS = [6, 11, 12].freeze
10
+
11
+ KEYS = %i[devices].freeze
12
+
13
+ # Device list message
14
+ def parse_tcp_l(body)
15
+ @io = StringIO.new(decode(body), 'rb')
16
+
17
+ hash = { devices: [] }
18
+ until @io.eof?
19
+ subhash = parse_tcp_l_submsg_1
20
+
21
+ temperature_msb = parse_tcp_l_submsg_2(subhash) if @length > 6
22
+ parse_tcp_l_submsg_3(subhash, temperature_msb) if @length > 11
23
+
24
+ hash[:devices] << subhash
25
+ end # until
26
+
27
+ hash
28
+ end
29
+
30
+ ########################
31
+
32
+ def parse_tcp_l_submsg_1
33
+ @length = read(1, true)
34
+ unless LENGTHS.include?(@length)
35
+ raise InvalidMessageBody
36
+ .new(@msg_type, "invalid length of submessage (#{@length}):" \
37
+ " should be in #{LENGTHS}")
38
+ end
39
+ subhash = {
40
+ length: @length,
41
+ rf_address: read(3, true),
42
+ unknown: read(1),
43
+ }
44
+ flags = read(2, true)
45
+ @mode = device_mode(flags & 0x3)
46
+ subhash[:flags] = {
47
+ value: flags,
48
+ mode: @mode,
49
+ dst_setting_active: !((flags & 0x8) >> 3).zero?,
50
+ gateway_known: !((flags & 0x10) >> 4).zero?,
51
+ panel_locked: !((flags & 0x20) >> 5).zero?,
52
+ link_error: !((flags & 0x40) >> 6).zero?,
53
+ low_battery: !((flags & 0x80) >> 7).zero?,
54
+ status_initialized: !((flags & 0x200) >> 9).zero?,
55
+ is_answer: !((flags & 0x400) >> 10).zero?,
56
+ error: !((flags & 0x800) >> 11).zero?,
57
+ valid_info: !((flags & 0x1000) >> 12).zero?,
58
+ }
59
+
60
+ subhash
61
+ rescue IOError
62
+ raise InvalidMessageBody
63
+ .new(@msg_type, 'unexpected EOF reached at submessage 1st part')
64
+ end
65
+
66
+ def parse_tcp_l_submsg_2(subhash)
67
+ subhash[:valve_opening] = read(1, true)
68
+
69
+ temperature = read(1, true)
70
+ # This bit may be used later
71
+ temperature_msb = temperature >> 7
72
+ subhash[:temperature] = (temperature & 0x3f).to_f / 2
73
+
74
+ date_until = read(2, true)
75
+ year = (date_until & 0x1f) + 2000
76
+ month = ((date_until & 0x80) >> 7) | ((date_until & 0xe000) >> 12)
77
+ day = (date_until & 0x1f00) >> 8
78
+
79
+ time_until = read(1, true)
80
+ hours = time_until / 2
81
+ minutes = (time_until % 2) * 30
82
+ # Sometimes when device is in 'auto' mode,
83
+ # this field can contain 'actual_temperature' instead
84
+ # (but never if it is already contained in next byte)
85
+ # !It seems that 'datetime' is used for 'vacation' mode,
86
+ # but it is not sure ...
87
+ begin
88
+ subhash[:datetime_until] = DateTime.new(year, month, day,
89
+ hours, minutes)
90
+ rescue ArgumentError
91
+ if @mode != :auto || @length > 11
92
+ raise InvalidMessageBody
93
+ .new(@msg_type, "unrecognized message part: #{date_until}" \
94
+ " (it does not seem to be 'date until'" \
95
+ " nor 'actual temperature')")
96
+ end
97
+ subhash[:actual_temperature] = date_until.to_f / 10
98
+ end
99
+
100
+ temperature_msb
101
+ rescue IOError
102
+ raise InvalidMessageBody
103
+ .new(@msg_type,
104
+ 'unexpected EOF reached at submessage 2nd part')
105
+ end
106
+
107
+ def parse_tcp_l_submsg_3(subhash, temperature_msb)
108
+ subhash[:actual_temperature] = ((temperature_msb << 8) |
109
+ read(1, true)).to_f / 10
110
+ rescue IOError
111
+ raise InvalidMessageBody
112
+ .new(@msg_type,
113
+ 'unexpected EOF reached at submessage 3rd part')
114
+ end
115
+ end
116
+ end
117
+
118
+ class Serializer
119
+ module MessageL
120
+ private
121
+
122
+ # Command to resend device list (L)
123
+ # Does not contain any data
124
+ def serialize_tcp_l(_hash)
125
+ ''
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,185 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageM
7
+ private
8
+
9
+ LENGTHS = [2, 2].freeze
10
+
11
+ KEYS = %i[index count unknown1 unknown2
12
+ rooms_count rooms devices_count devices].freeze
13
+
14
+ # Metadata message
15
+ def parse_tcp_m(body)
16
+ index, count, enc_data = parse_tcp_m_split(body)
17
+
18
+ @io = StringIO.new(decode(enc_data), 'rb')
19
+
20
+ hash = { index: index, count: count, unknown1: read(2), }
21
+ parse_tcp_m_rooms(hash)
22
+ parse_tcp_m_devices(hash)
23
+ hash[:unknown2] = read(1)
24
+
25
+ hash
26
+ rescue IOError
27
+ raise InvalidMessageBody
28
+ .new(@msg_type,
29
+ 'unexpected EOF reached at unknown parts' \
30
+ ' of decoded message data')
31
+ end
32
+
33
+ ########################
34
+
35
+ def parse_tcp_m_split(body)
36
+ index, count, enc_data = body.split(',')
37
+ check_msg_part_lengths(LENGTHS, index, count)
38
+ index, count = to_ints(16, 'message index, count',
39
+ index, count)
40
+ unless index < count
41
+ raise InvalidMessageBody
42
+ .new(@msg_type,
43
+ "index >= count: #{index} >= #{count}")
44
+ end
45
+
46
+ unless enc_data
47
+ raise InvalidMessageBody
48
+ .new(@msg_type, 'message data is missing')
49
+ end
50
+
51
+ [index, count, enc_data]
52
+ end
53
+
54
+ def parse_tcp_m_rooms(hash)
55
+ rooms_count = read(1, true)
56
+ hash[:rooms_count] = rooms_count
57
+ hash[:rooms] = []
58
+ rooms_count.times do
59
+ room_id = read(1, true)
60
+ room_name_length = read(1, true)
61
+ room = {
62
+ id: room_id,
63
+ name_length: room_name_length,
64
+ name: read(room_name_length),
65
+ rf_address: read(3, true)
66
+ }
67
+
68
+ # hash[:rooms][room_id] = room
69
+ hash[:rooms] << room
70
+ end
71
+ rescue IOError
72
+ raise InvalidMessageBody
73
+ .new(@msg_type,
74
+ 'unexpected EOF reached at rooms data part' \
75
+ ' of decoded message data')
76
+ end
77
+
78
+ def parse_tcp_m_devices(hash)
79
+ devices_count = read(1, true)
80
+ hash[:devices_count] = devices_count
81
+ hash[:devices] = []
82
+ devices_count.times do
83
+ device = {
84
+ type: device_type(read(1, true)),
85
+ rf_address: read(3, true),
86
+ serial_number: read(10),
87
+ }
88
+ device_name_length = read(1, true)
89
+ device.merge!(
90
+ name_length: device_name_length,
91
+ name: read(device_name_length),
92
+ room_id: read(1, true),
93
+ )
94
+
95
+ hash[:devices] << device
96
+ end
97
+ rescue IOError
98
+ raise InvalidMessageBody
99
+ .new(@msg_type,
100
+ 'unexpected EOF reached at devices data part' \
101
+ ' of decoded message data')
102
+ end
103
+ end
104
+ end
105
+
106
+ class Serializer
107
+ module MessageM
108
+ private
109
+
110
+ KEYS = %i[rooms_count rooms devices_count devices].freeze
111
+ OPT_KEYS = %i[index unknown1 unknown2].freeze
112
+
113
+ # Serialize metadata for Cube
114
+ # Message body has the same format as response (M)
115
+ # -> reverse operations
116
+ # ! I couldn't verify the assumption that bodies should be the same
117
+ # ! Cube does not check data format,
118
+ # so things could break if invalid data is sent
119
+ def serialize_tcp_m(hash)
120
+ index = hash.key?(:index) ? to_int(0, 'index', hash[:index]) : 0
121
+ head = format('%02x,', index)
122
+
123
+ @io = StringIO.new('', 'wb')
124
+ write(hash.key?(:unknown1) ? hash[:unknown1] : "\x00\x00")
125
+
126
+ serialize_tcp_m_rooms(hash)
127
+ serialize_tcp_m_devices(hash)
128
+ write(hash.key?(:unknown2) ? hash[:unknown2] : "\x00")
129
+
130
+ head.b << encode(@io.string)
131
+ end
132
+
133
+ ########################
134
+
135
+ def serialize_tcp_m_rooms(hash)
136
+ write(to_int(0, 'rooms count', hash[:rooms_count]), esize: 1)
137
+ hash[:rooms].each do |room|
138
+ name = room[:name]
139
+ if room.key?(:name_length)
140
+ name_length = to_int(0, 'name length', room[:name_length])
141
+ unless name_length == name.length
142
+ raise InvalidMessageBody
143
+ .new(@msg_type, 'room name length and length of name' \
144
+ " mismatch: #{name_length} != #{name.length}")
145
+ end
146
+ else
147
+ name_length = name.length
148
+ end
149
+
150
+ id, rf_address = to_ints(0, 'room id, RF address',
151
+ room[:id], room[:rf_address])
152
+ write(serialize(id, name_length, name, esize: 1) <<
153
+ serialize(rf_address, esize: 3))
154
+ end
155
+ end
156
+
157
+ def serialize_tcp_m_devices(hash)
158
+ write(to_int(0, 'devices count', hash[:devices_count]), esize: 1)
159
+ hash[:devices].each do |device|
160
+ name = device[:name]
161
+ if device.key?(:name_length)
162
+ name_length = to_int(0, 'name length', device[:name_length])
163
+ unless name_length == name.length
164
+ raise InvalidMessageBody
165
+ .new(@msg_type, 'device name length and length of name' \
166
+ " mismatch: #{name_length} != #{name.length}")
167
+ end
168
+ else
169
+ name_length = name.length
170
+ end
171
+
172
+ rf_address, room_id =
173
+ to_ints(0, 'device RF address, room ID',
174
+ device[:rf_address], device[:room_id])
175
+ write(serialize(device_type_id(device[:type]), esize: 1) <<
176
+ serialize(rf_address, esize: 3) <<
177
+ serialize(device[:serial_number],
178
+ name_length, name, room_id, esize: 1))
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,44 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module TCP
5
+ class Parser
6
+ module MessageN
7
+ private
8
+
9
+ KEYS = %i[device_type rf_address serial_number unknown].freeze
10
+
11
+ # New device (pairing) message
12
+ def parse_tcp_n(body)
13
+ @io = StringIO.new(decode(body), 'rb')
14
+
15
+ {
16
+ device_type: device_type(read(1, true)),
17
+ rf_address: read(3, true),
18
+ serial_number: read(10),
19
+ unknown: read(1),
20
+ }
21
+ rescue IOError
22
+ raise InvalidMessageBody
23
+ .new(@msg_type, 'unexpected EOF reached')
24
+ end
25
+ end
26
+ end
27
+
28
+ class Serializer
29
+ module MessageN
30
+ private
31
+
32
+ OPT_KEYS = %i[timeout].freeze
33
+
34
+ # Command to set the Cube into pairing mode
35
+ # with optional +timeout+ in seconds
36
+ def serialize_tcp_n(hash)
37
+ return '' unless hash.key?(:timeout)
38
+ format('%04x', to_int(0, 'timeout', hash[:timeout]))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end