somfy_sdn 1.0.4 → 1.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 339d400e53c19eeaa513328963a29c4abedb1b4f046d096a478b4f07576224fb
4
- data.tar.gz: 806e109d0934740ceebfc5ea51112b91073b9782d5fbb3267aac49808f98690a
3
+ metadata.gz: c1f060c34defbdfc40cae2d9e3f34535aa31d44d45bbcbb00ff769fc29cbb01c
4
+ data.tar.gz: a83d074584f5142609c9b2752fbefbeddb7d5247f7a4a6a2c26fa80d3f679c68
5
5
  SHA512:
6
- metadata.gz: a2b29a899d358c26a2640e359263785354f9eaff371ec5738d4cb78f58352d6e58fc91c24dca7f4445137eb2739e583cd3901ebda81a161f1d49b223e44617ff
7
- data.tar.gz: 5bc0d2fee7de84b8d69a6396f03ae4f4d61248b9c109c08f3e7ba1d3e35bc2d0a93e46178ee5c7963efdf6360da43969c9334e83b70ae453abe392743145a5c7
6
+ metadata.gz: 39226a05926b458bffb5f91ba69dbcd06d19a1d6daccf98d47c4caf2908e3d991afe5b0ee1917a4dcb0cc4d96fb03056cc9b4f017408666d7c93b32528f10e08
7
+ data.tar.gz: 2b80671284b192bc6eba54c98bf615e41dfe7990be3d343127f14a5cd8c222b7a45840c52c1de8a208d8b9468bdfd3bafd8acdd3f829198a9c2321ea6485b3b3
data/lib/sdn/message.rb CHANGED
@@ -5,79 +5,44 @@ module SDN
5
5
 
6
6
  class Message
7
7
  class << self
8
- def readpartial(io, length, allow_empty: true)
9
- data = []
10
- while data.length < length
11
- begin
12
- data.concat(io.read_nonblock(length - data.length).bytes)
13
- rescue EOFError
14
- break
15
- rescue IO::WaitReadable
16
- break if allow_empty
17
- IO.select([io])
18
- end
8
+ def parse(data)
9
+ offset = -1
10
+ msg = length = ack_requested = message_class = nil
11
+ # we loop here scanning for a valid message
12
+ loop do
13
+ offset += 1
14
+ return nil if data.length - offset < 11
15
+ msg = to_number(data[offset])
16
+ length = to_number(data[offset + 1])
17
+ ack_requested = length & 0x80 == 0x80
18
+ length &= 0x7f
19
+ # impossible message
20
+ next if length < 11 || length > 32
21
+ # don't have enough data for what this message wants;
22
+ # it could be garbage on the line so keep scanning
23
+ next if length > data.length - offset
24
+
25
+ message_class = constants.find { |c| (const_get(c, false).const_get(:MSG, false) rescue nil) == msg }
26
+ message_class = const_get(message_class, false) if message_class
27
+ message_class ||= UnknownMessage
28
+
29
+ calculated_sum = checksum(data.slice(offset, length - 2))
30
+ read_sum = data.slice(offset + length - 2, 2)
31
+ next unless read_sum == calculated_sum
32
+
33
+ break
19
34
  end
