lifx 0.0.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +1 -1
  5. data/README.md +71 -13
  6. data/Rakefile +12 -0
  7. data/bin/lifx-console +15 -0
  8. data/bin/lifx-snoop +50 -0
  9. data/examples/auto-off/Gemfile +3 -0
  10. data/examples/auto-off/auto-off.rb +35 -0
  11. data/examples/identify/Gemfile +3 -0
  12. data/examples/identify/identify.rb +70 -0
  13. data/examples/travis-build-light/Gemfile +4 -0
  14. data/examples/travis-build-light/build-light.rb +57 -0
  15. data/lib/bindata_ext/bool.rb +29 -0
  16. data/lib/bindata_ext/record.rb +11 -0
  17. data/lib/lifx/client.rb +136 -0
  18. data/lib/lifx/color.rb +190 -0
  19. data/lib/lifx/config.rb +12 -0
  20. data/lib/lifx/firmware.rb +55 -0
  21. data/lib/lifx/gateway_connection.rb +177 -0
  22. data/lib/lifx/light.rb +406 -0
  23. data/lib/lifx/light_collection.rb +105 -0
  24. data/lib/lifx/light_target.rb +189 -0
  25. data/lib/lifx/logging.rb +11 -0
  26. data/lib/lifx/message.rb +166 -0
  27. data/lib/lifx/network_context.rb +200 -0
  28. data/lib/lifx/observable.rb +46 -0
  29. data/lib/lifx/protocol/address.rb +21 -0
  30. data/lib/lifx/protocol/device.rb +225 -0
  31. data/lib/lifx/protocol/header.rb +24 -0
  32. data/lib/lifx/protocol/light.rb +110 -0
  33. data/lib/lifx/protocol/message.rb +17 -0
  34. data/lib/lifx/protocol/metadata.rb +21 -0
  35. data/lib/lifx/protocol/payload.rb +7 -0
  36. data/lib/lifx/protocol/sensor.rb +29 -0
  37. data/lib/lifx/protocol/type.rb +134 -0
  38. data/lib/lifx/protocol/wan.rb +50 -0
  39. data/lib/lifx/protocol/wifi.rb +76 -0
  40. data/lib/lifx/protocol_path.rb +84 -0
  41. data/lib/lifx/routing_manager.rb +110 -0
  42. data/lib/lifx/routing_table.rb +33 -0
  43. data/lib/lifx/seen.rb +15 -0
  44. data/lib/lifx/site.rb +89 -0
  45. data/lib/lifx/tag_manager.rb +105 -0
  46. data/lib/lifx/tag_table.rb +47 -0
  47. data/lib/lifx/target.rb +23 -0
  48. data/lib/lifx/timers.rb +18 -0
  49. data/lib/lifx/transport/tcp.rb +81 -0
  50. data/lib/lifx/transport/udp.rb +67 -0
  51. data/lib/lifx/transport.rb +41 -0
  52. data/lib/lifx/transport_manager/lan.rb +140 -0
  53. data/lib/lifx/transport_manager.rb +34 -0
  54. data/lib/lifx/utilities.rb +33 -0
  55. data/lib/lifx/version.rb +1 -1
  56. data/lib/lifx.rb +15 -1
  57. data/lifx.gemspec +11 -7
  58. data/spec/color_spec.rb +45 -0
  59. data/spec/gateway_connection_spec.rb +32 -0
  60. data/spec/integration/client_spec.rb +40 -0
  61. data/spec/integration/light_spec.rb +43 -0
  62. data/spec/integration/tags_spec.rb +31 -0
  63. data/spec/message_spec.rb +163 -0
  64. data/spec/protocol_path_spec.rb +109 -0
  65. data/spec/routing_manager_spec.rb +22 -0
  66. data/spec/spec_helper.rb +52 -0
  67. data/spec/transport/udp_spec.rb +38 -0
  68. data/spec/transport_spec.rb +14 -0
  69. metadata +143 -26
