somfy_sdn 2.4.1 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e1e1adfb127498d09f9df75ebcbe479aef58ee8412f4a9cb977d8b11e45b119
4
- data.tar.gz: 140db72e519d25a12028f901f0287ee6a41fd7c6071f25df406fd115616883ab
3
+ metadata.gz: b0cde44546a65fc3e056bd24f4b2306d87669f219bbb8f759b13d6b5f26dcfdf
4
+ data.tar.gz: 85da8daf249310449f80a7fb1d0bb6579c009efc9f65ab121deb0106be122ddf
5
5
  SHA512:
6
- metadata.gz: e42ed8ef8086f84247e70743e73f3d4f1dccc2a83e541d4f88bb2bb7d719f85ff16c95375de660e4398adc305517e76e43d766a9bf84de8f9247e32d1bc2fceb
7
- data.tar.gz: 879ab6fc00ba20c2c0b0bc1946ab2760702c791fc458955b65cc2c27db4687ccb8d95d112aedce0953f10e2d7ba88f7e54d20998e89bbd3519c0230971a79b7c
6
+ metadata.gz: f4665bc4b50b981ee51f89e1be7a14b943209a639179e59b6175991e7bf94612bf1c9bf603f98603a4234f7c99f4cd81cb25ae1ba96542641555d9d01a152a33
7
+ data.tar.gz: e7fb11e6d77febe8718d732e36f32fce86628fd31169e266b61f0784b063402dba5231494c66b45e04346a7c37191fdaf0eb57c4c7329369ac111f8dca6cf512
data/exe/somfy_sdn CHANGED
@@ -5,6 +5,7 @@ require "somfy_sdn"
5
5
  require "thor"
6
6
  require "uri"
7
7
 
8
+ # @!visibility private
8
9
  class SomfySDNCLI < Thor
9
10
  class_option :verbose, type: :boolean, default: false, desc: "Log protocol messages"
10
11
  class_option :trace, type: :boolean, default: false, desc: "Log protocol bytes"
@@ -3,6 +3,7 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # In-memory state container for a discovered group and its aggregated MQTT attributes.
6
7
  Group = Struct.new(:bridge,
7
8
  :addr,
8
9
  :position_percent,
@@ -17,6 +18,7 @@ module SDN
17
18
  super
18
19
  end
19
20
 
21
+ # @!visibility private
20
22
  def publish(attribute, value)
21
23
  return unless self[attribute] != value
22
24
 
@@ -24,14 +26,23 @@ module SDN
24
26
  self[attribute] = value
25
27
  end
26
28
 
29
+ # @return [String]
27
30
  def printed_addr
28
31
  Message.print_address(Message.parse_address(addr))
29
32
  end
30
33
 
34
+ # @!attribute [r] motor_objects
35
+ #
36
+ # @return [<SDN::CLI::MQTT::Motor>]
31
37
  def motor_objects
32
38
  bridge.motors.select { |_addr, motor| motor.groups_string.include?(printed_addr) }.values
33
39
  end
34
40
 
41
+ # @!attribute [r] motors_string
42
+ #
43
+ # Returns the current group membership as a comma-separated motor address list.
44
+ #
45
+ # @return [String]
35
46
  def motors_string
36
47
  motor_objects.map { |m| Message.print_address(Message.parse_address(m.addr)) }.sort.join(",")
37
48
  end
@@ -3,6 +3,7 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # In-memory state container for a discovered motor and its published MQTT attributes.
6
7
  Motor = Struct.new(:bridge,
7
8
  :addr,
8
9
  :node_type,
@@ -55,13 +56,17 @@ module SDN
55
56
  :ip16_percent,
56
57
  :groups,
57
58
  :last_action,
58
- :last_position_pulses) do
59
+ :last_position_pulses,
60
+ :last_action_retries) do
59
61
  def initialize(*)
60
62
  members.each { |k| self[k] = :nil }
61
63
  @groups = [].fill(nil, 0, 16)
62
64
  super
65
+ self.last_action = Message::Stop
66
+ self.last_action_retries = 0
63
67
  end
64
68
 
69
+ # @!visibility private
65
70
  def publish(attribute, value)
66
71
  return unless self[attribute] != value
67
72
 
@@ -69,6 +74,12 @@ module SDN
69
74
  self[attribute] = value
70
75
  end
71
76
 
