somfy_sdn 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43e93c1bd91289025342d8fb230e4fc961a04001eb8a607a0114cf40c92459f3
4
+ data.tar.gz: 1f29a53d8e8f697b0b6933439d42d154e439992567abdcf6f216aa5d3e7dfed3
5
+ SHA512:
6
+ metadata.gz: 13a0bb6280317801ea68d5a87e1fc2105f01fb6f44423cd4e7a9f553c04d2fb8a5d2009ae663b9f46889e7955c3eec6200330ba51abbd0005b4512b00b4041ca
7
+ data.tar.gz: e6bb95d108574b3ef836630721a22bf514a93d17470c6f25ecced7519c3cf646c6129121413e7f263d044b41a275f86f8ebc3050e83866b6b2e278ebc0df491e
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'somfy_sdn'
4
+
5
+ SDN::MQTTBridge.new(ARGV[0], ARGV[1])
@@ -0,0 +1,179 @@
1
+ require 'sdn/messages/helpers'
2
+
3
+ module SDN
4
+ class MalformedMessage < RuntimeError; end
5
+
6
+ class Message
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
19
+ 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
+
72
+ puts "read #{(data + read_sum).map { |b| '%02x' % b }.join(' ')}"
73
+
74
+ reserved = to_number(data[2])
75
+ src = transform_param(data[3..5])
76
+ dest = transform_param(data[6..8])
77
+ result = message_class.new(reserved: reserved, ack_requested: ack_requested, src: src, dest: dest)
78
+ result.parse(data[9..-1])
79
+ result.msg = msg if message_class == UnknownMessage
80
+ result
81
+ end
82
+ end
83
+
84
+ include Helpers
85
+ singleton_class.include Helpers
86
+
87
+ attr_reader :reserved, :ack_requested, :src, :dest
88
+
89
+ def initialize(reserved: nil, ack_requested: false, src: nil, dest: nil)
90
+ @reserved = reserved || 0x02 # message sent to Sonesse 30
91
+ @ack_requested = ack_requested
92
+ if src.nil? && is_group_address?(dest)
93
+ src = dest
94
+ dest = nil
95
+ end
96
+ @src = src || [0, 0, 1]
97
+ @dest = dest || [0, 0, 0]
98
+ end
99
+
100
+ def parse(params)
101
+ raise MalformedMessage, "unrecognized params for #{self.class.name}: #{params.map { |b| '%02x' % b }}" if self.class.const_defined?(:PARAMS_LENGTH) && params.length != self.class.const_get(:PARAMS_LENGTH)
102
+ end
103
+
104
+ def serialize
105
+ result = transform_param(reserved) + transform_param(src) + transform_param(dest) + params
106
+ length = result.length + 4
107
+ length |= 0x80 if ack_requested
108
+ result = transform_param(self.class.const_get(:MSG)) + transform_param(length) + result
109
+ result.concat(checksum(result))
110
+ puts "wrote #{result.map { |b| '%02x' % b }.join(' ')}"
111
+ result.pack("C*")
112
+ end
113
+
114
+ def inspect
115
+ "#<%s @reserved=%02xh, @ack_requested=%s, @src=%s, @dest=%s%s>" % [self.class.name, reserved, ack_requested, print_address(src), print_address(dest), class_inspect]
116
+ end
117
+
118
+ def class_inspect
119
+ ivars = instance_variables - [:@reserved, :@ack_requested, :@src, :@dest]
120
+ return if ivars.empty?
121
+ ivars.map { |iv| ", #{iv}=#{instance_variable_get(iv).inspect}" }.join
122
+ end
123
+
124
+ protected
125
+
126
+ def params; []; end
127
+
128
+ public
129
+
130
+ class SimpleRequest < Message
131
+ PARAMS_LENGTH = 0
132
+
133
+ def initialize(dest = nil, **kwargs)
134
+ kwargs[:dest] ||= dest
135
+ super(**kwargs)
136
+ end
137
+ end
138
+
139
+ class Nack < Message
140
+ MSG = 0x6f
141
+ PARAMS_LENGTH = 1
142
+ VALUES = { data_error: 0x01, unknown_message: 0x10, node_is_locked: 0x20, wrong_position: 0x21, limits_not_set: 0x22, ip_not_set: 0x23, out_of_range: 0x24, busy: 0xff }
143
+
144
+ # presumed
145
+ attr_accessor :error_code
146
+
147
+ def parse(params)
148
+ super
149
+ error_code = to_number(params[0])
150
+ self.error_code = VALUES[error_code] || error_code
151
+ end
152
+ end
153
+
154
+ class Ack < Message
155
+ MSG = 0x7f
156
+ PARAMS_LENGTH = 0
157
+ end
158
+
159
+ # messages after this point were decoded from UAI+ communication and may be named wrong
160
+ class UnknownMessage < Message
161
+ attr_accessor :msg, :params
162
+
163
+ alias parse params=
164
+
165
+ def class_inspect
166
+ result = ", @msg=%02xh" % msg
167
+ return result if params.empty?
168
+
169
+ result << ", @params=#{params.map { |b| "%02x" % b }.join(' ')}"
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ require 'sdn/messages/control'
177
+ require 'sdn/messages/get'
178
+ require 'sdn/messages/post'
179
+ require 'sdn/messages/set'
@@ -0,0 +1,154 @@
1
+ module SDN
2
+ class Message
3
+ # Move in momentary mode
4
+ class Move < Message
5
+ MSG = 0x01
6
+ PARAMS_LENGTH = 3
7
+ DIRECTION = { down: 0x00, up: 0x01, cancel: 0x02 }.freeze
8
+ SPEED = { up: 0x00, down: 0x01, slow: 0x02 }.freeze
9
+
10
+ attr_reader :direction, :duration, :speed
11
+
12
+ def initialize(dest = nil, direction = :cancel, duration: nil, speed: :up, **kwargs)
13
+ kwargs[:dest] ||= dest
14
+ super(**kwargs)
15
+ self.direction = direction
16
+ self.duration = duration
17
+ self.speed = speed
18
+ end
19
+
20
+ def parse(params)
21
+ super
22
+ self.direction = DIRECTION.invert[to_number(params[0])]
23
+ duration = to_number(params[1])
24
+ duration = nil if duration == 0
25
+ self.duration = duration
26
+ self.speed = SPEED.invert[to_number(params[3])]
27
+ end
28
+
29
+ def direction=(value)
30
+ raise ArgumentError, "direction must be one of :down, :up, or :cancel" unless DIRECTION.keys.include?(value)
31
+ @direction = value
32
+ end
33
+
34
+ def duration=(value)
35
+ raise ArgumentError, "duration must be in range 0x0a to 0xff" unless value || value >= 0x0a && value <= 0xff
36
+ @duration = value
37
+ end
38
+
39
+ def speed=(value)
40
+ raise ArgumentError, "speed must be one of :up, :down, or :slow" unless SPEED.keys.include?(value)
41
+ @speed = speed
42
+ end
43
+
44
+ def params
45
+ transform_param(DIRECTION[direction]) +
46
+ transform_param(duration || 0) +
47
+ transform_param(SPEED[speed])
48
+ end
49
+ end
50
+
51
+ # Stop movement
52
+ class Stop < Message
53
+ MSG = 0x02
54
+ PARAMS_LENGTH = 1
55
+
56
+ def initialize(dest = nil, **kwargs)
57
+ kwargs[:dest] ||= dest
58
+ super(**kwargs)
59
+ end
60
+
61
+ def params
62
+ transform_param(0)
63
+ end
64
+ end
65
+
66
+ # Move to absolute position
67
+ class MoveTo < Message
68
+ MSG = 0x03
69
+ PARAMS_LENGTH = 4
70
+ TARGET_TYPE = { down_limit: 0x00, up_limit: 0x01, ip: 0x02, position_pulses: 0x03, position_percent: 0x04 }.freeze
71
+ SPEED = { up: 0x00, down: 0x01, slow: 0x02 }.freeze
72
+
73
+ attr_reader :target_type, :target, :speed
74
+
75
+ def initialize(dest= nil, target_type = :down_limit, target = nil, speed = :up, **kwargs)
76
+ kwargs[:dest] ||= dest
77
+ super(**kwargs)
78
+ self.target_type = target_type
79
+ self.target = target
80
+ self.speed = speed
81
+ end
82
+
83
+ def parse(params)
84
+ super
85
+ self.target_type = TARGET_TYPE.invert[to_number(params[0])]
86
+ self.target = to_number(params[1..2], nillable: true)
87
+ self.speed = SPEED.invert[to_number(params[3])]
88
+ end
89
+
90
+ def target_type=(value)
91
+ raise ArgumentError, "target_type must be one of :down_limit, :up_limit, :ip, :position_pulses, or :position_percent" unless TARGET_TYPE.keys.include?(value)
92
+ @target_type = value
93
+ end
94
+
95
+ def target=(value)
96
+ value &= 0xffff if value
97
+ @target = value
98
+ end
99
+
100
+ def speed=(value)
101
+ raise ArgumentError, "speed must be one of :up, :down, or :slow" unless SPEED.keys.include?(value)
102
+ @speed = value
103
+ end
104
+
105
+ def params
106
+ transform_param(TARGET_TYPE[target_type]) + from_number(target || 0xffff, 2) + transform_param(SPEED[speed])
107
+ end
108
+ end
109
+
110
+ # Move relative to current position
111
+ class MoveOf < Message
112
+ MSG = 0x04
113
+ PARAMS_LENGTH = 4
114
+ TARGET_TYPE = { next_ip: 0x00, previous_ip: 0x01, jog_down_pulses: 0x02, jog_up_pulses: 0x03, jog_down_ms: 0x04, jog_up_ms: 0x05 }
115
+
116
+ attr_reader :target_type, :target
117
+
118
+ def initialize(dest = nil, target_type = nil, target = nil, **kwargs)
119
+ kwargs[:dest] ||= dest
120
+ super(**kwargs)
121
+ self.target_type = target_type
122
+ self.target = target
123
+ end
124
+
125
+ def parse(params)
126
+ super
127
+ self.target_type = TARGET_TYPE.invert[to_number(params[0])]
128
+ target = to_number(params[1..2], nillable: true)
129
+ target *= 10 if %I{jog_down_ms jog_up_ms}.include?(target_type)
130
+ self.target = target
131
+ end
132
+
133
+ def target_type=(value)
134
+ raise ArgumentError, "target_type must be one of :next_ip, :previous_ip, :jog_down_pulses, :jog_up_pulses, :jog_down_ms, :jog_up_ms" unless value.nil? || TARGET_TYPE.keys.include?(value)
135
+ @target_type = value
136
+ end
137
+
138
+ def target=(value)
139
+ value &= 0xffff if value
140
+ @target = value
141
+ end
142
+
143
+ def params
144
+ param = target || 0xffff
145
+ param /= 10 if %I{jog_down_ms jog_up_ms}.include?(target_type)
146
+ transform_param(TARGET_TYPE[target_type]) + from_number(param, 2) + transform_param(0)
147
+ end
148
+ end
149
+
150
+ class Wink < SimpleRequest
151
+ MSG = 0x05
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,99 @@
1
+ module SDN
2
+ class Message
3
+ class GetMotorPosition < SimpleRequest
4
+ MSG = 0x0c
5
+ end
6
+
7
+ class GetMotorStatus < SimpleRequest
8
+ MSG = 0x0e
9
+ end
10
+
11
+ class GetMotorLimits < SimpleRequest
12
+ MSG = 0x21
13
+ end
14
+
15
+ class GetMotorDirection < SimpleRequest
16
+ MSG = 0x22
17
+ end
18
+
19
+ class GetMotorRollingSpeed < SimpleRequest
20
+ MSG = 0x23
21
+ end
22
+
23
+ class GetMotorIP < Message
24
+ MSG = 0x25
25
+ PARAMS_LENGTH = 1
26
+
27
+ attr_reader :ip
28
+
29
+ def initialize(dest = nil, ip = nil, **kwargs)
30
+ kwargs[:dest] ||= dest
31
+ super(**kwargs)
32
+ self.ip = ip
33
+ end
34
+
35
+ def parse(params)
36
+ super
37
+ self.ip = to_number(params[0], nillable: true)
38
+ end
39
+
40
+ def ip=(value)
41
+ raise ArgumentError, "invalid IP #{ip} (should be 1-16)" unless ip.nil? || (1..16).include?(ip)
42
+ @ip = value
43
+ end
44
+
45
+ def params
46
+ transform_param(@ip || 0xff)
47
+ end
48
+ end
49
+
50
+ class GetGroupAddr < Message
51
+ MSG = 0x41
52
+ PARAMS_LENGTH = 1
53
+
54
+ attr_reader :group_index
55
+
56
+ def initialize(dest = nil, group_index = 0, **kwargs)
57
+ kwargs[:dest] ||= dest
58
+ super(**kwargs)
59
+ self.group_index = group_index
60
+ end
61
+
62
+ def parse(params)
63
+ super
64
+ self.group_index = to_number(params[0])
65
+ end
66
+
67
+ def group_index=(value)
68
+ raise ArgumentError, "group_index is out of range" unless (0...16).include?(value)
69
+ @group_index = value
70
+ end
71
+
72
+ def params
73
+ transform_param(group_index)
74
+ end
75
+ end
76
+
77
+ class GetNodeLabel < SimpleRequest
78
+ MSG = 0x45
79
+ end
80
+
81
+ class GetNodeAddr < Message
82
+ MSG = 0x40
83
+ PARAMS_LENGTH = 0
84
+
85
+ def initialize(dest = [0xff, 0xff, 0xff], **kwargs)
86
+ kwargs[:dest] ||= dest
87
+ super(**kwargs)
88
+ end
89
+ end
90
+
91
+ class GetNodeSerialNumber < SimpleRequest
92
+ MSG = 0x4c
93
+ end
94
+
95
+ class GetNodeStackVersion < SimpleRequest
96
+ MSG = 0x70
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,51 @@
1
+ module SDN
2
+ class Message
3
+ module Helpers
4
+ def parse_address(addr_string)
5
+ addr_string.match(/^(\h{2})[:.]?(\h{2})[:.]?(\h{2})$/).captures.map { |byte| byte.to_i(16) }
6
+ end
7
+
8
+ def print_address(addr_bytes)
9
+ "%02X.%02X.%02X" % addr_bytes
10
+ end
11
+
12
+ def is_group_address?(addr_bytes)
13
+ addr_bytes[0..1] == [1, 1]
14
+ end
15
+
16
+ def transform_param(param)
17
+ Array(param).reverse.map { |byte| 0xff - byte }
18
+ end
19
+
20
+ def to_number(param, nillable: false)
21
+ result = Array(param).reverse.inject(0) { |sum, byte| (sum << 8) + 0xff - byte }
22
+ result = nil if nillable && result == (1 << (8 * Array(param).length)) - 1
23
+ result
24
+ end
25
+
26
+ def from_number(number, bytes)
27
+ bytes.times.inject([]) do |res, _|
28
+ res << (0xff - number & 0xff)
29
+ number >>= 8
30
+ res
31
+ end
32
+ end
33
+
34
+ def to_string(param)
35
+ chars = param.map { |b| 0xff - b }
36
+ chars[0..-1].pack("C*").sub(/\0+$/, '')
37
+ end
38
+
39
+ def from_string(string, bytes)
40
+ chars = string.bytes
41
+ chars = chars[0...16].fill(0, chars.length, bytes - chars.length)
42
+ chars.map { |b| 0xff - b }
43
+ end
44
+
45
+ def checksum(bytes)
46
+ result = bytes.sum
47
+ [result >> 8, result & 0xff]
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,162 @@
1
+ module SDN
2
+ class Message
3
+ class PostMotorPosition < Message
4
+ MSG = 0x0d
5
+ PARAMS_LENGTH = 5
6
+
7
+ attr_accessor :position_pulses, :position_percent, :ip
8
+
9
+ def parse(params)
10
+ super
11
+ self.position_pulses = to_number(params[0..1], nillable: true)
12
+ self.position_percent = to_number(params[2], nillable: true)
13
+ self.ip = to_number(params[4], nillable: true)
14
+ end
15
+ end
16
+
17
+ class PostMotorStatus < Message
18
+ MSG = 0x0f
19
+ PARAMS_LENGTH = 4
20
+ STATE = { stopped: 0x00, running: 0x01, blocked: 0x02, locked: 0x03 }.freeze
21
+ DIRECTION = { down: 0x00, up: 0x01 }.freeze
22
+ SOURCE = { internal: 0x00, network: 0x01, dct: 0x02 }.freeze
23
+ CAUSE = { target_reached: 0x00,
24
+ explicit_command: 0x01,
25
+ wink: 0x02,
26
+ limits_not_set: 0x10,
27
+ ip_not_set: 0x11,
28
+ polarity_not_checked: 0x12,
29
+ in_configuration_mode: 0x13,
30
+ obstacle_detection: 0x20,
31
+ over_current_protection: 0x21,
32
+ thermal_protection: 0x22 }.freeze
33
+
34
+ attr_accessor :state, :last_direction, :last_action_source, :last_action_cause
35
+
36
+ def parse(params)
37
+ super
38
+ self.state = STATE.invert[to_number(params[0])]
39
+ self.last_direction = DIRECTION.invert[to_number(params[1])]
40
+ self.last_action_source = SOURCE.invert[to_number(params[2])]
41
+ self.last_action_cause = CAUSE.invert[to_number(params[3])]
42
+ end
43
+ end
44
+
45
+ class PostMotorLimits < Message
46
+ MSG = 0x31
47
+ PARAMS_LENGTH = 4
48
+
49
+ attr_accessor :up_limit, :down_limit
50
+
51
+ def parse(params)
52
+ super
53
+ self.up_limit = to_number(params[0..1], nillable: true)
54
+ self.down_limit = to_number(params[2..3], nillable: true)
55
+ end
56
+ end
57
+
58
+ class PostMotorDirection < Message
59
+ MSG = 0x32
60
+ PARAMS_LENGTH = 1
61
+ DIRECTION = { standard: 0x00, reversed: 0x01 }.freeze
62
+
63
+ attr_accessor :direction
64
+
65
+ def parse(params)
66
+ super
67
+ self.direction = DIRECTION.invert[to_number(params[0])]
68
+ end
69
+ end
70
+
71
+ class PostMotorRollingSpeed < Message
72
+ MSG = 0x33
73
+ PARAMS_LENGTH = 6
74
+
75
+ attr_accessor :up_speed, :down_speed, :slow_speed
76
+
77
+ def parse(params)
78
+ super
79
+ self.up_speed = to_number(params[0])
80
+ self.down_speed = to_number(params[1])
81
+ self.slow_speed = to_number(params[2])
82
+ # 3 ignored params
83
+ end
84
+ end
85
+
86
+ class PostMotorIP < Message
87
+ MSG = 0x35
88
+ PARAMS_LENGTH = 4
89
+
90
+ attr_accessor :ip, :position_pulses, :position_percent
91
+
92
+ def parse(params)
93
+ super
94
+ self.ip = to_number(params[0])
95
+ self.position_pulses = to_number(params[1..2], nillable: true)
96
+ self.position_percent = to_number(params[3], nillable: true)
97
+ end
98
+ end
99
+
100
+ class PostNodeAddr < Message
101
+ MSG = 0x60
102
+ PARAMS_LENGTH = 0
103
+ end
104
+
105
+ class PostGroupAddr < Message
106
+ MSG = 0x61
107
+ PARAMS_LENGTH = 4
108
+
109
+ attr_accessor :group_index, :group_address
110
+
111
+ def parse(params)
112
+ super
113
+ self.group_index = to_number(params[0])
114
+ self.group_address = transform_param(params[1..3])
115
+ self.group_address = nil if group_address == [0, 0, 0] || group_address == [0x01, 0x01, 0xff]
116
+ end
117
+
118
+ def class_inspect
119
+ ", group_index=#{group_index.inspect}, group_address=#{group_address ? print_address(group_address) : 'nil'}"
120
+ end
121
+ end
122
+
123
+ class PostNodeLabel < Message
124
+ MSG = 0x65
125
+
126
+ attr_accessor :label
127
+
128
+ def parse(params)
129
+ @label = to_string(params)
130
+ end
131
+ end
132
+
133
+ class PostNodeSerialNumber < Message
134
+ MSG = 0x6c
135
+
136
+ # format is NNNNNNMMYYWW
137
+ # N = NodeID (address)
138
+ # M = Manufacturer ID
139
+ # Y = Year (last two digits)
140
+ # W = Week
141
+ attr_accessor :serial_number
142
+
143
+ def parse(params)
144
+ @serial_number = to_string(params)
145
+ end
146
+ end
147
+
148
+ class PostNodeStackVersion < UnknownMessage
149
+ MSG = 0x71
150
+
151
+ attr_accessor :params
152
+
153
+ def parse(params)
154
+ # I don't know how to interpret this yet
155
+ # I get b6 bc b2 be fc fe, and UAI+ shows 5063497A3
156
+ super
157
+ end
158
+
159
+ def msg; MSG; end
160
+ end
161
+ end
162
+ end