sparkle_motion 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE +22 -0
  4. data/README.md +47 -0
  5. data/bin/sm-discover +18 -0
  6. data/bin/sm-mark-lights +58 -0
  7. data/bin/sm-off +38 -0
  8. data/bin/sm-on +50 -0
  9. data/bin/sm-simulate +595 -0
  10. data/bin/sm-watch-memory +9 -0
  11. data/bin/sparkle-motion +48 -0
  12. data/config.yml +201 -0
  13. data/data/dynamic/bridges.csv +5 -0
  14. data/data/static/devices.csv +7 -0
  15. data/lib/sparkle_motion.rb +72 -0
  16. data/lib/sparkle_motion/config.rb +45 -0
  17. data/lib/sparkle_motion/env.rb +14 -0
  18. data/lib/sparkle_motion/http.rb +26 -0
  19. data/lib/sparkle_motion/hue/ssdp.rb +39 -0
  20. data/lib/sparkle_motion/launch_pad/color.rb +41 -0
  21. data/lib/sparkle_motion/launch_pad/widget.rb +187 -0
  22. data/lib/sparkle_motion/launch_pad/widgets/button.rb +29 -0
  23. data/lib/sparkle_motion/launch_pad/widgets/horizontal_slider.rb +43 -0
  24. data/lib/sparkle_motion/launch_pad/widgets/radio_group.rb +53 -0
  25. data/lib/sparkle_motion/launch_pad/widgets/toggle.rb +48 -0
  26. data/lib/sparkle_motion/launch_pad/widgets/vertical_slider.rb +43 -0
  27. data/lib/sparkle_motion/lazy_request_config.rb +87 -0
  28. data/lib/sparkle_motion/node.rb +80 -0
  29. data/lib/sparkle_motion/nodes/generator.rb +8 -0
  30. data/lib/sparkle_motion/nodes/generators/const.rb +15 -0
  31. data/lib/sparkle_motion/nodes/generators/perlin.rb +26 -0
  32. data/lib/sparkle_motion/nodes/generators/wave2.rb +20 -0
  33. data/lib/sparkle_motion/nodes/transform.rb +34 -0
  34. data/lib/sparkle_motion/nodes/transforms/contrast.rb +20 -0
  35. data/lib/sparkle_motion/nodes/transforms/range.rb +41 -0
  36. data/lib/sparkle_motion/nodes/transforms/spotlight.rb +35 -0
  37. data/lib/sparkle_motion/output.rb +69 -0
  38. data/lib/sparkle_motion/results.rb +78 -0
  39. data/lib/sparkle_motion/utility.rb +68 -0
  40. data/lib/sparkle_motion/vector2.rb +11 -0
  41. data/lib/sparkle_motion/version.rb +3 -0
  42. data/sparkle_motion.gemspec +44 -0
  43. metadata +178 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bbef0303576ccb86e06cecb1cf2be03219822d74