77
+ # Updates a group slot and refreshes derived group membership state.
78
+ #
79
+ # @param index [Integer] group slot number
80
+ # @param address [(Integer, Integer, Integer), nil] group address
81
+ #
82
+ # @return [void]
72
83
  def add_group(index, address)
73
84
  group = bridge.add_group(Message.print_address(address)) if address
74
85
  old_group = @groups[index - 1]
@@ -78,6 +89,11 @@ module SDN
78
89
  bridge.touch_group(old_group) if old_group
79
90
  end
80
91
 
92
+ # Builds the SDN writes needed to reconcile group membership with a comma-separated list.
93
+ #
94
+ # @param groups [String] comma-separated group addresses
95
+ #
96
+ # @return [<SDN::Message>]
81
97
  def set_groups(groups) # rubocop:disable Naming/AccessorMethodName
82
98
  return unless /^(?:\h{2}[:.]?\h{2}[:.]?\h{2}(?:,\h{2}[:.]?\h{2}[:.]?\h{2})*)?$/i.match?(groups)
83
99
 
@@ -96,10 +112,18 @@ module SDN
96
112
  messages
97
113
  end
98
114
 
115
+ # @!attribute [r] groups_string
116
+ #
117
+ # Returns the current group membership list as a normalized string.
118
+ #
119
+ # @return [String]
99
120
  def groups_string
100
121
  @groups.compact.map { |g| Message.print_address(g) }.sort.uniq.join(",")
101
122
  end
102
123
 
124
+ # @!attribute [r] group_objects
125
+ #
126
+ # @return [<SDN::CLI::MQTT::Group>]
103
127
  def group_objects
104
128
  groups_string.split(",").map { |addr| bridge.groups[addr.delete(".")] }
105
129
  end
@@ -3,7 +3,13 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # Priority queue used to order outbound SDN messages by importance.
6
7
  class PQueue < Array
8
+ # Inserts a queued item ahead of lower-priority entries.
9
+ #
10
+ # @param obj [Object] queued item responding to `priority`
11
+ #
12
+ # @return [Array] the queue itself
7
13
  def push(obj)
8
14
  i = index { |o| o.priority > obj.priority }
9
15
  if i
@@ -3,7 +3,11 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # Reader loop that translates SDN responses into MQTT state updates.
6
7
  module Read
8
+ # Continuously consumes SDN messages and republishes state to MQTT.
9
+ #
10
+ # @return [void]
7
11
  def read
8
12
  loop do
9
13
  @sdn.receive do |message|
@@ -71,53 +75,7 @@ module SDN
71
75
  group.publish(:ip, ip)
72
76
  end
73
77
  when Message::PostMotorStatus
74
- if message.state == :running || motor.state == :running ||
75
- # if it's explicitly stopped, but we didn't ask it to, it's probably
76
- # changing directions so keep querying
77
- (message.state == :stopped &&
78
- message.last_action_cause == :explicit_command &&
79
- !(motor.last_action == Message::Stop || motor.last_action.nil?))
80
- follow_ups << Message::GetMotorStatus.new(message.src)
81
- end
82
- # this will do one more position request after it stopped
83
- follow_ups << Message::GetMotorPosition.new(message.src)
84
- motor.publish(:state, message.state)
85
- motor.publish(:last_direction, message.last_direction)
86
- hass_state = if message.state == :running
87
- (message.last_direction == :down) ? :closing : :opening
88
- elsif motor.position_percent&.zero?
89
- :open
90
- elsif motor.position_percent == 100
91
- :closed
92
- else
93
- :stopped
94
- end
95
- motor.publish(:hass_state, hass_state)
96
- motor.publish(:last_action_source, message.last_action_source)
97
- motor.publish(:last_action_cause, message.last_action_cause)
98
- motor.group_objects.each do |group|
99
- states = group.motor_objects.map(&:state).uniq
100
- state = (states.length == 1) ? states.first : "mixed"
101
- group.publish(:state, state)
102
-
103
- directions = group.motor_objects.map(&:last_direction).uniq
104
- direction = (directions.length == 1) ? directions.first : "mixed"
105
- group.publish(:last_direction, direction)
106
-
107
- positions = group.motor_objects.map(&:position_percent).uniq
108
- position = (positions.length == 1) ? positions.first : 50
109
-
110
- hass_state = if state == :running
111
- (direction == :down) ? :closing : :opening
112
- elsif position.zero?
113
- :open
114
- elsif position == 100
115
- :closed
116
- else
117
- :stopped
118
- end
119
- group.publish(:hass_state, hass_state)
120
- end
78
+ handle_post_motor_status(message, motor, follow_ups)
121
79
  when Message::PostMotorLimits