20
- data
21
- end
22
-
23
- def parse(io)
24
- io = StringIO.new(io) if io.is_a?(String)
25
- data = readpartial(io, 2, allow_empty: false)
26
- if data.length != 2
27
- # don't have enough data yet; buffer it
28
- io.ungetbyte(data.first) if data.length == 1
29
- raise MalformedMessage, "Could not get message type and length"
30
- end
31
- msg = to_number(data.first)
32
- length = to_number(data.last)
33
- ack_requested = length & 0x80 == 0x80
34
- length &= 0x7f
35
- if length < 11 || length > 32
36
- # only skip over one byte to try and re-sync
37
- io.ungetbyte(data.last)
38
- raise MalformedMessage, "Message has bogus length: #{length}"
39
- end
40
- data.concat(readpartial(io, length - 4))
41
- unless data.length == length - 2
42
- data.reverse.each { |byte| io.ungetbyte(byte) }
43
- raise MalformedMessage, "Missing data: got #{data.length} expected #{length}"
44
- end
45
-
46
- message_class = constants.find { |c| (const_get(c, false).const_get(:MSG, false) rescue nil) == msg }
47
- message_class = const_get(message_class, false) if message_class
48
- message_class ||= UnknownMessage
49
-
50
- bogus_checksums = [SetNodeLabel::MSG, PostNodeLabel::MSG].include?(msg)
51
-
52
- calculated_sum = checksum(data)
53
- read_sum = readpartial(io, 2)
54
- if read_sum.length == 0 || (!bogus_checksums && read_sum.length == 1)
55
- read_sum.each { |byte| io.ungetbyte(byte) }
56
- data.reverse.each { |byte| io.ungetbyte(byte) }
57
- raise MalformedMessage, "Missing data: got #{data.length} expected #{length}"
58
- end
59
-
60
- # check both the proper checksum, and a truncated checksum
61
- unless calculated_sum == read_sum || (bogus_checksums && calculated_sum.last == read_sum.first)
62
- raw_message = (data + read_sum).map { |b| '%02x' % b }.join(' ')
63
- # skip over single byte to try and re-sync
64
- data.shift
65
- read_sum.reverse.each { |byte| io.ungetbyte(byte) }
66
- data.reverse.each { |byte| io.ungetbyte(byte) }
67
- raise MalformedMessage, "Checksum mismatch for #{message_class.name}: #{raw_message}"
68
- end
69
- # the checksum was truncated; put back the unused byte
70
- io.ungetbyte(read_sum.last) if calculated_sum != read_sum && read_sum.length == 2
71
35
 
72
- puts "read #{(data + read_sum).map { |b| '%02x' % b }.join(' ')}"
36
+ puts "discarding invalid data prior to message #{data[0...offset].map { |b| '%02x' % b }.join(' ')}" unless offset == 0
37
+ puts "read #{data.slice(offset, length).map { |b| '%02x' % b }.join(' ')}"
73
38
 
74
- reserved = to_number(data[2])
75
- src = transform_param(data[3..5])
76
- dest = transform_param(data[6..8])
39
+ reserved = to_number(data[offset + 2])
40
+ src = transform_param(data.slice(offset + 3, 3))
41
+ dest = transform_param(data.slice(offset + 6, 3))
77
42
  result = message_class.new(reserved: reserved, ack_requested: ack_requested, src: src, dest: dest)
78
- result.parse(data[9..-1])
43
+ result.parse(data.slice(offset + 9, length - 11))
79
44
  result.msg = msg if message_class == UnknownMessage
80
- result
45
+ [result, offset + length]
81
46
  end
82
47
  end
83
48
 
@@ -1,5 +1,7 @@
1
1
  require 'mqtt'
2
2
  require 'serialport'
3
+ require 'socket'
4
+ require 'uri'
3
5
  require 'set'
4
6
 
5
7
  module SDN
@@ -92,24 +94,56 @@ module SDN
92
94
  end
93
95
 
94
96
  class MQTTBridge
95
- def initialize(mqtt_uri, serialport, device_id: "somfy", base_topic: "homie")
97
+ WAIT_TIME = 0.25
98
+ BROADCAST_WAIT = 5.0
99
+
100
+ def initialize(mqtt_uri, port, device_id: "somfy", base_topic: "homie")
96
101
  @base_topic = "#{base_topic}/#{device_id}"
97
102
  @mqtt = MQTT::Client.new(mqtt_uri)
98
103
  @mqtt.set_will("#{@base_topic}/$state", "lost", true)
99
104
  @mqtt.connect
105
+
100
106
  @motors = {}
101
107
  @groups = Set.new
102
- @write_queue = Queue.new
108
+
109
+ @mutex = Mutex.new
110
+ @cond = ConditionVariable.new
111
+ @command_queue = []
112
+ @request_queue = []
113
+ @response_pending = false
114
+ @broadcast_pending = false
103
115
 
104
116
  publish_basic_attributes
105
117
 
106
- @sdn = SerialPort.open(serialport, "baud" => 4800, "parity" => SerialPort::ODD)
118
+ uri = URI.parse(port)
119
+ if uri.scheme == "tcp"
120
+ @sdn = TCPSocket.new(uri.host, uri.port)
121
+ else
122
+ @sdn = SerialPort.open(serialport, "baud" => 4800, "parity" => SerialPort::ODD)
123
+ end
107
124
 
108
125
  read_thread = Thread.new do
126
+ buffer = ""
109
127
  loop do
110
128
  begin