4
+ data.tar.gz: 6b94c6225bff6d537328dbbe1381c2d8014bc048
5
+ SHA512:
6
+ metadata.gz: cc7c4cce004cb6b4f4a4b116f345e13123fa42322ad3e21e3ea813cde541995d1fbb58906449e04237056d750f07ac92655fd477a4e66d18e7e9aaadde7b024e
7
+ data.tar.gz: b561b42d6323c11c4fc7a878fe2a05e30ba51e32483b3977f1d85e02647f25d41555291453059cd997fda48b472b5206baadb12786a2d2335cf37b480057dd6e
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changes
2
+
3
+ ## Next
4
+
5
+ ## 0.1.0 - 2015-09-12
6
+
7
+ * Initial release. Totally undocumented, annoyingly hard-coded, etc.
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Jon Frisby, http://MrJoy.com
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # Sparkle Motion
2
+
3
+ Generative event lighting system using Philips Hue and Novation Launchpad.
4
+
5
+
6
+ ## Installation
7
+
8
+ After cloning this repo, run:
9
+
10
+ ```bash
11
+ brew install portmidi
12
+ gem install sparkle_motion
13
+ sm-discover # Find all available bridges using SSDP.
14
+ # Create `config.yml`, and register the username(s) in it with the relevant bridges.
15
+ # You probably want to start with the one in this project's source repo as a baseline.
16
+ sm-mark-lights # Ensure your lights are physically arranged properly.
17
+ sm-on # Switch all the lights on, and set color to expected base state.
18
+ sparkle-motion # Run the simulation.
19
+ ```
20
+
21
+ __TODO: Document how to register user with hub(s).__
22
+
23
+
24
+ ## Usage
25
+
26
+ * `bin/sm-discover`: Discover all Philips Hue bridges on your network.
27
+ * `bin/sm-mark-lights`: Mark the lights distinctively to help ensure they're physically arranged properly.
28
+ * `bin/sm-off`: Turn all configured lights off.
29
+ * `bin/sm-on`: Turn all configured lights on, and set them to the base color.
30
+ * `bin/sm-simulate`: Run the effect system directly. You probably want `sparkle-motion` instead.
31
+ * `bin/sparkle-motion`: Runs the effect system with configuration settings for debugging, and restarts it if the kick-in-the-head button is pressed. See source for details.
32
+
33
+ ## Using the Code
34
+
35
+ * `examples/tictactoe.rb`: A simple example of the Novation Launchpad widgets, and how to use/extend them.
36
+ * `tools/chunker.rb`: Helper for churning through `*.raw` files and preparing them for visualization.
37
+ * `tools/color_scale.rb`: A small playground for defining color schemes for Novation LaunchPad widgets.
38
+
39
+
40
+ ## Debugging
41
+
42
+ * `bin/sm-watch-memory`: External monitor to keep an eye on the process size of `sm-simulate`. Useful for debugging memory allocations and GC pressure.
43
+
44
+
45
+ ## Configuration
46
+
47
+ __ TODO: Write me.__
data/bin/sm-discover ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ###############################################################################
4
+ # Early Initialization/Helpers
5
+ ###############################################################################
6
+ lib = File.expand_path("../../lib", __FILE__)
7
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
8
+ require "sparkle_motion"
9
+ SparkleMotion.init!("discover")
10
+ SparkleMotion.use_hue!(discovery: true)
11
+
12
+ def ip_atob(ip)
13
+ ip.split(/\./).map(&:to_i).pack("C4")
14
+ end
15
+
16
+ # TODO: Show MAC address so we can map the IPs to bridges sanely!
17
+ results = SparkleMotion::Hue::SSDP.new.scan
18
+ puts results.values.sort { |a, b| ip_atob(a) <=> ip_atob(b) }.join("\n")
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ ###############################################################################
3
+ # Early Initialization/Helpers
4
+ ###############################################################################
5
+ lib = File.expand_path("../../lib", __FILE__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+ require "sparkle_motion"
8
+ SparkleMotion.init!("mark_lights")
9
+ SparkleMotion.use_hue!(api: true)
10
+ LOGGER = SparkleMotion.logger
11
+
12
+ ###############################################################################
13
+ # Main Logic
14
+ ###############################################################################
15
+ # TODO: Use Novation Launchpad to be able to toggle lights.
16
+ def light_state(hue, index, num_lights)
17
+ target = (254 * (index / num_lights.to_f)).round
18
+ data = { "on" => true,
19
+ "hue" => hue,
20
+ "sat" => target,
21
+ "bri" => target }
22
+ with_transition_time(data, 0)
23
+ end
24
+
25
+ # TODO: Speed this up by setting on/hue via group message per bridge...
26
+ %w(main_lights dance_lights accent_lights).each do |group_name|
27
+ config = SparkleMotion::LightConfig.new(config: CONFIG, group: group_name)
28
+ url_req_map = {}
29
+ config.bridges.each do |bridge_name, bridge|
30
+ light_ids = config.lights[bridge_name].map(&:last)
31
+ hue = bridge["debug_hue"]
32
+ index = 0
33
+ num_lights = light_ids.length
34
+ requests = light_ids
35
+ .map do |lid|
36
+ url = hue_light_endpoint(bridge, lid)
37
+ color = light_state(hue, index, num_lights)
38
+ index += 1
39
+ url_req_map[url] = SparkleMotion::LazyRequestConfig.new(LOGGER, bridge, url) do
40
+ color
41
+ end
42
+ end
43
+
44
+ next unless requests.length > 0
45
+ while requests.length > 0
46
+ retry_queue = []
47
+ Curl::Multi.http(requests, MULTI_OPTIONS) do |easy|
48
+ if easy.response_code != 200 ||
49
+ easy.body =~ /error/
50
+ url = easy.url
51
+ LOGGER.error { "#{url} => #{easy.response_code} / #{easy.body}" }
52
+ retry_queue << url
53
+ end
54
+ end
55
+ requests = retry_queue.map { |url| url_req_map[url] }
56
+ end
57
+ end
58
+ end
data/bin/sm-off ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ###############################################################################
4
+ # Early Initialization/Helpers
5
+ ###############################################################################
6
+ lib = File.expand_path("../../lib", __FILE__)
7
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
8
+ require "sparkle_motion"
9
+ SparkleMotion.init!("off")
10
+ SparkleMotion.use_hue!(api: true)
11
+
12
+ ###############################################################################
13
+ # Helper Functions
14
+ ###############################################################################
15
+ def make_req_struct(url, data)
16
+ { method: :put,
17
+ url: url,
18
+ put_data: Oj.dump(data) }.merge(EASY_OPTIONS)
19
+ end
20
+
21
+ def hue_init(config)
22
+ make_req_struct(hue_group_endpoint(config, 0), "on" => false)
23
+ end
24
+
25
+ ###############################################################################
26
+ # Main
27
+ ###############################################################################
28
+ # TODO: Hoist this into a separate script.
29
+ # debug "Initializing lights..."
30
+ init_reqs = CONFIG["bridges"]
31
+ .values
32
+ .map { |config| hue_init(config) }
33
+ Curl::Multi.http(init_reqs, MULTI_OPTIONS) do |easy|
34
+ if easy.response_code != 200
35
+ SparkleMotion.logger.error { "Failed to initialize light: #{easy.url}" }
36
+ add(easy)
37
+ end
38
+ end
data/bin/sm-on ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ###############################################################################
4
+ # Early Initialization/Helpers
5
+ ###############################################################################
6
+ lib = File.expand_path("../../lib", __FILE__)
7
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
8
+ require "sparkle_motion"
9
+ SparkleMotion.init!("on")
10
+ SparkleMotion.use_hue!(api: true)
11
+
12
+ ###############################################################################
13
+ # Effect
14
+ #
15
+ # Tweak this to change the visual effect.
16
+ ###############################################################################
17
+ INIT_HUE = env_int("INIT_HUE", true) || 49_500
18
+ INIT_SAT = env_int("INIT_SAT", true) || 254
19
+ INIT_BRI = env_int("INIT_BRI", true) || 127
20
+
21
+ ###############################################################################
22
+ # Helper Functions
23
+ ###############################################################################
24
+ def make_req_struct(url, data)
25
+ { method: :put,
26
+ url: url,
27
+ put_data: Oj.dump(data) }.merge(EASY_OPTIONS)
28
+ end
29
+
30
+ def hue_init(config)
31
+ make_req_struct(hue_group_endpoint(config, 0), "on" => true,
32
+ "bri" => INIT_BRI,
33
+ "sat" => INIT_SAT,
34
+ "hue" => INIT_HUE)
35
+ end
36
+
37
+ ###############################################################################
38
+ # Main
39
+ ###############################################################################
40
+ # TODO: Hoist this into a separate script.
41
+ # debug "Initializing lights..."
42
+ init_reqs = CONFIG["bridges"]
43
+ .values
44
+ .map { |config| hue_init(config) }
45
+ Curl::Multi.http(init_reqs, MULTI_OPTIONS) do |easy|
46
+ if easy.response_code != 200
47
+ SparkleMotion.logger.error { "Failed to initialize light: #{easy.url}" }
48
+ add(easy)
49
+ end
50
+ end
data/bin/sm-simulate ADDED
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env ruby
2
+ # def bench_init!; @first_time = @last_time = Time.now.to_f; end
3
+
4
+ # def bench_snap!(depth = 0)
5
+ # t = Time.now.to_f
6
+ # elapsed = t - @last_time
7
+ # @last_time = t
8
+ # key = caller[depth].split(":")[0..1].join(":").split("/").last
9
+ # key = "TOTAL" if depth > 0
10
+ # puts "%s => %f sec (@%f)" % [key, elapsed, t]
11
+ # end
12
+
13
+ # def bench_end!
14
+ # @last_time = @first_time
15
+ # bench_snap!(2)
16
+ # end
17
+
18
+ # TODO: Run update across nodes from back to front for simulation rather than
19
+ # TODO: relying on a call-chain. This should make it easy to eliminate the
20
+ # TODO: `yield` usage and avoid associated allocations.
21
+
22
+ # TODO: Tool to read journaled debug data and produce a PNG.
23
+
24
+ # TODO: Deeper memory profiling to ensure this process can run for hours.
25
+
26
+ # TODO: Pick four downlights for the dance floor, and treat them as a separate
27
+ # TODO: simulation. Consider how spotlighting and the like will be relevant to
28
+ # TODO: them.
29
+
30
+ # TODO: Node to *clamp* brightness range so we can set the absolute limits at
31
+ # TODO: the end of the chain? Need to consider use-cases more thoroughly.
32
+ # TODO: May be useful for photographer!
33
+
34
+ # TODO: Rename widgets to clarify that they're LaunchPad-specific, and hoist all
35
+ # TODO: LaunchPad code into one namespace.
36
+
37
+ # TODO: Hoist all Hue code into one namespace.
38
+
39
+ # TODO: Possibly break out discovery / user registration into a separate gem?
40
+
41
+ # f = Fiber.new do
42
+ # meth(1) do
43
+ # Fiber.yield
44
+ # end
45
+ # end
46
+ # meth(2) do
47
+ # f.resume
48
+ # end
49
+ # f.resume
50
+ # p Thread.current[:name]
51
+
52
+ ###############################################################################
53
+ # Early Initialization/Helpers
54
+ ###############################################################################
55
+ bench_init! if defined?(bench_init!)
56
+ lib = File.expand_path("../../lib", __FILE__)
57
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
58
+ require "sparkle_motion"
59
+
60
+ SparkleMotion.init!("simulate")
61
+ # We load the following unconditionally because so much plugs into the graph
62
+ # we don't currently have a good way of decoupling things gracefully. So all
63
+ # USE_GRAPH=0 really means is that we don't run the simulation thread.
64
+ SparkleMotion.use_graph!
65
+
66
+ # Code loading / modular behavior configuration:
67
+ USE_LIGHTS = env_bool("USE_LIGHTS")
68
+ USE_INPUT = env_bool("USE_INPUT")
69
+ USE_SWEEP = env_bool("USE_SWEEP")
70
+ USE_GRAPH = env_bool("USE_GRAPH")
71
+ SparkleMotion.use_hue!(api: true) if USE_LIGHTS
72
+ SparkleMotion.use_widgets!
73
+ SparkleMotion.use_launchpad! if USE_INPUT
74
+
75
+ # Crufty common code:
76
+ require "sparkle_motion/output"
77
+
78
+ ###############################################################################
79
+ # Profiling and Debugging
80
+ ###############################################################################
81
+ LOGGER = SparkleMotion.logger
82
+ profile_run = ENV["PROFILE_RUN"]
83
+ PROFILE_RUN = (profile_run != "") ? profile_run : nil
84
+ SKIP_GC = env_bool("SKIP_GC")
85
+ DEBUG_FLAGS = Hash[(ENV["DEBUG_NODES"] || "")
86
+ .split(/\s*,\s*/)
87
+ .map(&:upcase)
88
+ .map { |nn| [nn, true] }]
89
+
90
+ ###############################################################################
91
+ # Shared State Setup
92
+ ###############################################################################
93
+ # TODO: Run all simulations, and use a mixer to blend between them...
94
+ num_lights = CONFIG["main_lights"].length
95
+ LIGHTS_FOR_THREADS = SparkleMotion::LightConfig.new(config: CONFIG, group: "main_lights")
96
+ INTERACTION = USE_INPUT ? Launchpad::Interaction.new(use_threads: false) : nil
97
+ INT_STATES = []
98
+ NODES = {}
99
+ PENDING_COMMANDS = Queue.new
100
+ CURRENT_STATE = {}
101
+ STATE_FILENAME = "/tmp/sparkle-motion.state"
102
+ SKIP_STATE_PERSISTENCE = [false]
103
+ HAVE_STATE_FILE = File.exist?(STATE_FILENAME)
104
+ if HAVE_STATE_FILE
105
+ age = Time.now.to_f - File.stat(STATE_FILENAME).mtime.to_f
106
+ if age > 3600
107
+ LOGGER.warn do
108
+ "#{STATE_FILENAME} is #{age} seconds old!"\
109
+ " This is probably not what you want, but you're the boss..."
110
+ end
111
+ end
112
+ CURRENT_STATE.merge!(YAML.load(File.read(STATE_FILENAME)))
113
+ end
114
+
115
+ def update_state!(key, value)
116
+ old_value = CURRENT_STATE[key]
117
+ return if old_value == value
118
+ CURRENT_STATE[key] = value
119
+ return if SKIP_STATE_PERSISTENCE[0]
120
+ LOGGER.debug { "Persisting control state." }
121
+ # TODO: Maybe keep the file open, and rewind?
122
+ File.open(STATE_FILENAME, "w") do |fh|
123
+ fh.write(CURRENT_STATE.to_yaml)
124
+ end
125
+ end
126
+
127
+ ###############################################################################
128
+ # Simulation Graph Configuration / Setup
129
+ ###############################################################################
130
+ # Root nodes (don't act as modifiers on other nodes' output):
131
+ n_cfg = CONFIG["simulation"]["nodes"]
132
+ p_speed = n_cfg["perlin"]["speed"]
133
+ NODES["PERLIN"] = SparkleMotion::Nodes::Generators::Perlin.new(lights: num_lights, speed: p_speed)
134
+ last = NODES["PERLIN"]
135
+
136
+ # Transform nodes (act as a chain of modifiers):
137
+ c_cfg = n_cfg["contrast"]
138
+ c_fun = c_cfg["function"]
139
+ c_iter = c_cfg["iterations"]
140
+ NODES["STRETCHED"] = last = SparkleMotion::Nodes::Transforms::Contrast.new(function: c_fun,
141
+ iterations: c_iter,
142
+ source: last)
143
+ # Create one control group here per "quadrant"...
144
+ intensity_cfg = CONFIG["simulation"]["controls"]["intensity"]
145
+ LIGHTS_FOR_THREADS.bridges.each_with_index do |(bridge_name, _bridge_config), idx|
146
+ mask = LIGHTS_FOR_THREADS.masks[bridge_name]
147
+ int_vals = intensity_cfg["values"]
148
+ last = SparkleMotion::Nodes::Transforms::Range.new(mid_point: int_vals[0][0],
149
+ delta: int_vals[0][1],
150
+ source: last,
151
+ mask: mask,
152
+ logger: LOGGER)
153
+ NODES["SHIFTED_#{idx}"] = last
154
+
155
+ int_colors = intensity_cfg["colors"]
156
+ pos = intensity_cfg["positions"][idx]
157
+ int_widget = Kernel.const_get(intensity_cfg["widget"])
158
+ int_key = "SHIFTED_#{idx}"
159
+ INT_STATES[idx] = int_widget.new(launchpad: INTERACTION,
160
+ x: pos[0],
161
+ y: pos[1],
162
+ size: intensity_cfg["size"],
163
+ on: int_colors["on"],
164
+ off: int_colors["off"],
165
+ down: int_colors["down"],
166
+ on_change: proc do |val|
167
+ ival = int_vals[val]
168
+ LOGGER.info { "Intensity[#{idx},#{val}]: #{ival}" }
169
+ NODES[int_key].set_range(ival[0], ival[1])
170
+ update_state!(int_key, val)
171
+ end)
172
+ end
173
+
174
+ SAT_STATES = []
175
+ sat_cfg = CONFIG["simulation"]["controls"]["saturation"]
176
+ sat_len = sat_cfg["transition"]
177
+ sat_colors = sat_cfg["colors"]
178
+ sat_vals = sat_cfg["values"]
179
+ sat_grps = sat_cfg["groups"]
180
+ sat_widget = Kernel.const_get(sat_cfg["widget"])
181
+ sat_cfg["positions"].each_with_index do |(xx, yy), idx|
182
+ sat_grp_info = sat_grps[idx]
183
+ sat_bridge = CONFIG["bridges"][sat_grp_info[0]]
184
+ sat_group = sat_grp_info[1]
185
+ sat_key = "SAT_STATES[#{idx}]"
186
+ SAT_STATES << sat_widget.new(launchpad: INTERACTION,
187
+ x: xx,
188
+ y: yy,
189
+ size: sat_cfg["size"],
190
+ on: sat_colors["on"],
191
+ off: sat_colors["off"],
192
+ down: sat_colors["down"],
193
+ on_change: proc do |val|
194
+ ival = sat_vals[val]
195
+ LOGGER.info { "Saturation[#{idx},#{val}]: #{ival}" }
196
+ data = with_transition_time({ "sat" => ival }, sat_len)
197
+ req = { method: :put,
198
+ url: hue_group_endpoint(sat_bridge, sat_group),
199
+ put_data: Oj.dump(data) }.merge(EASY_OPTIONS)
200
+ PENDING_COMMANDS << req
201
+ update_state!(sat_key, val)
202
+ end)
203
+ end
204
+
205
+ last = NODES["SPOTLIT"] = SparkleMotion::Nodes::Transforms::Spotlight.new(source: last)
206
+ FINAL_RESULT = last # The end node that will be rendered to the lights.
207
+ sl_cfg = CONFIG["simulation"]["controls"]["spotlighting"]
208
+ sl_colors = sl_cfg["colors"]
209
+ sl_map_raw = sl_cfg["mappings"]
210
+ sl_pos = sl_map_raw.flatten
211
+ sl_key = "SPOTLIT"
212
+ sl_size = [sl_map_raw.map(&:length).sort[-1], sl_map_raw.length]
213
+ SL_STATE = SparkleMotion::LaunchPad::Widgets::RadioGroup.new(launchpad: INTERACTION,
214
+ x: sl_cfg["x"],
215
+ y: sl_cfg["y"],
216
+ size: sl_size,
217
+ on: sl_colors["on"],
218
+ off: sl_colors["off"],
219
+ down: sl_colors["down"],
220
+ on_select: proc do |x|
221
+ LOGGER.info { "Spot ##{sl_pos[x]}" }
222
+ NODES[sl_key].spotlight!(sl_pos[x])
223
+ update_state!(sl_key, x)
224
+ end,
225
+ on_deselect: proc do
226
+ LOGGER.info { "Spot Off" }
227
+ NODES["SPOTLIT"].clear!
228
+ update_state!(sl_key, nil)
229
+ end)
230
+
231
+ NODES.each do |name, node|
232
+ node.debug = DEBUG_FLAGS[name]
233
+ end
234
+
235
+ ###############################################################################
236
+ # Operational Configuration
237
+ ###############################################################################
238
+ ITERATIONS = env_int("ITERATIONS", true) || 0
239
+ TIME_TO_DIE = [false, :terminate]
240
+
241
+ ###############################################################################
242
+ # Profiling Support
243
+ ###############################################################################
244
+ # TODO: Make this optional.
245
+ e_cfg = CONFIG["simulation"]["controls"]["exit"]
246
+ EXIT_BUTTON = SparkleMotion::LaunchPad::Widgets::Button.new(launchpad: INTERACTION,
247
+ position: e_cfg["position"].to_sym,
248
+ color: e_cfg["colors"]["color"],
249
+ down: e_cfg["colors"]["down"],
250
+ on_press: lambda do |value|
251
+ return unless value != 0
252
+ LOGGER.unknown { "Kick!" }
253
+ TIME_TO_DIE[1] = :restart
254
+ TIME_TO_DIE[0] = true
255
+ end)
256
+
257
+ def start_ruby_prof!
258
+ return unless PROFILE_RUN == "ruby-prof"
259
+
260
+ SparkleMotion.logger.unknown { "Enabling ruby-prof, be careful!" }
261
+ require "ruby-prof"
262
+ RubyProf.measure_mode = RubyProf.const_get(ENV.fetch("RUBY_PROF_MODE").upcase)
263
+ RubyProf.start
264
+ end
265
+
266
+ def stop_ruby_prof!
267
+ return unless PROFILE_RUN == "ruby-prof"
268
+
269
+ result = RubyProf.stop
270
+ printer = RubyProf::CallStackPrinter.new(result)
271
+ File.open("tmp/results.html", "w") do |fh|
272
+ printer.print(fh)
273
+ end
274
+ end
275
+
276
+ ###############################################################################
277
+ # Main Simulation
278
+ ###############################################################################
279
+ def clear_board!
280
+ return unless defined?(Launchpad)
281
+
282
+ # TODO: Generalize this to deal with the entire board.
283
+
284
+ # TODO: Hoist setup / teardown of the board into separate binaries and
285
+ # TODO: don't do it from here to avoid startup overhead!
286
+
287
+ INT_STATES.map(&:blank)
288
+ sleep 0.01 # 88 updates/sec input limit!
289
+ SAT_STATES.map(&:blank)
290
+ sleep 0.01 # 88 updates/sec input limit!
291
+ SL_STATE.blank
292
+ sleep 0.01
293
+ EXIT_BUTTON.blank
294
+ end
295
+
296
+ def any_in_state(threads, state)
297
+ threads = Array(threads)
298
+ threads.find { |th| th.status != state }
299
+ end
300
+
301
+ def wait_for(threads, state)
302
+ threads = Array(threads)
303
+ sleep 0.01 while any_in_state(threads, state)
304
+ end
305
+
306
+ def without_persistence(&block)
307
+ SKIP_STATE_PERSISTENCE[0] = defined?(LaunchPad) ? true : false
308
+ block.call
309
+ ensure
310
+ SKIP_STATE_PERSISTENCE[0] = false
311
+ end
312
+
313
+ def setup_intensity_controls!
314
+ INT_STATES.each_with_index do |ctrl, idx|
315
+ ctrl.update(CURRENT_STATE.fetch("SHIFTED_#{idx}", ctrl.max_v / 2))
316
+ end
317
+ end
318
+
319
+ def setup_saturation_controls!
320
+ SAT_STATES.each_with_index do |ctrl, idx|
321
+ ctrl.update(CURRENT_STATE.fetch("SAT_STATES[#{idx}]", ctrl.max_v))
322
+ end
323
+ end
324
+
325
+ def setup_spotlight_controls!
326
+ SL_STATE.update(CURRENT_STATE.fetch("SPOTLIT", nil))
327
+ end
328
+
329
+ def setup_exit_controls!
330
+ EXIT_BUTTON.update(false)
331
+ end
332
+
333
+ def guarded_thread(name, &block)
334
+ Thread.new { guard_call(name, &block) }
335
+ end
336
+
337
+ def launch_input_thread!
338
+ without_persistence do
339
+ setup_intensity_controls!
340
+ setup_saturation_controls!
341
+ setup_spotlight_controls!
342
+ setup_exit_controls!
343
+ end
344
+ # Don't send updates from our attempts at setting things up when we're
345
+ # picking up where we left of...
346
+ PENDING_COMMANDS.clear if HAVE_STATE_FILE
347
+
348
+ return nil unless defined?(Launchpad)
349
+
350
+ guarded_thread("Input Handler") do
351
+ Thread.stop
352
+ INTERACTION.start
353
+ end
354
+ end
355
+
356
+ def launch_graph_thread!
357
+ return nil unless USE_GRAPH
358
+ guarded_thread("Graph Renderer") do
359
+ Thread.stop
360
+
361
+ loop do
362
+ t = Time.now.to_f
363
+ FINAL_RESULT.update(t)
364
+ el = Time.now.to_f - t
365
+ # Try to adhere to a specific update frequency...
366
+ sleep SparkleMotion::Node::FRAME_PERIOD - el if el < SparkleMotion::Node::FRAME_PERIOD
367
+ end
368
+ end
369
+ end
370
+
371
+ def add_group_command!(config, data)
372
+ # TODO: Hoist the hash into something reusable?
373
+ PENDING_COMMANDS << { method: :put,
374
+ url: hue_group_endpoint(config, 0),
375
+ put_data: Oj.dump(data) }.merge(EASY_OPTIONS)
376
+ end
377
+
378
+ def launch_sweep_thread!(sweep_cfg)
379
+ return nil unless USE_LIGHTS && USE_SWEEP
380
+ hues = sweep_cfg["values"]
381
+ sweep_len = sweep_cfg["transition"]
382
+ sweep_wait = sweep_len
383
+ if sweep_len < 0
384
+ sweep_wait = sweep_len.abs
385
+ sweep_len = 0.0
386
+ end
387
+ guarded_thread("Sweeper") do
388
+ Thread.stop
389
+
390
+ loop do
391
+ before_time = Time.now.to_f
392
+ idx = ((before_time / sweep_wait) % hues.length).floor
393
+ data = with_transition_time({ "hue" => hues[idx] }, sweep_len)
394
+ CONFIG["bridges"].each do |(_name, config)|
395
+ add_group_command!(config, data)
396
+ end
397
+
398
+ elapsed = Time.now.to_f - before_time
399
+ sleep sweep_wait - elapsed if elapsed < sweep_wait
400
+ end
401
+ end
402
+ end
403
+
404
+ def launch_dummy_light_threads!
405
+ threads = []
406
+ threads << guarded_thread("Dummy Thread") do
407
+ Thread.stop
408
+ iters = ITERATIONS
409
+ iters = 20 if iters == 0
410
+ sleep iters * 5.0
411
+ TIME_TO_DIE[1] = :terminate
412
+ TIME_TO_DIE[0] = true
413
+ end
414
+ threads
415
+ end
416
+
417
+ def launch_light_threads!(cfg, global_results)
418
+ threads = []
419
+ return launch_dummy_light_threads! unless USE_LIGHTS
420
+
421
+ transition = cfg["transition"]
422
+ debug = DEBUG_FLAGS["OUTPUT"]
423
+ threads += LIGHTS_FOR_THREADS.bridges.map do |(bridge_name, config)|
424
+ guarded_thread(bridge_name) do
425
+ lights = LIGHTS_FOR_THREADS.lights[bridge_name]
426
+ stats = defined?(Results) ? Results.new(logger: LOGGER) : nil
427
+ iterator = (ITERATIONS > 0) ? ITERATIONS.times : loop
428
+
429
+ LOGGER.unknown do
430
+ light_list = lights.map(&:first).join(", ")
431
+ "#{bridge_name}: Thread set to handle #{lights.count} lights (#{light_list})."
432
+ end
433
+
434
+ Thread.stop
435
+
436
+ requests = lights
437
+ .map do |(idx, lid)|
438
+ url = hue_light_endpoint(config, lid)
439
+ SparkleMotion::LazyRequestConfig.new(LOGGER, config, url, stats, debug: debug) do
440
+ # TODO: Recycle this hash?
441
+ data = { "bri" => (FINAL_RESULT[idx] * 254).to_i }
442
+ with_transition_time(data, transition)
443
+ end
444
+ end
445
+
446
+ iterator.each do
447
+ Curl::Multi.http(requests.dup, MULTI_OPTIONS) do
448
+ end
449
+
450
+ global_results.add_from(stats) if global_results
451
+ stats.clear! if stats
452
+ sleep 0.1
453
+ end
454
+ end
455
+ end
456
+
457
+ threads << guarded_thread("Command Queue") do
458
+ Thread.stop
459
+ loop do
460
+ sleep 0.05 while PENDING_COMMANDS.empty?
461
+
462
+ # TODO: Gather stats about success/failure...
463
+ # results = Results.new(logger: LOGGER)
464
+ # global_results.add_from(results)
465
+ # results.clear!
466
+
467
+ requests = []
468
+ requests << PENDING_COMMANDS.pop until PENDING_COMMANDS.empty?
469
+ next if requests.length == 0
470
+ LOGGER.debug { "Processing #{requests.length} pending commands." }
471
+ Curl::Multi.http(requests, MULTI_OPTIONS) do |easy|
472
+ rc = easy.response_code
473
+ body = easy.body
474
+ next if rc >= 200 && rc < 400 && body !~ /error/
475
+ LOGGER.warn { "Problem processing command: #{easy.url} => #{rc}; #{body}" }
476
+ end
477
+ end
478
+ end
479
+ end
480
+
481
+ def launch_all_threads!(sim_cfg, global_results)
482
+ tmp = { input: [launch_input_thread!].compact,
483
+ graph: [launch_graph_thread!].compact,
484
+ sweep: [launch_sweep_thread!(sim_cfg["sweep"])].compact,
485
+ lights: launch_light_threads!(sim_cfg["output"], global_results) }
486
+ tmp[:all] = tmp.values.flatten.compact
487
+ tmp
488
+ end
489
+
490
+ def pre_init!
491
+ trap("INT") do
492
+ TIME_TO_DIE[0] = true
493
+ # If we hit ctrl-c, it'll show up on the terminal, mucking with log output right when we're
494
+ # about to produce reports. This annoys me, so I'm working around it:
495
+ puts
496
+ end
497
+ Thread.abort_on_exception = false
498
+ end
499
+
500
+ def nodes_under_debug
501
+ NODES.select { |name, _node| DEBUG_FLAGS[name] }
502
+ end
503
+
504
+ def debugging?
505
+ nodes_under_debug.length > 0 || DEBUG_FLAGS["OUTPUT"] || PROFILE_RUN
506
+ end
507
+
508
+ def wait_for_threads!(threads)
509
+ LOGGER.unknown { "Waiting for threads to finish initializing..." }
510
+ wait_for(threads, "sleep")
511
+ end
512
+
513
+ def init!(global_results)
514
+ LOGGER.unknown { "Initializing system..." }
515
+ if SKIP_GC
516
+ LOGGER.unknown { "Disabling garbage collection! BE CAREFUL!" }
517
+ GC.disable
518
+ end
519
+ global_results.begin! if global_results
520
+ start_ruby_prof!
521
+ FINAL_RESULT.update(Time.now.to_f)
522
+ end
523
+
524
+ def wake!(threads)
525
+ LOGGER.unknown { "Final setup done, waking threads..." }
526
+ threads.each(&:run)
527
+ end
528
+
529
+ def spin!(threads)
530
+ LOGGER.unknown { "Waiting for the world to end..." }
531
+ loop do
532
+ # Someone hit the exit button:
533
+ break if TIME_TO_DIE[0]
534
+ # We went through and did `ITERATIONS` update loops over the lights:
535
+ # ... the `- 1` is for the command queue thread!
536
+ unfinished = (threads.length - threads.count { |th| th.status == false }) - 1
537
+ break if USE_LIGHTS && unfinished == 0
538
+ sleep 0.25
539
+ end
540
+ end
541
+
542
+ def stop!(threads)
543
+ LOGGER.unknown { "Stopping threads..." }
544
+ %i(lights sweep graph input).each do |thread_group|
545
+ threads[thread_group].each(&:terminate)
546
+ end
547
+ end
548
+
549
+ def main
550
+ pre_init!
551
+
552
+ announce_iteration_config(ITERATIONS)
553
+
554
+ global_results = defined?(Results) ? Results.new(logger: LOGGER) : nil
555
+ threads = launch_all_threads!(CONFIG["simulation"], global_results)
556
+
557
+ wait_for_threads!(threads[:all])
558
+ init!(global_results)
559
+ wake!(threads[:all])
560
+ spin!(threads[:lights])
561
+ stop!(threads)
562
+
563
+ LOGGER.unknown { "Doing final shutdown..." }
564
+ global_results.done! if global_results
565
+ clear_board!
566
+
567
+ print_results(global_results) if global_results
568
+ dump_debug_data!
569
+ exit 127 if TIME_TO_DIE[1] == :restart
570
+ end
571
+
572
+ def profile!(&block)
573
+ unless PROFILE_RUN == "memory_profiler"
574
+ block.call
575
+ return
576
+ end
577
+
578
+ LOGGER.unknown { "Enabling memory_profiler, be careful!" }
579
+ require "memory_profiler"
580
+ report = MemoryProfiler.report do
581
+ block.call
582
+ LOGGER.unknown { "Preparing MemoryProfiler report." }
583
+ end
584
+ LOGGER.unknown { "Dumping MemoryProfiler report." }
585
+ # TODO: Dump this to a file...
586
+ report.pretty_print
587
+ end
588
+
589
+ ###############################################################################
590
+ # Launcher
591
+ ###############################################################################
592
+ profile! do
593
+ bench_end! if defined?(bench_end!)
594
+ main
595
+ end