122
80
  motor.publish(:up_limit, message.up_limit)
123
81
  motor.publish(:down_limit, message.down_limit)
@@ -173,6 +131,47 @@ module SDN
173
131
  end
174
132
  end
175
133
  end
134
+
135
+ private
136
+
137
+ def handle_post_motor_status(message, motor, follow_ups)
138
+ if message.state == :running || motor.state == :running ||
139
+ # if it's explicitly stopped, but we didn't ask it to, it's probably
140
+ # changing directions so keep querying
141
+ (message.state == :stopped &&
142
+ message.last_action_cause == :explicit_command &&
143
+ motor.last_action != Message::Stop && motor.last_action_retries.positive?)
144
+ if message.state == :stopped
145
+ SDN.logger.debug("Re-querying motor status for #{motor.addr} #{motor.last_action_retries} " \
146
+ "more time(s) even though stopped")
147
+ motor.last_action_retries -= 1
148
+ end
149
+ follow_ups << Message::GetMotorStatus.new(message.src)
150
+ end
151
+ # this will do one more position request after it stopped
152
+ follow_ups << Message::GetMotorPosition.new(message.src)
153
+ motor.publish(:state, message.state)
154
+ motor.publish(:last_direction, message.last_direction)
155
+ motor.publish(:hass_state, motor_hass_state(message.state,
156
+ message.last_direction,
157
+ motor.position_percent))
158
+ motor.publish(:last_action_source, message.last_action_source)
159
+ motor.publish(:last_action_cause, message.last_action_cause)
160
+ motor.group_objects.each do |group|
161
+ states = group.motor_objects.map(&:state).uniq
162
+ state = (states.length == 1) ? states.first : "mixed"
163
+ group.publish(:state, state)
164
+
165
+ directions = group.motor_objects.map(&:last_direction).uniq
166
+ direction = (directions.length == 1) ? directions.first : "mixed"
167
+ group.publish(:last_direction, direction)
168
+
169
+ positions = group.motor_objects.map(&:position_percent).uniq
170
+ position = (positions.length == 1) ? positions.first : 50
171
+
172
+ group.publish(:hass_state, motor_hass_state(state, direction, position))
173
+ end
174
+ end
176
175
  end
177
176
  end
178
177
  end
@@ -3,7 +3,14 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # MQTT subscription handlers that translate inbound command messages into SDN commands.
6
7
  module Subscriptions
8
+ # Converts an MQTT command message into one or more queued SDN messages.
9
+ #
10
+ # @param topic [String] absolute MQTT topic
11
+ # @param value [String] MQTT payload
12
+ #
13
+ # @return [void]
7
14
  def handle_message(topic, value)
8
15
  SDN.logger.info "Got #{value.inspect} at #{topic}"
