road_to_rubykaigi 0.2.1 → 2026.0.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.road_to_rubykaigi.sample +26 -0
  3. data/Rakefile +1 -3
  4. data/examples/accelerometer.rb +131 -0
  5. data/lib/road_to_rubykaigi/ansi.rb +8 -0
  6. data/lib/road_to_rubykaigi/calibration_bar.rb +84 -0
  7. data/lib/road_to_rubykaigi/calibration_result.rb +70 -0
  8. data/lib/road_to_rubykaigi/calibration_sampler.rb +52 -0
  9. data/lib/road_to_rubykaigi/calibration_screen.rb +171 -0
  10. data/lib/road_to_rubykaigi/config.rb +164 -0
  11. data/lib/road_to_rubykaigi/event_dispatcher.rb +3 -0
  12. data/lib/road_to_rubykaigi/fireworks.rb +2 -2
  13. data/lib/road_to_rubykaigi/game.rb +2 -0
  14. data/lib/road_to_rubykaigi/game_server.rb +91 -0
  15. data/lib/road_to_rubykaigi/graphics/2026/map.txt +30 -0
  16. data/lib/road_to_rubykaigi/graphics/2026/mask.txt +30 -0
  17. data/lib/road_to_rubykaigi/graphics/map.rb +2 -1
  18. data/lib/road_to_rubykaigi/graphics/mask.rb +1 -1
  19. data/lib/road_to_rubykaigi/graphics/player.rb +78 -48
  20. data/lib/road_to_rubykaigi/graphics/player.txt +4 -0
  21. data/lib/road_to_rubykaigi/jump_detector.rb +125 -0
  22. data/lib/road_to_rubykaigi/manager/audio_manager.rb +8 -2
  23. data/lib/road_to_rubykaigi/manager/game_manager.rb +3 -2
  24. data/lib/road_to_rubykaigi/opening_screen.rb +87 -16
  25. data/lib/road_to_rubykaigi/score_board.rb +6 -1
  26. data/lib/road_to_rubykaigi/serial_reader.rb +76 -0
  27. data/lib/road_to_rubykaigi/signal_interpreter.rb +199 -0
  28. data/lib/road_to_rubykaigi/signal_window.rb +139 -0
  29. data/lib/road_to_rubykaigi/sprite/bonus.rb +83 -45
  30. data/lib/road_to_rubykaigi/sprite/deadline.rb +2 -2
  31. data/lib/road_to_rubykaigi/sprite/enemy.rb +28 -21
  32. data/lib/road_to_rubykaigi/sprite/player.rb +51 -12
  33. data/lib/road_to_rubykaigi/version.rb +1 -1
  34. data/lib/road_to_rubykaigi.rb +42 -19
  35. data/public/controller.html +54 -0
  36. data/public/controller.rb +137 -0
  37. data/public/init.iife.js +122 -0
  38. data/public/picoruby.js +2 -0
  39. data/public/picoruby.wasm +0 -0
  40. data/tmp/.keep +0 -0
  41. metadata +41 -8
  42. data/.standard.yml +0 -29
  43. /data/lib/road_to_rubykaigi/graphics/{demo-map.txt → 2025/demo-map.txt} +0 -0
  44. /data/lib/road_to_rubykaigi/graphics/{demo-mask.txt → 2025/demo-mask.txt} +0 -0
  45. /data/lib/road_to_rubykaigi/graphics/{map.txt → 2025/map.txt} +0 -0
  46. /data/lib/road_to_rubykaigi/graphics/{mask.txt → 2025/mask.txt} +0 -0
@@ -5,62 +5,92 @@ module RoadToRubykaigi
5
5
  LEFT = RoadToRubykaigi::Sprite::Player::LEFT
6
6
  FILE_PATH = "player.txt"
7
7
 