111
- message = SDN::Message.parse(@sdn)
112
- next unless message
129
+ message, bytes_read = SDN::Message.parse(buffer.bytes)
130
+ unless message
131
+ begin
132
+ buffer.concat(@sdn.read_nonblock(1))
133
+ next
134
+ rescue IO::WaitReadable
135
+ wait = buffer.empty? ? nil : WAIT_TIME
136
+ if IO.select([@sdn], nil, nil, wait).nil?
137
+ # timed out; just discard everything
138
+ puts "timed out reading; discarding buffer: #{buffer.unpack('H*').first}"
139
+ buffer = ""
140
+ end
141
+ end
142
+ next
143
+ end
144
+ # discard how much we read
145
+ buffer = buffer[bytes_read..-1]
146
+
113
147
  src = SDN::Message.print_address(message.src)
114
148
  # ignore the UAI Plus and ourselves
115
149
  if src != '7F.7F.7F' && !SDN::Message::is_group_address?(message.src) && !(motor = @motors[src.gsub('.', '')])
@@ -118,6 +152,7 @@ module SDN
118
152
  end
119
153
 
120
154
  puts "read #{message.inspect}"
155
+ follow_ups = []
121
156
  case message
122
157
  when SDN::Message::PostNodeLabel
123
158
  if (motor.publish(:label, message.label))
@@ -129,10 +164,10 @@ module SDN
129
164
  motor.publish(:ip, message.ip)
130
165
  when SDN::Message::PostMotorStatus
131
166
  if message.state == :running || motor.state == :running
132
- @write_queue.push(SDN::Message::GetMotorStatus.new(message.src))
167
+ follow_ups << SDN::Message::GetMotorStatus.new(message.src)
133
168
  end
134
169
  # this will do one more position request after it stopped
135
- @write_queue.push(SDN::Message::GetMotorPosition.new(message.src))
170
+ follow_ups << SDN::Message::GetMotorPosition.new(message.src)
136
171
  motor.publish(:state, message.state)
137
172
  motor.publish(:last_direction, message.last_direction)
138
173
  motor.publish(:last_action_source, message.last_action_source)
@@ -153,6 +188,12 @@ module SDN
153
188
  motor.add_group(message.group_index, message.group_address)
154
189
  end
155
190
 
191
+ @mutex.synchronize do
192
+ signal = @response_pending || !follow_ups.empty?
193
+ @response_pending = @broadcast_pending
194
+ @request_queue.concat(follow_ups)
195
+ @cond.signal if signal
196
+ end
156
197
  rescue SDN::MalformedMessage => e
157
198
  puts "ignoring malformed message: #{e}" unless e.to_s =~ /issing data/
158
199
  rescue => e
@@ -164,13 +205,43 @@ module SDN
164
205
  write_thread = Thread.new do
165
206
  begin
166
207
  loop do
167
- message = @write_queue.pop
208
+ message = nil
209
+ @mutex.synchronize do
210
+ # got woken up early by another command getting queued; spin
211
+ if @response_pending
212
+ while @response_pending
213
+ remaining_wait = @response_pending - Time.now.to_f
214
+ if remaining_wait < 0
215
+ puts "timed out waiting on response"
216
+ @response_pending = nil
217
+ @broadcast_pending = nil
218
+ else
219
+ @cond.wait(@mutex, remaining_wait)
220
+ end
221
+ end
222
+ else
223
+ sleep 0.1
224
+ end
225
+
226
+ message = @command_queue.shift
227
+ unless message
228
+ message = @request_queue.shift
229
+ if message
230
+ @response_pending = Time.now.to_f + WAIT_TIME
231
+ if message.dest == [0xff, 0xff, 0xff]
232
+ @broadcast_pending = Time.now.to_f + BROADCAST_WAIT
233
+ end
234
+ end
235
+ end
236
+
237
+ # spin until there is a message
238
+ @cond.wait(@mutex) unless message
239
+ end
240
+ next unless message
241
+
168
242
  puts "writing #{message.inspect}"
169
243
  @sdn.write(message.serialize)
170
244
  @sdn.flush
171
- # give more response time to a discovery message
172
- sleep 5 if (message.is_a?(SDN::Message::GetNodeAddr) && message.dest == [0xff, 0xff, 0xff])
173
- sleep 0.1
174
245
  end
175
246
  rescue => e
176
247
  puts "failure writing: #{e}"
@@ -182,7 +253,10 @@ module SDN
182
253
  puts "got #{value.inspect} at #{topic}"