@@ -0,0 +1,166 @@
1
+ require 'forwardable'
2
+ require 'lifx/protocol_path'
3
+
4
+ module LIFX
5
+ # @api private
6
+
7
+ class Message
8
+ include Logging
9
+ extend Forwardable
10
+
11
+ class MessageError < StandardError; end
12
+ class UnpackError < MessageError; end
13
+ class PackError < MessageError; end
14
+ class NoPath < MessageError; end
15
+
16
+ class InvalidFrame < UnpackError; end
17
+ class UnsupportedProtocolVersion < UnpackError; end
18
+ class NotAddressableFrame < UnpackError; end
19
+ class NoPayload < PackError; end
20
+ class UnmappedPayload < MessageError; end
21
+ class InvalidFields < PackError; end
22
+
23
+ PROTOCOL_VERSION = 1024
24
+
25
+ class << self
26
+ attr_accessor :log_invalid_messages
27
+
28
+ def unpack(data)
29
+ raise InvalidFrame if data.length < 2
30
+
31
+ header = Protocol::Header.read(data)
32
+ raise UnsupportedProtocolVersion.new("Expected #{PROTOCOL_VERSION} but got #{header.protocol} instead") if header.protocol != PROTOCOL_VERSION
33
+ raise NotAddressableFrame if header.addressable == 0
34
+
35
+ message = Protocol::Message.read(data)
36
+ path = ProtocolPath.new(raw_site: message.raw_site, raw_target: message.raw_target, tagged: message.tagged)
37
+ payload_class = message_type_for_id(message.type.snapshot)
38
+ if payload_class.nil?
39
+ if self.log_invalid_messages
40
+ logger.error("Message.unpack: Unrecognised payload ID: #{message.type}")
41
+ logger.error("Message.unpack: Message: #{message}")
42
+ end
43
+ return nil # FIXME
44
+ raise UnmappedPayload.new("Unrecognised payload ID: #{message.type}")
45
+ end
46
+ begin
47
+ payload = payload_class.read(message.payload)
48
+ rescue => ex
49
+ if message.raw_site == "\x00" * 6
50
+ logger.info("Message.unpack: Ignoring malformed message from virgin bulb")
51
+ else
52
+ if self.log_invalid_messages
53
+ logger.error("Message.unpack: Exception while unpacking payload of type #{payload_class}: #{ex}")
54
+ logger.error("Message.unpack: Data: #{data.inspect}")
55
+ end
56
+ end
57
+ end
58
+ new(path, message, payload)
59
+ rescue => ex
60
+ if self.log_invalid_messages
61
+ logger.debug("Message.unpack: Exception while unpacking #{data.inspect}")
62
+ logger.debug("Message.unpack: #{ex} - #{ex.backtrace.join("\n")}")
63
+ end
64
+ raise ex
65
+ end
66
+
67
+ def message_type_for_id(type_id)
68
+ Protocol::TYPE_ID_TO_CLASS[type_id]
69
+ end
70
+
71
+ def type_id_for_message_class(klass)
72
+ Protocol::CLASS_TO_TYPE_ID[klass]
73
+ end
74
+
75
+ def valid_fields
76
+ @valid_fields ||= Protocol::Message.new.field_names.map(&:to_sym)
77
+ end
78
+ end
79
+
80
+ LIFX::Protocol::Message.fields.each do |field|
81
+ define_method(field.name) do
82
+ @message.send(field.name).snapshot
83
+ end
84
+
85
+ define_method("#{field.name}=") do |value|
86
+ @message.send("#{field.name}=", value)
87
+ end
88
+ end
89
+
90
+ alias_method :tagged?, :tagged
91
+ alias_method :addressable?, :addressable
92
+
93
+ def_delegators :path, :device_id, :site_id, :tagged
94
+
95
+ attr_accessor :path, :payload
96
+ def initialize(*args)
97
+ if args.count == 3
98
+ @path, @message, @payload = args
99
+ elsif (hash = args.first).is_a?(Hash)
100
+ path = hash.delete(:path)
101
+ payload = hash.delete(:payload)
102
+
103
+ check_valid_fields!(hash)
104
+
105
+ @message = Protocol::Message.new(hash)
106
+ self.payload = payload
107
+ self.path = path
108
+ @message.tagged = path.tagged? if path
109
+ else
110
+ @message = Protocol::Message.new
111
+ end
112
+ @message.msg_size = @message.num_bytes
113
+ @message.protocol = PROTOCOL_VERSION
114
+ rescue => ex
115
+ raise MessageError.new("Unable to initialize message with args: #{args.inspect} - #{ex}")
116
+ end
117
+
118
+ def payload=(payload)
119
+ @payload = payload
120
+ type_id = self.class.type_id_for_message_class(payload.class)
121
+ if type_id.nil?
122
+ raise UnmappedPayload.new("Unmapped payload class #{payload.class}")
123
+ end
124
+ @message.type = type_id
125
+ @message.payload = payload.pack
126
+ end
127
+
128
+ def pack
129
+ raise NoPayload if !payload
130
+ raise NoPath if !path
131
+ @message.raw_site = path.raw_site
132
+ @message.raw_target = path.raw_target
133
+ @message.tagged = path.tagged?
134
+ @message.msg_size = @message.num_bytes
135
+ @message.pack
136
+ end
137
+
138
+ def to_s
139
+ hash = {site: path.site_id}
140
+ if path.tagged?
141
+ hash[:tags] = path.tag_ids
142
+ hash[:tags] = 'all' if hash[:tags].empty?
143
+ else
144
+ hash[:device] = path.device_id
145
+ end
146
+ hash[:type] = payload.class.to_s.sub('LIFX::Protocol::', '')
147
+ hash[:addressable] = addressable? ? 'true' : 'false'
148
+ hash[:tagged] = path.tagged? ? 'true' : 'false'
149
+ hash[:at_time] = @message.at_time if @message.at_time && @message.at_time > 0
150
+ hash[:protocol] = protocol
151
+ hash[:payload] = payload.snapshot if payload
152
+ attrs = hash.map { |k, v| "#{k}=#{v}" }.join(' ')
153
+ %Q{#<LIFX::Message #{attrs}>}
154
+ end
155
+ alias_method :inspect, :to_s
156
+
157
+ protected
158
+
159
+ def check_valid_fields!(hash)
160
+ invalid_fields = hash.keys - self.class.valid_fields
161
+ if invalid_fields.count > 0
162
+ raise InvalidFields.new("Invalid fields for Message: #{invalid_fields.join(', ')}")
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,200 @@
1
+ require 'lifx/timers'
2
+ require 'lifx/transport_manager'
3
+ require 'lifx/routing_manager'
4
+ require 'lifx/tag_manager'
5
+ require 'lifx/light'
6
+ require 'lifx/protocol_path'
7
+
8
+ module LIFX
9
+ class NetworkContext
10
+ include Timers
11
+ include Logging
12
+ include Utilities
13
+ extend Forwardable
14
+
15
+ # NetworkContext stores lights and ties together TransportManager, TagManager and RoutingManager
16
+ attr_reader :transport_manager, :tag_manager, :routing_manager
17
+
18
+ def initialize(transport: :lan)
19
+ @devices = {}
20
+
21
+ @transport_manager = case transport
22
+ when :lan
23
+ TransportManager::LAN.new
24
+ else
25
+ raise ArgumentError.new("Unknown transport method: #{transport}")
26
+ end
27
+ @transport_manager.add_observer(self) do |message:, ip:, transport:|
28
+ handle_message(message, ip, transport)
29
+ end
30
+
31
+ reset!
32
+
33
+ @threads = []
34
+ @threads << initialize_timer_thread
35
+ initialize_periodic_refresh
36
+ initialize_message_rate_updater
37
+ end
38
+
39
+ def discover
40
+ @transport_manager.discover
41
+ end
42
+
43
+ def refresh
44
+ @routing_manager.refresh
45
+ end
46
+
47
+ def reset!
48
+ @routing_manager = RoutingManager.new(context: self)
49
+ @tag_manager = TagManager.new(context: self, tag_table: @routing_manager.tag_table)
50
+ end
51
+
52
+ def stop
53
+ @transport_manager.stop
54
+ @threads.each do |thread|
55
+ Thread.kill(thread)
56
+ end
57
+ end
58
+
59
+ # Sends a message to their destination(s)
60
+ # @param target: [Target] Target of the message
61
+ # @param payload: [Protocol::Payload] Message payload
62
+ # @param acknowledge: [Boolean] If recipients must acknowledge with a response
63
+ def send_message(target:, payload:, acknowledge: false)
64
+ paths = @routing_manager.resolve_target(target)
65
+
66
+ messages = paths.map do |path|
67
+ Message.new(path: path, payload: payload, acknowledge: acknowledge)
68
+ end
69
+
70
+ if within_sync?
71
+ Thread.current[:sync_messages].push(*messages)
72
+ return
73
+ end
74
+
75
+ messages.each do |message|
76
+ @transport_manager.write(message)
77
+ end
78
+ end
79
+
80
+ protected def within_sync?
81
+ !!Thread.current[:sync_enabled]
82
+ end
83
+
84
+ # Synchronize asynchronous set_color, set_waveform and set_power messages to multiple devices.
85
+ # You cannot use synchronous methods in the block
86
+ # @note This is alpha
87
+ # @yield Block to synchronize commands in
88
+ # @return [Float] Delay before messages are executed
89
+ def sync(&block)
90
+ if within_sync?
91
+ raise "You cannot nest sync"
92
+ end
93
+ messages = Thread.new do
94
+ Thread.current[:sync_enabled] = true
95
+ Thread.current[:sync_messages] = messages = []
96
+ block.call
97
+ Thread.current[:sync_enabled] = false
98
+ messages
99
+ end.join.value
100
+
101
+ time = nil
102
+ try_until -> { time } do
103
+ light = gateways.sample
104
+ time = light && light.time
105
+ end
106
+
107
+ delay = (messages.count + 1) * (1.0 / message_rate)
108
+ at_time = ((time.to_f + delay) * 1_000_000_000).to_i
109
+ messages.each do |m|
110
+ m.at_time = at_time
111
+ @transport_manager.write(m)
112
+ end
113
+ flush
114
+ delay
115
+ end
116
+
117
+ def flush(**options)
118
+ @transport_manager.flush(**options)
119
+ end
120
+
121
+ def register_device(device)
122
+ device_id = device.id
123
+ @devices[device_id] = device # What happens when there's already one registered?
124
+ end
125
+
126
+ def lights
127
+ LightCollection.new(context: self)
128
+ end
129
+
130
+ def all_lights
131
+ @devices.values
132
+ end
133
+
134
+ # Tags
135
+
136
+ def_delegators :@tag_manager, :tags,
137
+ :unused_tags,
138
+ :purge_unused_tags!,
139
+ :add_tag_to_device,
140
+ :remove_tag_from_device
141
+
142
+ def tags_for_device(device)
143
+ @routing_manager.tags_for_device_id(device.id)
144
+ end
145
+
146
+ def gateways
147
+ transport_manager.gateways.map(&:keys).flatten.map { |id| lights.with_id(id) }
148
+ end
149
+
150
+ def gateway_connections
151
+ transport_manager.gateways.map(&:values).flatten
152
+ end
153
+
154
+ protected
155
+
156
+ def handle_message(message, ip, transport)
157
+ logger.debug("<- #{self} #{transport}: #{message}")
158
+
159
+ @routing_manager.update_from_message(message)
160
+ if !message.tagged?
161
+ if @devices[message.device_id].nil?
162
+ device = Light.new(context: self, id: message.device_id, site_id: message.site_id)
163
+ register_device(device)
164
+ end
165
+ device = @devices[message.device_id]
166
+ device.handle_message(message, ip, transport)
167
+ end
168
+ end
169
+
170
+ def initialize_periodic_refresh
171
+ timers.every(10) do
172
+ refresh
173
+ end
174
+ end
175
+
176
+ def initialize_message_rate_updater
177
+ timers.every(5) do
178
+ missing_mesh_firmware = lights.select { |l| l.mesh_firmware(fetch: false).nil? }
179
+ if missing_mesh_firmware.count > 10
180
+ send_message(target: Target.new(broadcast: true), payload: Protocol::Device::GetMeshFirmware.new)
181
+ elsif missing_mesh_firmware.count > 0
182
+ missing_mesh_firmware.each { |l| l.send_message(Protocol::Device::GetMeshFirmware.new) }
183
+ else
184
+ @message_rate = lights.all? do |light|
185
+ m = light.mesh_firmware(fetch: false)
186
+ m && m >= '1.2'
187
+ end ? 20 : 5
188
+ gateway_connections.each do |connection|
189
+ connection.set_message_rate(@message_rate)
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ DEFAULT_MESSAGING_RATE = 5 # per second
196
+ def message_rate
197
+ @message_rate || 5
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,46 @@
1
+ module LIFX
2
+ module Observable
3
+ class ObserverCallbackMismatch < ArgumentError; end
4
+ def add_observer(obj, &callback)
5
+ if !callback_has_required_keys?(callback)
6
+ raise ObserverCallbackMismatch.new
7
+ end
8
+ observers[obj] = callback
9
+ end
10
+
11
+ def remove_observer(obj)
12
+ observers.delete(obj)
13
+ end
14
+
15
+ def notify_observers(**args)
16
+ observers.each do |_, callback|
17
+ callback.call(**args)
18
+ end
19
+ end
20
+
21
+ def callback_has_required_keys?(callback)
22
+ (required_keys_for_callback - required_keys_in_proc(callback)).empty?
23
+ end
24
+
25
+ def observer_callback_definition
26
+ nil
27
+ end
28
+
29
+ def required_keys_for_callback
30
+ @_required_keys_for_callback ||= begin
31
+ return [] if !observer_callback_definition
32
+ required_keys_in_proc(observer_callback_definition)
33
+ end
34
+ end
35
+
36
+ def required_keys_in_proc(proc)
37
+ proc.parameters.select do |type, _|
38
+ type == :keyreq
39
+ end.map(&:last)
40
+ end
41
+
42
+ def observers
43
+ @_observers ||= {}
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ module LIFX
2
+ module Protocol
3
+ module AddressFields
4
+ def AddressFields.included(mod)
5
+ mod.instance_eval do
6
+ hide :_reserved2
7
+ string :raw_target, length: 8
8
+ string :raw_site, length: 6
9
+ bool_bit1 :acknowledge
10
+ bit15le :_reserved2
11
+ end
12
+ end
13
+ end
14
+
15
+ class Address < BinData::Record
16
+ endian :little
17
+
18
+ include AddressFields
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,225 @@
1
+ # Generated code ahoy!
2
+ module LIFX
3
+ module Protocol
4
+ module Device
5
+ module Service
6
+ UDP = 1
7
+ TCP = 2
8
+ end
9
+
10
+ class SetSite < Payload
11
+ endian :little
12
+
13
+ string :site, length: 6
14
+ end
15
+
16
+ class GetPanGateway < Payload
17
+ endian :little
18
+
19
+ end
20
+
21
+ class StatePanGateway < Payload
22
+ endian :little
23
+
24
+ uint8 :service
25
+ uint32 :port
26
+ end
27
+
28
+ class GetTime < Payload
29
+ endian :little
30
+
31
+ end
32
+
33
+ class SetTime < Payload
34
+ endian :little
35
+
36
+ uint64 :time # Nanoseconds since epoch.
37
+ end
38
+
39
+ class StateTime < Payload
40
+ endian :little
41
+
42
+ uint64 :time # Nanoseconds since epoch.
43
+ end
44
+
45
+ class GetResetSwitch < Payload
46
+ endian :little
47
+
48
+ end
49
+
50
+ class StateResetSwitch < Payload
51
+ endian :little
52
+
53
+ uint8 :position
54
+ end
55
+
56
+ class GetMeshInfo < Payload
57
+ endian :little
58
+
59
+ end
60
+
61
+ class StateMeshInfo < Payload
62
+ endian :little
63
+
64
+ float :signal # Milliwatts.
65
+ uint32 :tx # Bytes.
66
+ uint32 :rx # Bytes.
67
+ int16 :mcu_temperature # Deci-celsius. 25.45 celsius is 2545
68
+ end
69
+
70
+ class GetMeshFirmware < Payload
71
+ endian :little
72
+
73
+ end
74
+
75
+ class StateMeshFirmware < Payload
76
+ endian :little
77
+
78
+ uint64 :build # Firmware build nanoseconds since epoch.
79
+ uint64 :install # Firmware install nanoseconds since epoch.
80
+ uint32 :version # Firmware human readable version.
81
+ end
82
+
83
+ class GetWifiInfo < Payload
84
+ endian :little
85
+
86
+ end
87
+
88
+ class StateWifiInfo < Payload
89
+ endian :little
90
+
91
+ float :signal # Milliwatts.
92
+ uint32 :tx # Bytes.
93
+ uint32 :rx # Bytes.
94
+ int16 :mcu_temperature # Deci-celsius. 25.45 celsius is 2545
95
+ end
96
+
97
+ class GetWifiFirmware < Payload
98
+ endian :little
99
+
100
+ end
101
+
102
+ class StateWifiFirmware < Payload
103
+ endian :little
104
+
105
+ uint64 :build # Firmware build nanoseconds since epoch.
106
+ uint64 :install # Firmware install nanoseconds since epoch.
107
+ uint32 :version # Firmware human readable version.
108
+ end
109
+
110
+ class GetPower < Payload
111
+ endian :little
112
+
113
+ end
114
+
115
+ class SetPower < Payload
116
+ endian :little
117
+
118
+ uint16 :level # 0 Standby. > 0 On.
119
+ end
120
+
121
+ class StatePower < Payload
122
+ endian :little
123
+
124
+ uint16 :level # 0 Standby. > 0 On.
125
+ end
126
+
127
+ class GetLabel < Payload
128
+ endian :little
129
+
130
+ end
131
+
132
+ class SetLabel < Payload
133
+ endian :little
134
+
135
+ string :label, length: 32, trim_padding: true
136
+ end
137
+
138
+ class StateLabel < Payload
139
+ endian :little
140
+
141
+ string :label, length: 32, trim_padding: true
142
+ end
143
+
144
+ class GetTags < Payload
145
+ endian :little
146
+
147
+ end
148
+
149
+ class SetTags < Payload
150
+ endian :little
151
+
152
+ uint64 :tags
153
+ end
154
+
155
+ class StateTags < Payload
156
+ endian :little
157
+
158
+ uint64 :tags
159
+ end
160
+
161
+ class GetTagLabels < Payload
162
+ endian :little
163
+
164
+ uint64 :tags
165
+ end
166
+
167
+ class SetTagLabels < Payload
168
+ endian :little
169
+
170
+ uint64 :tags
171
+ string :label, length: 32, trim_padding: true
172
+ end
173
+
174
+ class StateTagLabels < Payload
175
+ endian :little
176
+
177
+ uint64 :tags
178
+ string :label, length: 32, trim_padding: true
179
+ end
180
+
181
+ class GetVersion < Payload
182
+ endian :little
183
+
184
+ end
185
+
186
+ class StateVersion < Payload
187
+ endian :little
188
+
189
+ uint32 :vendor
190
+ uint32 :product
191
+ uint32 :version
192
+ end
193
+
194
+ class GetInfo < Payload
195
+ endian :little
196
+
197
+ end
198
+
199
+ class StateInfo < Payload
200
+ endian :little
201
+
202
+ uint64 :time # Nanoseconds since epoch.
203
+ uint64 :uptime # Nanoseconds since boot.
204
+ uint64 :downtime # Nanoseconds off last power cycle.
205
+ end
206
+
207
+ class GetMcuRailVoltage < Payload
208
+ endian :little
209
+
210
+ end
211
+
212
+ class StateMcuRailVoltage < Payload
213
+ endian :little
214
+
215
+ uint32 :voltage
216
+ end
217
+
218
+ class Reboot < Payload
219
+ endian :little
220
+
221
+ end
222
+
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,24 @@
1
+ module LIFX
2
+ module Protocol
3
+ module HeaderFields
4
+ def HeaderFields.included(mod)
5
+ mod.instance_eval do
6
+ hide :_reserved, :_reserved1
7
+
8
+ uint16 :msg_size
9
+ bit12le :protocol
10
+ bool_bit1 :addressable, value: true
11
+ bool_bit1 :tagged
12
+ bit2le :_reserved
13
+ uint32 :_reserved1
14
+ end
15
+ end
16
+ end
17
+
18
+ class Header < BinData::Record
19
+ endian :little
20
+
21
+ include HeaderFields
22
+ end
23
+ end
24
+ end