8
+ STATUSES_BY_POSTURE = {
9
+ standup: %i[normal stunned running],
10
+ crouching: %i[normal stunned],
11
+ }.freeze
12
+ FRAMES = [1, 2].freeze
13
+
14
+ Direction = Data.define(:value) do
15
+ def right? = value == RIGHT
16
+ def name = right? ? 'RIGHT' : 'LEFT'
17
+ end
18
+ DIRECTIONS = [Direction.new(RIGHT), Direction.new(LEFT)].freeze
19
+
20
+ Variant = Data.define(:posture, :status, :direction, :default_frames, :attack_frames) do
21
+ class << self
22
+ def build(posture:, status:, direction:, parts:)
23
+ new(
24
+ posture: posture,
25
+ status: status,
26
+ direction: direction,
27
+ default_frames: FRAMES.map { |frame| default_frame(posture, status, direction, frame, parts) },
28
+ attack_frames: FRAMES.map { |frame| attack_frame(posture, status, direction, frame, parts) },
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def face_key(posture, status, direction) = :"face_#{posture}_#{status}_#{direction.name}"
35
+ def foot_key(status, frame) = :"foot_#{status == :running ? :normal : status}_#{frame}"
36
+ def crouching?(posture) = posture == :crouching
37
+
38
+ def default_frame(posture, status, direction, frame, parts)
39
+ [
40
+ parts[:head],
41
+ parts[face_key(posture, status, direction)],
42
+ crouching?(posture) ? nil : parts[foot_key(status, frame)],
43
+ ].compact
44
+ end
45
+
46
+ def attack_frame(posture, status, direction, frame, parts)
47
+ head = parts[:head]
48
+ face = parts[face_key(posture, status, direction)]
49
+ foot = crouching?(posture) ? nil : parts[foot_key(status, frame)]
50
+
51
+ if direction.right?
52
+ [head + " ".chars, face + "_◢◤".chars, foot && foot + " ".chars].compact
53
+ else
54
+ [" ".chars + head, "◥◣_".chars + face, foot && " ".chars + foot].compact
55
+ end
56
+ end
57
+ end
58
+ end
59
+
8
60
  class << self
9
61
  def character(posture:, status:, direction:, attack_mode:)
10
- load_data
11
- characters = attack_mode ? @attack_characters : @default_characters
12
- characters[posture][status][direction]
62
+ load_data unless @variants
63
+
64
+ variant = @variants[[posture, status, direction]]
65
+ attack_mode ? variant.attack_frames : variant.default_frames
13
66
  end
14
67
 
15
68
  private
16
69
 
17
70
  def load_data
18
- return if @default_characters
19
- index = { "RIGHT" => Sprite::Player::RIGHT, "LEFT" => Sprite::Player::LEFT }
20
- hash = Hash.new { |h, k| h[k] = [] }
21
- @default_characters = {
22
- standup: { normal: hash.dup, stunned: hash.dup },
23
- crouching: { normal: hash.dup, stunned: hash.dup },
24
- }
25
- @attack_characters = {
26
- standup: { normal: hash.dup, stunned: hash.dup },
27
- crouching: { normal: hash.dup, stunned: hash.dup },
28
- }
29
- set = {}
30
- File.read("#{__dir__}/#{FILE_PATH}").scan(/# (\w+)\n(.*)\n/) do |type, line|
31
- set[type.to_sym] = line.chars.map do |char|
32
- fullwidth?(char) ? [char, ANSI::NULL] : char
33
- end.flatten
34
- end
35
-
36
- %i[standup crouching].each do |posture|
37
- %i[normal stunned].each do |status|
38
- index.each do |(direction, direction_value)|
39
- (1..2).each do |i|
40
- @default_characters[posture][status][direction_value] << (
41
- [
42
- set[:head],
43
- set[:"face_#{posture}_#{status}_#{direction}"],
44
- posture == :crouching ? nil : set[:"foot_#{status}_#{i}"],
45
- ].compact
46
- )
47
- @attack_characters[posture][status][direction_value] << (
48
- direction == "RIGHT" ?
49
- [
50
- set[:head] + " ".chars,
51
- set[:"face_#{posture}_#{status}_#{direction}"] + "_◢◤".chars,
52
- posture == :crouching ? nil : set[:"foot_#{status}_#{i}"] + " ".chars,
53
- ].compact :
54
- [
55
- " ".chars + set[:head],
56
- "◥◣_".chars + set[:"face_#{posture}_#{status}_#{direction}"],
57
- posture == :crouching ? nil : " ".chars + set[:"foot_#{status}_#{i}"],
58
- ].compact
59
- )
60
- end
61
- end
71
+ parts = load_parts
72
+ @variants = STATUSES_BY_POSTURE.flat_map do |posture, statuses|
73
+ statuses.product(DIRECTIONS).map do |status, direction|
74
+ [
75
+ [posture, status, direction.value],
76
+ Variant.build(posture:, status:, direction:, parts:),
77
+ ]
62
78
  end
63
- end
79
+ end.to_h
80
+ end
81
+
82
+ # @return [Hash{Symbol => Array<String>}]
83
+ # {head: ["╭", "─", "─", "─", "─", "─", "─", "╮"],
84
+ # foot_normal_1: ["╰", "─", "∪", "─", "─", "─", "∪", "╯"],
85
+ # face_standup_normal_RIGHT: ["│", "。", "・", "\u0000", "◡", "・", "\u0000", "│"], ...
86
+ def load_parts
87
+ File.read("#{__dir__}/#{FILE_PATH}")
88
+ .scan(/^# (\w+)\n(.*)$/)
89
+ .to_h { |name, line| [name.to_sym, expand_cells(line)] }
90
+ end
91
+
92
+ def expand_cells(line)
93
+ line.chars.flat_map { |char| fullwidth?(char) ? [char, ANSI::NULL] : [char] }
64
94
  end
65
95
 
66
96
  def fullwidth?(char)
@@ -12,6 +12,10 @@
12
12
  │。・◡・│
13
13
  # face_standup_normal_LEFT
14
14
  │・◡・。│
15
+ # face_standup_running_RIGHT
16
+ │。> ◡ <│
17
+ # face_standup_running_LEFT
18
+ │> ◡ <。│
15
19
  # face_standup_stunned_RIGHT
16
20
  │ ´×⌓× │
17
21
  # face_standup_stunned_LEFT
@@ -0,0 +1,125 @@
1
+ module RoadToRubykaigi
2
+ # Detects squat-jumps from the accelerometer stream.
3
+ #
4
+ # Axis-agnostic: consumes the gravity-compensated vertical acceleration
5
+ # instead of any single raw axis. vertical_acceleration = projection of the
6
+ # sample onto the gravity vector, minus |gravity|. So rest = 0, upward
7
+ # proper acceleration > 0, downward (or free fall) < 0.
8
+ #
9
+ # Rule: loaded hold + bottom turnover. Running strides briefly peak the
10
+ # vertical acceleration above the loaded threshold at each footstrike.
11
+ # A squat-jump holds it above the threshold noticeably longer. Fire at the
12
+ # bottom of that span (slope becomes clearly negative), whether the signal
13
+ # is still above the threshold or has just crossed back below.
14
+ #
15
+ # Firing at the bottom of the load (not at landing or at full takeoff)
16
+ # minimizes latency.
17
+ #
18
+ # The loaded span must start from an up-crossing (a prior sample at or
19
+ # below the threshold) inside the buffer. Otherwise a stationary sensor
20
+ # reading near 0 could drift above threshold and accumulate an unbounded
21
+ # "loaded hold" that passes the duration gate on its own.
22
+ class JumpDetector
23
+ LOADED_THRESHOLD = 0.20 # vertical_acceleration above this counts as "loaded" (body under extra g-load)
24
+ LOADED_MIN_SECONDS = 0.2 # loaded span qualifying as squat hold
25
+ TAKEOFF_SLOPE_MAX = -1.0 # g/s — fall slope indicating takeoff turnover
26
+ SLOPE_WINDOW_SECONDS = 0.08
27
+ LOADED_END_GRACE_SECONDS = 0.15 # allow fire shortly past the span's last above-threshold point
28
+
29
+ COOLDOWN_SECONDS = 0.8 # min gap between consecutive fires (covers takeoff→landing)
30
+ SAMPLE_BUFFER_SECONDS = 1.2 # retain samples long enough to cover an elongated squat hold
31
+ MIN_SAMPLES_FOR_ANALYSIS = 5 # slope + hold both need a few samples to be meaningful
32
+
33
+ def initialize(gravity:)
34
+ @gravity = gravity
35
+ @gravity_magnitude = Math.sqrt(gravity[0] ** 2 + gravity[1] ** 2 + gravity[2] ** 2)
36
+ @last_samples = [] # [{time:, vertical_acceleration:}] sliding window
37
+ @last_jump_time = nil
38
+ end
39
+
40
+ def detect(sample:)
41
+ now = Time.now
42
+ @last_samples << { time: now, vertical_acceleration: vertical_acceleration(sample) }
43
+ cutoff = now - SAMPLE_BUFFER_SECONDS
44
+ @last_samples.shift while !@last_samples.empty? && @last_samples.first[:time] < cutoff
45
+
46
+ if !buffer_ready? || !squat_takeoff? || cooling_down?(now)
47
+ false
48
+ else
49
+ @last_jump_time = now
50
+ debug_log('JUMP_FIRED')
51
+ true
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def vertical_acceleration(sample)
58
+ projection = (-sample[0] * @gravity[0] + sample[1] * @gravity[1] + sample[2] * @gravity[2]) / @gravity_magnitude
59
+ projection - @gravity_magnitude
60
+ end
61
+
62
+ def buffer_ready? = @last_samples.size >= MIN_SAMPLES_FOR_ANALYSIS
63
+ def cooling_down?(now) = @last_jump_time && (now - @last_jump_time) < COOLDOWN_SECONDS
64
+
65
+ def squat_takeoff?
66
+ hold_seconds = last_loaded_hold_seconds
67
+ slope = last_slope
68
+ debug_log(format('vertical_acceleration=%+.2f hold=%.3f slope=%+.2f', @last_samples.last[:vertical_acceleration], hold_seconds, slope)) if hold_seconds > 0
69
+
70
+ if hold_seconds < LOADED_MIN_SECONDS
71
+ false
72
+ else
73
+ slope <= TAKEOFF_SLOPE_MAX
74
+ end
75
+ end
76
+
77
+ # Duration of the latest span where vertical_acceleration stayed
78
+ # continuously above LOADED_THRESHOLD. Returns 0 if the span reaches the
79
+ # buffer edge without a preceding below-threshold sample — that indicates
80
+ # stationary state (signal hovering near 0 continuously), not an
81
+ # oscillating stride.
82
+ # The span may still be in progress (latest sample above threshold) or
83
+ # just ended within the last sample — jumps launch fast enough that the
84
+ # signal can skip from +1g to below threshold between consecutive samples.
85
+ def last_loaded_hold_seconds
86
+ loaded_end_idx = @last_samples.rindex { |sample| sample[:vertical_acceleration] > LOADED_THRESHOLD }
87
+ return 0.0 unless loaded_end_idx
88
+ loaded_end_time = @last_samples[loaded_end_idx][:time]
89
+ # Too long past the span's end — no longer just after the turnover
90
+ return 0.0 if @last_samples.last[:time] - loaded_end_time > LOADED_END_GRACE_SECONDS
91
+
92
+ pre_load_idx = @last_samples[0...loaded_end_idx].rindex { |sample| sample[:vertical_acceleration] <= LOADED_THRESHOLD }
93
+ if pre_load_idx
94
+ loaded_begin_idx = pre_load_idx + 1
95
+ loaded_begin_time = @last_samples[loaded_begin_idx][:time]
96
+ loaded_end_time - loaded_begin_time
97
+ else
98
+ 0.0
99
+ end
100
+ end
101
+
102
+ # Slope (g/s) of vertical_acceleration over the last SLOPE_WINDOW_SECONDS,
103
+ # measured as (latest - reference) / dt where reference is the newest
104
+ # sample at or before the window cutoff. A clearly negative value means
105
+ # the signal has peaked and started falling — the takeoff turnover.
106
+ def last_slope
107
+ window_end_sample = @last_samples.last
108
+ cutoff = window_end_sample[:time] - SLOPE_WINDOW_SECONDS
109
+ window_begin_sample = @last_samples.reverse_each.find { |sample| sample[:time] <= cutoff }
110
+ return 0.0 unless window_begin_sample
111
+
112
+ elapsed_seconds = window_end_sample[:time] - window_begin_sample[:time]
113
+ if elapsed_seconds <= 0
114
+ 0.0
115
+ else
116
+ (window_end_sample[:vertical_acceleration] - window_begin_sample[:vertical_acceleration]) / elapsed_seconds
117
+ end
118
+ end
119
+
120
+ def debug_log(message)
121
+ return unless ENV['JUMP_LOG'] == '1'
122
+ $stderr.puts "[JumpDetector] #{Time.now.to_f} #{message}"
123
+ end
124
+ end
125
+ end
@@ -23,6 +23,7 @@ module RoadToRubykaigi
23
23
  ],
24
24
  }
25
25
  WALK_SOUND_INTERVAL = 0.25
26
+ RUN_SOUND_INTERVAL = 0.11
26
27
 
27
28
  SOUND_FILES.keys.each do |action|
28
29
  define_method(action) {
@@ -53,9 +54,10 @@ module RoadToRubykaigi
53
54
  end
54
55
  end
55
56
 
56
- def walk
57
+ def walk(running: false)
57
58
  now = Time.now
58
- if (now - @last_walk_time) >= WALK_SOUND_INTERVAL
59
+ interval = running ? RUN_SOUND_INTERVAL : WALK_SOUND_INTERVAL
60
+ if (now - @last_walk_time) >= interval
59
61
  @audio_engine.add_source(@sources[:walk][@walk_index])
60
62
  @last_walk_time = now
61
63
  @walk_index = (@walk_index + 1) % @sources[:walk].size
@@ -69,6 +71,10 @@ module RoadToRubykaigi
69
71
  @melody_sequencer = Audio::MelodySequencer.new
70
72
  @fanfare_sequencer = Audio::FanfareSequencer.new
71
73
  @audio_engine = Audio::AudioEngine.new(@bass_sequencer, @melody_sequencer)
74
+ if Config.bgm_off?
75
+ @audio_engine.remove_source(@bass_sequencer)
76
+ @audio_engine.remove_source(@melody_sequencer)
77
+ end
72
78
  @sources = SOUND_FILES
73
79
  dir = __dir__.sub("lib/road_to_rubykaigi/manager", "")
74
80
  @sources.each do |action, file_paths|
@@ -3,7 +3,7 @@ module RoadToRubykaigi
3
3
  class GameManager
4
4
  UPDATE_RATE = 1.0 / 10
5
5
  FRAME_RATE = 1.0 / 60
6
- GOAL_X = 650
6
+ GOAL_X = { 2025 => 650, 2026 => 800 }
7
7
  DEMO_GOAL_X = 540
8
8
  STATE = {
9
9
  playing: 0,
@@ -15,7 +15,7 @@ module RoadToRubykaigi
15
15
  attr_reader :fireworks
16
16
 
17
17
  def self.goal_x
18
- @goal_x ||= RoadToRubykaigi.demo? ? DEMO_GOAL_X : GOAL_X
18
+ @goal_x ||= RoadToRubykaigi.demo? ? DEMO_GOAL_X : GOAL_X[RoadToRubykaigi.version]
19
19
  end
20
20
 
21
21
  def offset_x
@@ -25,6 +25,7 @@ module RoadToRubykaigi
25
25
  def update
26
26
  @deadline.activate(player_x: @player.x)
27
27
  @enemies.activate if player_moved?
28
+ @score_board.start_timer if @player.x > Sprite::Player::WARMUP_END_X[RoadToRubykaigi.version]
28
29
  if @player.x >= GameManager.goal_x && playing?
29
30
  EventDispatcher.publish(:ending)
30
31
  end
@@ -27,29 +27,100 @@ module RoadToRubykaigi
27
27
  │。・◡・│_◢◤
28
28
  ╰ᜊ───ᜊ─╯
29
29
  PLAYER
30
+ VERSION_ROW = 25
30
31
 
31
32
  def display
32
- x = 0
33
- direction = 1
34
-
35
33
  loop do
36
34
  ANSI.clear
37
- puts "\e[6;1H" + LOGO
38
- puts [
39
- PLAYER.lines.map.with_index do |line, i|
40
- "\e[#{i+1};#{x+OFFSET}H" + line
41
- end.join,
42
- "\e[4;1H" + "Press Space to start...",
43
- ]
44
- if $stdin.raw { $stdin.read_nonblock(1, exception: false) == " " }
45
- break true
35
+ render
36
+ $stdin.raw do
37
+ if handle_input == :SELECTED
38
+ item = menu_items[@menu_index]
39
+ case item
40
+ when :calibrate
41
+ CalibrationScreen.new.display
42
+ when :input_source
43
+ Config.cycle_input_source
44
+ @menu_items = nil
45
+ @menu_index = menu_items.index(:input_source) || 0
46
+ when :open_controller
47
+ GameServer.start
48
+ GameServer.open_controller
49
+ else
50
+ return item
51
+ end
52
+ end
53
+ move_player
54
+ sleep Manager::GameManager::FRAME_RATE
46
55
  end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def initialize
62
+ @player_x = 0
63
+ @direction = 1
64
+ @last_move_time = Time.now
65
+ @menu_index = 0
66
+ end
67
+
68
+ def menu_items
69
+ return @menu_items if @menu_items
70
+ @menu_items = RoadToRubykaigi::VERSIONS + [:input_source]
71
+ @menu_items = @menu_items + [:calibrate, :open_controller] if Config.external_input?
72
+ @menu_items
73
+ end
74
+
75
+ INPUT_SOURCE_LABELS = { ble: 'BLE', serial: 'Serial', nil => 'Keyboard' }.freeze
76
+
77
+ def render
78
+ puts [
79
+ "\e[6;1H",
80
+ LOGO,
81
+ PLAYER.lines.map.with_index do |line, i|
82
+ "\e[#{i+1};#{@player_x+OFFSET}H" + line
83
+ end.join,
84
+ "\e[4;1H",
85
+ " Press Space to start...",
86
+ ]
87
+ menu_items.each_with_index do |item, i|
88
+ cursor = i == @menu_index ? " -> " : " "
89
+ row_offset = case item
90
+ when :input_source, :calibrate, :open_controller then 1
91
+ else 0
92
+ end
93
+ label =
94
+ case item
95
+ when :calibrate then 'Calibrate sensor'
96
+ when :open_controller then 'Open Controller'
97
+ when :input_source then "Input: #{INPUT_SOURCE_LABELS[Config.input_source]}"
98
+ else "ver. #{RoadToRubykaigi::VERSIONS[i]}"
99
+ end
100
+ print "\e[#{VERSION_ROW + i + row_offset};1H#{cursor}#{label}"
101
+ end
102
+ end
103
+
104
+ def handle_input
105
+ case $stdin.read_nonblock(3, exception: false)
106
+ when ANSI::UP
107
+ @menu_index = (@menu_index - 1) % menu_items.size
108
+ when ANSI::DOWN
109
+ @menu_index = (@menu_index + 1) % menu_items.size
110
+ when " ", ANSI::ENTER, ANSI::LF
111
+ :SELECTED
112
+ when ANSI::ETX
113
+ raise Interrupt
114
+ end
115
+ end
47
116
 
48
- x += direction
49
- if x >= WIDTH || x <= 0
50
- direction = -direction
117
+ def move_player
118
+ if Time.now - @last_move_time >= DELAY
119
+ @player_x += @direction
120
+ if @player_x >= WIDTH || @player_x <= 0
121
+ @direction = -@direction
51
122
  end
52
- sleep DELAY
123
+ @last_move_time = Time.now
53
124
  end
54
125
  end
55
126
  end
@@ -4,6 +4,10 @@ module RoadToRubykaigi
4
4
  @score += 1
5
5
  end
6
6
 
7
+ def start_timer
8
+ @start_time ||= Time.now
9
+ end
10
+
7
11
  def render_score_board
8
12
  "Score: #{@score}".ljust(10).rjust(Map::VIEWPORT_WIDTH)
9
13
  end
@@ -24,10 +28,11 @@ module RoadToRubykaigi
24
28
 
25
29
  def initialize
26
30
  @score = 0
27
- @start_time = Time.now
31
+ @start_time = Config.external_input? ? nil : Time.now
28
32
  end
29
33
 
30
34
  def result_time
35
+ return 0.0 unless @start_time
31
36
  (Time.now - @start_time).round(2)
32
37
  end
33
38
  end
@@ -0,0 +1,76 @@
1
+ require 'singleton'
2
+ require 'logger'
3
+ require 'uart'
4
+
5
+ module RoadToRubykaigi
6
+ class SerialReader
7
+ include Singleton
8
+
9
+ BAUD = 115200
10
+
11
+ class << self
12
+ extend Forwardable
13
+ def_delegators :instance, :start, :queue, :drain
14
+ end
15
+
16
+ attr_reader :queue
17
+
18
+ def drain
19
+ until @queue.empty?
20
+ yield @queue.pop(true)
21
+ end
22
+ rescue ThreadError
23
+ # pop(true) raises if the queue empties mid-drain
24
+ end
25
+
26
+ def start
27
+ @queue.clear
28
+ Config.detect_serial_port!
29
+ @port = Config.serial_port
30
+ return if @thread
31
+
32
+ @thread = Thread.new { read_loop }
33
+ at_exit { @thread&.kill }
34
+ end
35
+
36
+ private
37
+
38
+ def initialize
39
+ @queue = Thread::Queue.new
40
+ log_path = Config.debug? ? File.join(Config.project_root, 'tmp/serial_reader.log') : File::NULL
41
+ @logger = Logger.new(log_path)
42
+ end
43
+
44
+ def read_loop
45
+ serial = UART.open(@port, BAUD)
46
+ buf = +''
47
+ loop do
48
+ chunk = serial.sysread(256)
49
+ buf << chunk
50
+ while (idx = buf.index("\n"))
51
+ line = buf.slice!(0..idx).strip
52
+ next if line.empty?
53
+
54
+ data = parse_line(line)
55
+ unless data.empty?
56
+ @logger.info(data.inspect)
57
+ @queue.push(data)
58
+ end
59
+ end
60
+ rescue EOFError
61
+ sleep 0.05
62
+ end
63
+ rescue => e
64
+ $stderr.puts "[SerialReader] #{e.message}"
65
+ ensure
66
+ serial&.close
67
+ end
68
+
69
+ def parse_line(line)
70
+ line.split(',').each_with_object({}) do |pair, hash|
71
+ key, value = pair.split('=', 2)
72
+ hash[key] = value if key && value
73
+ end
74
+ end
75
+ end
76
+ end