9
16
  unless (match = topic.match(%r{^#{Regexp.escape(@base_topic)}/
@@ -181,6 +188,7 @@ module SDN
181
188
  Message::Wink,
182
189
  Message::Stop].include?(message.class)
183
190
  motor.last_action = message.class
191
+ motor.last_action_retries = 3
184
192
  end
185
193
 
186
194
  return unless message
@@ -3,7 +3,11 @@
3
3
  module SDN
4
4
  module CLI
5
5
  class MQTT
6
+ # Writer loop that drains the outbound queue and manages retries and follow-up timing.
6
7
  module Write
8
+ # Continuously drains the prioritized queue and transmits SDN messages.
9
+ #
10
+ # @return [void]
7
11
  def write
8
12
  last_write_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
13
 
@@ -11,34 +15,38 @@ module SDN
11
15
  message_and_retries = nil
12
16
  @mutex.synchronize do
13
17
  # got woken up early by another command getting queued; spin
14
- if @response_pending
15
- while @response_pending
16
- remaining_wait = @response_pending - Time.now.to_f
17
- if remaining_wait.negative?
18
- SDN.logger.debug "Timed out waiting on response"
19
- @response_pending = nil
20
- @broadcast_pending = nil
21
- if @prior_message && @prior_message&.remaining_retries != 0
22
- SDN.logger.debug "Retrying #{@prior_message.remaining_retries} more times ..."
23
- if Message.group_address?(@prior_message.message.src) && !@pending_group_motors.empty?
24
- SDN.logger.debug "Re-targetting group message to individual motors"
25
- @pending_group_motors.each do |addr|
26
- new_message = @prior_message.message.dup
27
- new_message.src = [0, 0, 1]
28
- new_message.dest = Message.parse_address(addr)
29
- @queue.push(MessageAndRetries.new(new_message,
30
- @prior_message.remaining_retries,
31
- @prior_message.priority))
32
- end
33
- @pending_group_motors = []
34
- else
35
- @queue.push(@prior_message)
18
+ while @response_pending
19
+ remaining_wait = @response_pending - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ if remaining_wait.negative?
21
+ if @prior_message
22
+ SDN.logger.info "Timed out waiting on response to #{@prior_message.message.inspect}; " \
23
+ "retries left: #{@prior_message.remaining_retries}"
24
+ else
25
+ SDN.logger.info "Timed out waiting on response"
26
+ end
27
+ @response_pending = nil
28
+ @broadcast_pending = nil
29
+ if @prior_message
30
+ if @prior_message.remaining_retries.zero?
31
+ handle_timeout_fallback(@prior_message.message)
32
+ elsif Message.group_address?(@prior_message.message.src) && !@pending_group_motors.empty?
33
+ SDN.logger.debug "Re-targetting group message to individual motors"
34
+ @pending_group_motors.each do |addr|
35
+ new_message = @prior_message.message.dup
36
+ new_message.src = [0, 0, 1]
37
+ new_message.dest = Message.parse_address(addr)
38
+ @queue.push(MessageAndRetries.new(new_message,
39
+ @prior_message.remaining_retries,
40
+ @prior_message.priority))
36
41
  end
37
- @prior_message = nil
42
+ @pending_group_motors = []
43
+ else
44
+ @queue.push(@prior_message)
38
45
  end
39
- else
40
- @cond.wait(@mutex, remaining_wait)
46
+ @prior_message = nil
41
47
  end
48
+ else
49
+ @cond.wait(@mutex, remaining_wait)
42
50
  end
43
51
  end
44
52
 
@@ -46,7 +54,7 @@ module SDN
46
54
  if message_and_retries && (
47
55
  message_and_retries.message.ack_requested ||
48
56
  message_and_retries.message.class.name =~ /^SDN::Message::Get/)
49
- @response_pending = Time.now.to_f + WAIT_TIME
57
+ @response_pending = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WAIT_TIME
50
58
  @pending_group_motors = if Message.group_address?(message_and_retries.message.src)
51
59
  group_addr = Message.print_address(message_and_retries.message.src).delete(
52
60
  "."
@@ -59,7 +67,7 @@ module SDN
59
67
  if message_and_retries.message.dest == BROADCAST_ADDRESS || (
60
68
  Message.group_address?(message_and_retries.message.src) &&
61
69
  message_and_retries.message.is_a?(Message::GetNodeAddr))
62
- @broadcast_pending = Time.now.to_f + BROADCAST_WAIT
70
+ @broadcast_pending = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BROADCAST_WAIT
63
71
  end
64
72
  end
65
73
 
@@ -92,6 +100,38 @@ module SDN
92
100
  SDN.logger.fatal "Failure writing: #{e}: #{e.backtrace}"
93
101
  exit 1
94
102
  end
103
+
104
+ private
105
+
106
+ def handle_timeout_fallback(message)
107
+ case message
108
+ when Message::GetMotorStatus
109
+ handle_get_motor_status_timeout(message)
110
+ when Message::GetMotorPosition, Message::ILT2::GetMotorPosition
111
+ handle_get_motor_position_timeout(message)
112
+ end
113
+ end
114
+
115
+ def handle_get_motor_status_timeout(message)
116
+ addr = Message.print_address(message.dest).delete(".")
117
+ motor = @motors[addr]
118
+ return unless motor&.state == :running
119
+
120
+ motor.publish(:state, :stopped)
121
+ motor.publish(:hass_state, motor_hass_state(:stopped, motor.last_direction, motor.position_percent))
122
+ motor.publish(:last_action_source, :bridge)
123
+ motor.publish(:last_action_cause, :timeout)
124
+ end
125
+
126
+ def handle_get_motor_position_timeout(message)
127
+ addr = Message.print_address(message.dest).delete(".")
128
+ motor = @motors[addr]
129
+ return unless motor
130
+
131
+ motor.publish(:position_pulses, nil)
132
+ motor.publish(:position_percent, nil)
133
+ motor.publish(:ip, nil)
134
+ end
95
135
  end
96
136
  end
97
137
  end