183
254
  if topic == "#{@base_topic}/discovery/discover/set" && value == "true"
184
255
  # trigger discovery
185
- @write_queue.push(SDN::Message::GetNodeAddr.new)
256
+ @mutex.synchronize do
257
+ @request_queue.push(SDN::Message::GetNodeAddr.new)
258
+ @cond.signal
259
+ end
186
260
  elsif (match = topic.match(%r{^#{Regexp.escape(@base_topic)}/(?<addr>\h{6})/(?<property>label|down|up|stop|positionpulses|positionpercent|ip|wink|reset|(?<speed_type>upspeed|downspeed|slowspeed)|uplimit|downlimit|direction|ip(?<ip>\d+)(?<ip_type>pulses|percent)|groups)/set$}))
187
261
  addr = SDN::Message.parse_address(match[:addr])
188
262
  property = match[:property]
@@ -255,13 +329,18 @@ module SDN
255
329
  next if is_group
256
330
  next unless motor
257
331
  messages = motor.set_groups(value)
258
- messages.each { |m| @write_queue.push(m) }
332
+ @mutex.synchronize do
333
+ messages.each { |m| @command_queue.push(m) }
334
+ @cond.signal
335
+ end
259
336
  nil
260
337
  end
261
338
  if message
262
- @write_queue.push(message)
263
- next if follow_up.is_a?(SDN::Message::GetMotorStatus) && motor&.state == :running
264
- @write_queue.push(follow_up)
339
+ @mutex.synchronize do
340
+ @command_queue.push(message)
341
+ @request_queue.push(follow_up) unless @request_queue.include?(follow_up)
342
+ @cond.signal
343
+ end
265
344
  end
266
345
  end
267
346
  end
@@ -275,6 +354,16 @@ module SDN
275
354
  @mqtt.subscribe("#{@base_topic}/#{topic}")
276
355
  end
277
356
 
357
+ def enqueue(message, queue = :command)
358
+ @mutex.synchronize do
359
+ queue = instance_variable_get(:"#{@queue}_queue")
360
+ unless queue.include?(message)
361
+ queue.push(message)
362
+ @cond.signal
363
+ end
364
+ end
365
+ end
366
+
278
367
  def publish_basic_attributes
279
368
  publish("$homie", "v4.0.0")
280
369
  publish("$name", "Somfy SDN Network")
@@ -441,14 +530,17 @@ module SDN
441
530
  publish("$nodes", (["discovery"] + @motors.keys.sort + @groups.to_a).join(","))
442
531
 
443
532
  sdn_addr = SDN::Message.parse_address(addr)
444
- # these messages are often corrupt; just don't bother for now.
445
- #@write_queue.push(SDN::Message::GetNodeLabel.new(sdn_addr))
446
- @write_queue.push(SDN::Message::GetMotorStatus.new(sdn_addr))
447
- @write_queue.push(SDN::Message::GetMotorLimits.new(sdn_addr))
448
- @write_queue.push(SDN::Message::GetMotorDirection.new(sdn_addr))
449
- @write_queue.push(SDN::Message::GetMotorRollingSpeed.new(sdn_addr))
450
- (1..16).each { |ip| @write_queue.push(SDN::Message::GetMotorIP.new(sdn_addr, ip)) }
451
- (0...16).each { |g| @write_queue.push(SDN::Message::GetGroupAddr.new(sdn_addr, g)) }
533
+ @mutex.synchronize do
534
+ @request_queue.push(SDN::Message::GetNodeLabel.new(sdn_addr))
535
+ @request_queue.push(SDN::Message::GetMotorStatus.new(sdn_addr))
536
+ @request_queue.push(SDN::Message::GetMotorLimits.new(sdn_addr))
537
+ @request_queue.push(SDN::Message::GetMotorDirection.new(sdn_addr))
538
+ @request_queue.push(SDN::Message::GetMotorRollingSpeed.new(sdn_addr))
539
+ (1..16).each { |ip| @request_queue.push(SDN::Message::GetMotorIP.new(sdn_addr, ip)) }
540
+ (0...16).each { |g| @request_queue.push(SDN::Message::GetGroupAddr.new(sdn_addr, g)) }
541
+
542
+ @cond.signal
543
+ end
452
544
 
453
545
  motor
454
546
  end
data/lib/sdn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module SDN
2
- VERSION = '1.0.4'
2
+ VERSION = '1.0.5'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: somfy_sdn
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer