signalbox 0.1.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: '0183b44f559e1894e69e638f2dd97aa215f124a16a97b6237fa78f2ef10b9e70'
4
+ data.tar.gz: 24e6bc0cac766f39f458a0f6edcc9556ca86abc110ac91f6cbc9c799aa0acb82
5
+ SHA512:
6
+ metadata.gz: 69b1acbec07598d24e7870aa920170961613f5091343e7100d794973a65e5ebe0120782ecda4020e571f4d70f5e70ee0404ef2761eab0f3b58d07d7098c696db
7
+ data.tar.gz: d85c9f4b20b85fcb46752139ebb5061644c6102689c832741422b8949f88af4f2ccd0e4d37f591d2e111aa16774c402b952162cc216d4472674994bd1ce3f366
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # 🚂 Signalbox
2
+
3
+ A system for operating model trains powered by DCC.
4
+
5
+ ## Installation
6
+
7
+ `gem install signalbox`
8
+
9
+ ## Layout Setup
10
+
11
+ See `examples/layouts/`
12
+
13
+ ## Running the server
14
+
15
+ `signalbox-server`
16
+
17
+ ## Running the conductor TUI
18
+
19
+ `signalbox-conductor`
20
+
21
+ ## Notes
22
+
23
+ This project is unrelated to and unaffiliated with Signalbox.io and related
24
+ British Railway data projects.
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "signalbox"
6
+ require "logger"
7
+ require "optparse"
8
+ require "json"
9
+ require "io/console"
10
+
11
+ # Parse command line arguments
12
+ options = {}
13
+ OptionParser.new do |opts|
14
+ opts.banner = "Usage: signalbox-conductor [options]"
15
+
16
+ opts.on("--version", "Show version") do
17
+ puts "SignalBox version #{SignalBox::VERSION}"
18
+ exit
19
+ end
20
+
21
+ opts.on("-h", "--help", "Show this help message") do
22
+ puts opts
23
+ exit
24
+ end
25
+ end.parse!
26
+
27
+ host = ARGV[0] || "localhost"
28
+ port = 4040
29
+
30
+ puts "Connecting to #{host}:#{port}..."
31
+ socket = TCPSocket.new host, port
32
+ puts "Connected!\n"
33
+
34
+ # ANSI escape codes
35
+ CLEAR_SCREEN = "\e[2J\e[H"
36
+ BOLD = "\e[1m"
37
+ RESET = "\e[0m"
38
+ GREEN = "\e[32m"
39
+ YELLOW = "\e[33m"
40
+ BLUE = "\e[34m"
41
+ CYAN = "\e[36m"
42
+
43
+ def format_speed(speed)
44
+ sprintf("%.1f", speed)
45
+ end
46
+
47
+ def format_cab_status(cab, selected: false)
48
+ location = cab["location"] ? cab["location"]["name"] : "Unknown"
49
+ current = format_speed(cab["current_speed"])
50
+ target = cab["target_speed"]
51
+ direction = cab["direction_name"]
52
+
53
+ status_line = sprintf("%-20s | %-25s | Speed: %5s / %-3d | %s",
54
+ cab["name"],
55
+ location,
56
+ current,
57
+ target,
58
+ direction.capitalize
59
+ )
60
+
61
+ # Add selection indicator
62
+ if selected
63
+ status_line = "▶ #{status_line}"
64
+ else
65
+ status_line = " #{status_line}"
66
+ end
67
+
68
+ # Color code based on speed status
69
+ if cab["current_speed"] < cab["target_speed"]
70
+ "#{GREEN}#{status_line}#{RESET}"
71
+ elsif cab["current_speed"] > cab["target_speed"]
72
+ "#{YELLOW}#{status_line}#{RESET}"
73
+ else
74
+ status_line
75
+ end
76
+ end
77
+
78
+ def read_nonblock_char
79
+ if IO.select([$stdin], nil, nil, 0)
80
+ c = $stdin.getch
81
+
82
+ # Handle arrow keys (escape sequences)
83
+ if c == "\e"
84
+ c2 = $stdin.getch
85
+ c3 = $stdin.getch
86
+ if c2 == "["
87
+ case c3
88
+ when "A" then return :up
89
+ when "B" then return :down
90
+ when "C" then return :right
91
+ when "D" then return :left
92
+ end
93
+ end
94
+ end
95
+
96
+ return c
97
+ end
98
+ nil
99
+ end
100
+
101
+ # Helper to print with proper line endings in raw mode
102
+ def print_line(str = "")
103
+ print str + "\r\n"
104
+ end
105
+
106
+ # Set terminal to raw mode for keyboard input
107
+ $stdin.raw!
108
+ $stdin.echo = false
109
+
110
+ selected_cab_index = 0
111
+ last_status_time = Time.now
112
+ data = nil
113
+
114
+ begin
115
+ loop do
116
+ # Fetch status every second
117
+ if Time.now - last_status_time >= 1
118
+ socket.puts "STATUS"
119
+ response = socket.gets
120
+ last_status_time = Time.now
121
+
122
+ begin
123
+ data = JSON.parse(response)
124
+
125
+ # Clear screen and move cursor to top
126
+ print CLEAR_SCREEN
127
+
128
+ # Header
129
+ print_line "#{BOLD}#{BLUE}═══════════════════════════════════════════════════════════════════════════#{RESET}"
130
+ print_line "#{BOLD}#{BLUE} SignalBox Conductor - Live Status Monitor#{RESET}"
131
+ print_line "#{BOLD}#{BLUE}═══════════════════════════════════════════════════════════════════════════#{RESET}"
132
+ print_line
133
+
134
+ # Layout info
135
+ print_line "#{BOLD}Layout:#{RESET} #{data["layout"]["name"]}"
136
+ print_line
137
+
138
+ # Cabs header
139
+ print_line "#{BOLD}#{CYAN}Cabs:#{RESET}"
140
+ print_line "#{'-' * 79}"
141
+
142
+ if data["cabs"].empty?
143
+ print_line " No cabs configured"
144
+ else
145
+ # Ensure selected index is valid
146
+ selected_cab_index = 0 if selected_cab_index >= data["cabs"].length
147
+ selected_cab_index = data["cabs"].length - 1 if selected_cab_index < 0
148
+
149
+ data["cabs"].each_with_index do |cab, idx|
150
+ print_line format_cab_status(cab, selected: idx == selected_cab_index)
151
+ end
152
+ end
153
+
154
+ print_line
155
+ print_line "#{'-' * 79}"
156
+ print_line "#{GREEN}●#{RESET} Accelerating #{YELLOW}●#{RESET} Decelerating ⚪ At target speed"
157
+ print_line
158
+ print_line "Controls: ↑/↓ Adjust speed ←/→ Select cab X Stop Q/Ctrl+C Exit"
159
+
160
+ rescue JSON::ParserError => e
161
+ print_line "#{YELLOW}Warning: Failed to parse response#{RESET}"
162
+ end
163
+ end
164
+
165
+ # Check for keyboard input
166
+ key = read_nonblock_char
167
+
168
+ if key && data && !data["cabs"].empty?
169
+ selected_cab = data["cabs"][selected_cab_index]
170
+
171
+ case key
172
+ when :up
173
+ # Increase target speed
174
+ new_speed = selected_cab["target_speed"] + 1
175
+ socket.puts "SET_TARGET_SPEED #{selected_cab["address"]} #{new_speed}"
176
+ socket.gets # Read OK/ERROR response
177
+
178
+ when :down
179
+ # Decrease target speed
180
+ new_speed = [selected_cab["target_speed"] - 1, 0].max
181
+ socket.puts "SET_TARGET_SPEED #{selected_cab["address"]} #{new_speed}"
182
+ socket.gets # Read OK/ERROR response
183
+
184
+ when :left
185
+ # Select previous cab
186
+ selected_cab_index = (selected_cab_index - 1) % data["cabs"].length
187
+
188
+ when :right
189
+ # Select next cab
190
+ selected_cab_index = (selected_cab_index + 1) % data["cabs"].length
191
+
192
+ when "x", "X"
193
+ # Stop cab (set target speed to 0)
194
+ socket.puts "SET_TARGET_SPEED #{selected_cab["address"]} 0"
195
+ socket.gets # Read OK/ERROR response
196
+
197
+ when "\u0003", "q", "Q" # Ctrl+C or 'q'/'Q'
198
+ break
199
+ end
200
+ end
201
+
202
+ sleep 0.05 # Small sleep to reduce CPU usage
203
+ end
204
+ ensure
205
+ # Restore terminal settings
206
+ $stdin.cooked!
207
+ $stdin.echo = true
208
+ puts "\nExiting..."
209
+ end
210
+
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "signalbox"
6
+ require "logger"
7
+ require "optparse"
8
+
9
+ # Parse command line arguments
10
+ options = {}
11
+ OptionParser.new do |opts|
12
+ opts.banner = "Usage: signalbox-server [options]"
13
+
14
+ opts.on("--layout FILE", "Layout file to load (default: main.layout.rb)") do |file|
15
+ options[:layout] = file
16
+ end
17
+
18
+ opts.on("-l", "--logfile FILE", "Log file path (default: stdout)") do |file|
19
+ options[:logfile] = file
20
+ end
21
+
22
+ opts.on("--version", "Show version") do
23
+ puts "SignalBox version #{SignalBox::VERSION}"
24
+ exit
25
+ end
26
+
27
+ opts.on("-h", "--help", "Show this help message") do
28
+ puts opts
29
+ exit
30
+ end
31
+ end.parse!
32
+
33
+ LAYOUT_FILE = options[:layout] || "main.layout.rb"
34
+
35
+ # Check if layout file exists
36
+ unless File.exist?(LAYOUT_FILE)
37
+ puts "Error: Layout file '#{LAYOUT_FILE}' not found."
38
+ puts "Please specify a layout file using --layout option."
39
+ puts "Example: signalbox-server --layout path/to/layout.rb"
40
+ exit 1
41
+ end
42
+
43
+ SENSOR_PORT = ENV.fetch("SENSOR_PORT", "4040").to_i
44
+
45
+ # Setup logger
46
+ log_output = options[:logfile] ? options[:logfile] : STDOUT
47
+ $logger = Logger.new(log_output)
48
+ $logger.level = Logger::DEBUG
49
+
50
+ $logger.info ""
51
+ $logger.info "################################"
52
+ $logger.info "SIGNALBOX SERVER STARTING"
53
+ $logger.info "Version: #{SignalBox::VERSION}"
54
+
55
+ # Load layout definition
56
+ layout = SignalBox::LayoutLoader.load(LAYOUT_FILE, logger: $logger)
57
+
58
+ # Get DCC connection from layout
59
+ unless layout.dcc
60
+ $logger.error "Layout does not define a DCC controller."
61
+ $logger.error "Please add a 'dcc' block to your layout file."
62
+ puts "Error: Layout file must include a 'dcc' block defining the DCC controller."
63
+ puts "Example:"
64
+ puts " dcc do"
65
+ puts " host '192.168.0.22'"
66
+ puts " port 2560"
67
+ puts " end"
68
+ exit 1
69
+ end
70
+
71
+ dcc = layout.dcc
72
+
73
+ # Mutex for thread-safe layout controller access
74
+ layout_controller_mutex = Mutex.new
75
+
76
+ layout_controller = SignalBox::LayoutController.new(
77
+ layout: layout,
78
+ dcc: dcc,
79
+ mutex: layout_controller_mutex,
80
+ logger: $logger
81
+ )
82
+
83
+ layout_controller.start
84
+
85
+ # Initialize variables for shutdown handler
86
+ control_loop_thread = nil
87
+ server = nil
88
+
89
+ # Graceful shutdown handler
90
+ def shutdown(control_loop_thread, layout_controller, dcc, server, logger)
91
+ logger.info ""
92
+ logger.info "################################"
93
+ logger.info "SIGNALBOX SERVER SHUTTING DOWN"
94
+ logger.info "################################"
95
+
96
+ # Stop control loop
97
+ if control_loop_thread && control_loop_thread.alive?
98
+ logger.info "[SHUTDOWN] Stopping control loop..."
99
+ control_loop_thread.kill
100
+ end
101
+
102
+ # Emergency stop (turns off track power)
103
+ logger.info "[SHUTDOWN] Turning off track power..."
104
+ layout_controller.emergency_stop
105
+
106
+ # Close DCC connection
107
+ logger.info "[SHUTDOWN] Closing DCC connection..."
108
+ dcc.close
109
+
110
+ # Close TCP server
111
+ if server && !server.closed?
112
+ logger.info "[SHUTDOWN] Closing sensor server..."
113
+ server.close
114
+ end
115
+
116
+ logger.info "[SHUTDOWN] Shutdown complete"
117
+ end
118
+
119
+ # Set up signal trap to raise Interrupt
120
+ Signal.trap("INT") { Thread.main.raise(Interrupt) }
121
+ Signal.trap("TERM") { Thread.main.raise(Interrupt) }
122
+
123
+ # 10 Hz control loop
124
+ control_loop_thread = Thread.new do
125
+ last_tick_time = Time.now
126
+ $logger.info "[CONTROL LOOP] Starting 10 Hz control loop"
127
+
128
+ loop do
129
+ sleep(0.1) # 10 Hz = 100ms interval
130
+
131
+ current_time = Time.now
132
+ elapsed_time = current_time - last_tick_time
133
+ last_tick_time = current_time
134
+
135
+ layout_controller_mutex.synchronize do
136
+ layout_controller.tick(elapsed_time)
137
+ end
138
+ end
139
+ end
140
+ control_loop_thread.abort_on_exception = true
141
+ $logger.info "[CONTROL LOOP] Control loop thread started"
142
+
143
+ HOST = "0.0.0.0"
144
+
145
+ # Create sensor client handler
146
+ sensor_handler = SignalBox::SensorClientHandler.new(
147
+ layout_controller: layout_controller,
148
+ mutex: layout_controller_mutex,
149
+ logger: $logger
150
+ )
151
+
152
+ server = TCPServer.new(HOST, SENSOR_PORT)
153
+ $logger.info "Sensor server listening on #{HOST}:#{SENSOR_PORT}"
154
+
155
+ begin
156
+ loop do
157
+ sock = server.accept
158
+
159
+ Thread.new(sock) do |client|
160
+ sensor_handler.handle_client(client)
161
+ end
162
+ end
163
+ rescue Interrupt, SystemExit
164
+ shutdown(control_loop_thread, layout_controller, dcc, server, $logger)
165
+ end
@@ -0,0 +1,27 @@
1
+ module SignalBox
2
+ class Cab
3
+ attr_reader :name, :address, :logger
4
+
5
+ attr_accessor :current_speed, :target_speed, :acceleration, :location, :direction
6
+
7
+ # param name [String] the name of the cab
8
+ # param address [String] the DCC address of the cab
9
+ # param current_speed [Integer] the current speed of the cab
10
+ # param target_speed [Integer] the target speed of the cab
11
+ # param acceleration [Integer] the acceleration of the cab, in units per second squared, absolute value
12
+ # param direction [Integer] the direction of the cab (0 = reverse, 1 = forward)
13
+ # param location [LayoutSector, nil] the current location of the cab
14
+ # param logger [Logger] the logger to use
15
+ def initialize(name: "", address: "", current_speed: 0, target_speed: 0, acceleration: 5, direction: 1, location: nil, logger: Logger.new(STDOUT))
16
+ @name = name
17
+ @address = address
18
+ @current_speed = current_speed
19
+ @target_speed = target_speed
20
+ @acceleration = acceleration
21
+ @direction = direction
22
+ @location = location
23
+ @logger = logger
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SignalBox
6
+ class ConfigStore
7
+ DEFAULT_CONFIG = {
8
+ "trigger_delta" => 200,
9
+ "release_delta" => 120,
10
+ "debounce_ms" => 80
11
+ }.freeze
12
+
13
+ def initialize(path)
14
+ @path = path
15
+ @mutex = Mutex.new
16
+ @configs = load_file
17
+ end
18
+
19
+ def get(sensor_id)
20
+ @mutex.synchronize do
21
+ DEFAULT_CONFIG.merge(@configs.fetch(sensor_id, {}))
22
+ end
23
+ end
24
+
25
+ def set(sensor_id, hash)
26
+ @mutex.synchronize do
27
+ @configs[sensor_id] ||= {}
28
+ @configs[sensor_id].merge!(hash)
29
+ save_file
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def load_file
36
+ return {} unless File.exist?(@path)
37
+ YAML.load_file(@path) || {}
38
+ rescue => e
39
+ $logger.warn "Failed to load #{@path}: #{e}"
40
+ {}
41
+ end
42
+
43
+ def save_file
44
+ File.write(@path, YAML.dump(@configs))
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,180 @@
1
+ require 'socket'
2
+
3
+ module SignalBox
4
+ module DCC
5
+ class Client
6
+ def initialize(host, port, logger:)
7
+ @host = host
8
+ @port = port
9
+ @logger = logger
10
+ @socket = nil
11
+ end
12
+
13
+ def connect
14
+ ensure_connected!
15
+ end
16
+
17
+ def close
18
+ safe_close
19
+ rescue => e
20
+ @logger.warn "[DCC] close failed (#{e.class}: #{e.message})"
21
+ end
22
+
23
+ def send(cmd)
24
+ ensure_connected!
25
+ @socket.puts(cmd)
26
+ rescue => e
27
+ @logger.warn "[DCC] send failed (#{e.class}: #{e.message}); reconnecting"
28
+ safe_close
29
+ ensure_connected!
30
+ @socket.puts(cmd)
31
+ end
32
+
33
+ # Track power control
34
+ def track_power_on!(track = "MAIN")
35
+ @logger.debug "[DCC] Track power ON: #{track}"
36
+ send("<1 #{track}>")
37
+ end
38
+
39
+ def track_power_off!(track = "MAIN")
40
+ @logger.debug "[DCC] Track power OFF: #{track}"
41
+ send("<0 #{track}>")
42
+ end
43
+
44
+ # Legacy aliases
45
+ def track_power_main!
46
+ track_power_on!("MAIN")
47
+ end
48
+
49
+
50
+ def set_speed(addr:, speed:, dir: 1)
51
+ @logger.debug "[DCC] Set speed addr=#{addr} speed=#{speed} dir=#{dir}"
52
+ speed = [[speed, 0].max, 127].min
53
+ send("<t #{addr} #{speed} #{dir}>")
54
+ end
55
+
56
+ # Request cab status and parse the broadcast response
57
+ # Returns a hash with: { cab:, reg:, speed:, direction:, speed_byte:, funct_map: }
58
+ # speed: 0-127 (bits 0-6 of speed_byte)
59
+ # direction: 0 (reverse) or 1 (forward) (bit 7 of speed_byte)
60
+ # or nil if no response received
61
+ def get_cab_status(cab)
62
+ @logger.debug "[DCC] Getting status for cab #{cab}"
63
+ response = send_and_receive("<t #{cab}>", timeout: 2.0)
64
+
65
+ if response && response =~ /<l\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)>/
66
+ speed_byte = $3.to_i
67
+
68
+ {
69
+ cab: $1.to_i,
70
+ reg: $2.to_i,
71
+ speed: speed_byte & 0x7F,
72
+ direction: (speed_byte & 0x80) >> 7,
73
+ speed_byte: speed_byte,
74
+ funct_map: $4.to_i
75
+ }
76
+ else
77
+ @logger.warn "[DCC] No valid response for cab #{cab} status request"
78
+ nil
79
+ end
80
+ end
81
+
82
+ # Emergency stop - halts all locomotives immediately
83
+ def emergency_stop!
84
+ @logger.warn "[DCC] EMERGENCY STOP"
85
+ send("<!>")
86
+ end
87
+
88
+ # Function control (lights, sound, etc.)
89
+ # funct: 0-68 (F0-F68)
90
+ # state: 1=on, 0=off
91
+ def set_function(cab:, funct:, state:)
92
+ @logger.debug "[DCC] Set function cab=#{cab} F#{funct}=#{state}"
93
+ send("<F #{cab} #{funct} #{state}>")
94
+ end
95
+
96
+ # Convenience methods for common functions
97
+ def set_light(cab:, state:)
98
+ set_function(cab: cab, funct: 0, state: state)
99
+ end
100
+
101
+ def light_on(cab:)
102
+ set_light(cab: cab, state: 1)
103
+ end
104
+
105
+ def light_off(cab:)
106
+ set_light(cab: cab, state: 0)
107
+ end
108
+
109
+ # Turnout/point control
110
+ # id: turnout ID
111
+ # state: 1/"T"=throw, 0/"C"=close, "X"=examine
112
+ def set_turnout(id:, state:)
113
+ @logger.debug "[DCC] Set turnout #{id} to #{state}"
114
+ send("<T #{id} #{state}>")
115
+ end
116
+
117
+ def throw_turnout(id:)
118
+ set_turnout(id: id, state: 1)
119
+ end
120
+
121
+ def close_turnout(id:)
122
+ set_turnout(id: id, state: 0)
123
+ end
124
+
125
+ # List all defined turnouts
126
+ # Returns array of responses, each formatted as <H id state>
127
+ def list_turnouts
128
+ @logger.debug "[DCC] Requesting turnout list"
129
+ # This requires reading multiple responses, so we'll just send the command
130
+ send("<T>")
131
+ # Note: Response handling would need enhancement for multi-line responses
132
+ end
133
+
134
+ # Accessory decoder control
135
+ # Using address/subaddress format (addr: 0-511, subaddr: 0-3)
136
+ def set_accessory(addr:, subaddr:, activate:)
137
+ @logger.debug "[DCC] Accessory addr=#{addr} subaddr=#{subaddr} activate=#{activate}"
138
+ send("<a #{addr} #{subaddr} #{activate}>")
139
+ end
140
+
141
+ # Using linear address format (1-2044)
142
+ def set_accessory_linear(addr:, activate:)
143
+ @logger.debug "[DCC] Accessory linear addr=#{addr} activate=#{activate}"
144
+ send("<a #{addr} #{activate}>")
145
+ end
146
+
147
+
148
+ private
149
+
150
+ def send_and_receive(cmd, timeout: 2.0)
151
+ ensure_connected!
152
+ @socket.puts(cmd)
153
+
154
+ # Wait for response with timeout
155
+ if IO.select([@socket], nil, nil, timeout)
156
+ @socket.gets&.chomp
157
+ else
158
+ nil
159
+ end
160
+ rescue => e
161
+ @logger.warn "[DCC] send_and_receive failed (#{e.class}: #{e.message})"
162
+ nil
163
+ end
164
+
165
+ def ensure_connected!
166
+ return if @socket && !@socket.closed?
167
+ @socket = TCPSocket.new(@host, @port)
168
+ end
169
+
170
+ def safe_close
171
+ @socket&.close
172
+ rescue
173
+ nil
174
+ ensure
175
+ @socket = nil
176
+ end
177
+
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,41 @@
1
+ module SignalBox
2
+ class Layout
3
+ attr_reader :name, :cabs, :sectors, :proximity_sensors, :dcc
4
+
5
+ def initialize(name:)
6
+ @name = name
7
+ @cabs = []
8
+ @sectors = []
9
+ @proximity_sensors = []
10
+ @dcc = nil
11
+ end
12
+
13
+ def add_cab(cab)
14
+ @cabs << cab
15
+ end
16
+
17
+ def add_sector(sector)
18
+ @sectors << sector
19
+ end
20
+
21
+ def add_proximity_sensor(sensor)
22
+ @proximity_sensors << sensor
23
+ end
24
+
25
+ def set_dcc(dcc)
26
+ @dcc = dcc
27
+ end
28
+
29
+ def find_sector(id)
30
+ @sectors.find { |s| s.id == id }
31
+ end
32
+
33
+ def find_sensor(id)
34
+ @proximity_sensors.find { |s| s.id == id }
35
+ end
36
+
37
+ def to_s
38
+ "Layout(name: #{@name}, cabs: #{@cabs.count}, sectors: #{@sectors.count}, sensors: #{@proximity_sensors.count})"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,204 @@
1
+ require 'forwardable'
2
+
3
+ module SignalBox
4
+ class LayoutController
5
+ extend Forwardable
6
+
7
+ attr_reader :dcc, :layout, :logger
8
+
9
+ # Delegate layout-related methods to the layout object
10
+ def_delegators :@layout, :cabs, :sectors, :proximity_sensors, :find_sector, :find_sensor
11
+
12
+ def initialize(dcc:, layout:, mutex:, logger: Logger.new(STDOUT))
13
+ @dcc = dcc
14
+ @layout = layout
15
+ @mutex = mutex
16
+ @logger = logger
17
+ end
18
+
19
+ def start
20
+ dcc.track_power_main!
21
+
22
+ # Fetch current status for all cabs from DCC controller
23
+ cabs.each do |cab|
24
+ @logger.info "[LAYOUT] Fetching status for cab: #{cab.name} (address: #{cab.address})"
25
+
26
+ status = dcc.get_cab_status(cab.address)
27
+
28
+ if status
29
+ cab.current_speed = status[:speed]
30
+ cab.target_speed = status[:speed]
31
+ cab.direction = status[:direction]
32
+
33
+ @logger.info "[LAYOUT] Cab #{cab.name}: speed=#{status[:speed]}, direction=#{status[:direction] == 1 ? 'forward' : 'reverse'}"
34
+ else
35
+ @logger.warn "[LAYOUT] Could not fetch status for cab #{cab.name}, using defaults"
36
+ end
37
+ end
38
+ end
39
+
40
+ def emergency_stop
41
+ dcc.track_power_off!
42
+ end
43
+
44
+ # Returns a hash with the complete system status
45
+ # @return [Hash] Status information including cabs, sectors, sensors, and layout info
46
+ def status
47
+ {
48
+ layout: {
49
+ name: layout.name
50
+ },
51
+ cabs: cabs.map do |cab|
52
+ {
53
+ name: cab.name,
54
+ address: cab.address,
55
+ current_speed: cab.current_speed.round(2),
56
+ target_speed: cab.target_speed,
57
+ direction: cab.direction,
58
+ direction_name: cab.direction == 1 ? "forward" : "reverse",
59
+ acceleration: cab.acceleration,
60
+ location: cab.location ? {
61
+ id: cab.location.id,
62
+ name: cab.location.name,
63
+ speed_limit: cab.location.speed_limit
64
+ } : nil
65
+ }
66
+ end,
67
+ sectors: sectors.map do |sector|
68
+ {
69
+ id: sector.id,
70
+ name: sector.name,
71
+ speed_limit: sector.speed_limit
72
+ }
73
+ end,
74
+ sensors: proximity_sensors.map do |sensor|
75
+ {
76
+ id: sensor.id,
77
+ sector_id: sensor.sector_id
78
+ }
79
+ end
80
+ }
81
+ end
82
+
83
+ # Called periodically by control loop
84
+ # @param elapsed_time [Float] seconds since last tick
85
+ def tick(elapsed_time)
86
+ cabs.each do |cab|
87
+ # Skip if already at target
88
+ next if cab.current_speed == cab.target_speed
89
+
90
+ # Calculate maximum speed change for this tick
91
+ max_delta = cab.acceleration * elapsed_time
92
+
93
+ if cab.current_speed < cab.target_speed
94
+ # Accelerating
95
+ new_speed = [cab.current_speed + max_delta, cab.target_speed].min
96
+ else
97
+ # Decelerating
98
+ new_speed = [cab.current_speed - max_delta, cab.target_speed].max
99
+ end
100
+
101
+ # Update cab state
102
+ cab.current_speed = new_speed
103
+
104
+ # Send to DCC system (rounds to integer)
105
+ @dcc.set_speed(addr: cab.address, speed: new_speed.round, dir: cab.direction)
106
+
107
+ @logger.debug "[TICK] Cab #{cab.name} speed: #{cab.current_speed.round(2)} -> #{cab.target_speed}"
108
+ end
109
+ end
110
+
111
+ # @return [Cab] the primary cab
112
+ def primary_cab
113
+ cabs.first
114
+ end
115
+
116
+ # Set target speed for a cab
117
+ # @param cab_address [Integer] the DCC address of the cab
118
+ # @param speed [Integer] the target speed (0-127)
119
+ # @return [Boolean] true if successful, false if cab not found
120
+ def set_target_speed(cab_address:, speed:)
121
+ cab = cabs.find { |c| c.address == cab_address }
122
+
123
+ unless cab
124
+ @logger.warn "[CAB] Cannot set target speed: Cab with address #{cab_address} not found"
125
+ return false
126
+ end
127
+
128
+ # Clamp speed to valid range
129
+ speed = [[speed, 0].max, 127].min
130
+
131
+ @logger.info "[CAB] Setting target speed for #{cab.name} (#{cab_address}): #{cab.target_speed} -> #{speed}"
132
+ cab.target_speed = speed
133
+ true
134
+ end
135
+
136
+ # @param cab [Cab] the cab to handle, defaults to primary cab
137
+ # @param sector [LayoutSector] the sector that triggered the sensor
138
+ def sector_proximity_sensor_triggered(cab: nil, sensor_id:)
139
+ cab ||= primary_cab
140
+
141
+ sensor = find_sensor(sensor_id)
142
+ sector = find_sector(sensor.sector_id)
143
+
144
+ @logger.debug "[CAB] Proximity sensor (#{sensor_id}) triggered for sector: #{sector}"
145
+
146
+ unless sectors.include?(sector)
147
+ @logger.warn "Unknown sector triggered: #{sector&.id}"
148
+ return
149
+ end
150
+
151
+ if cab.location.nil?
152
+ # First sector assignment
153
+ cab.location = sector
154
+ @logger.debug "Initial sector assignment: #{sector&.id}"
155
+ return
156
+ end
157
+
158
+ if cab.location == sector
159
+ # Already in this sector, no action needed
160
+ @logger.debug "Already in sector #{sector&.id}, no action taken"
161
+ return
162
+ end
163
+
164
+ sa = sector_after(cab.location)
165
+
166
+ if sector != sa
167
+ @logger.warn "Invalid sector transition attempted: #{cab.location&.id} -> #{sector&.id}. Expected #{sa&.id}."
168
+ return
169
+ end
170
+
171
+ # Update location
172
+ cab.location = sector
173
+
174
+ if sector.speed_limit
175
+ @logger.info "[CAB] Cab #{cab.name} entered sector #{sector.id}, setting target speed to #{sector.speed_limit}"
176
+ cab.target_speed = sector.speed_limit
177
+ end
178
+
179
+ end
180
+
181
+ private
182
+
183
+ def sector_index_for(sector_id)
184
+ @logger.debug "sector_index_for #{sector_id}"
185
+ return nil if sector_id.nil?
186
+
187
+ sectors.find_index { |s| s.id == sector_id }
188
+ end
189
+
190
+ # Returns the sector object after the given sector, wrapping around to the first sector if needed
191
+ # @param sector [LayoutSector,nil] the sector
192
+ # @return [LayoutSector, nil] the next sector or nil if sector_id not found
193
+ def sector_after(sector)
194
+ @logger.debug "sector_after #{sector}"
195
+
196
+ index = sector_index_for(sector&.id)
197
+ return nil if index.nil?
198
+
199
+ next_index = (index + 1) % sectors.length
200
+ sectors[next_index]
201
+ end
202
+
203
+ end
204
+ end
@@ -0,0 +1,143 @@
1
+ require_relative 'layout'
2
+ require_relative 'cab'
3
+ require_relative 'layout_sector'
4
+ require_relative 'proximity_sensor'
5
+ require_relative 'dcc/client'
6
+ require 'logger'
7
+
8
+ module SignalBox
9
+ module LayoutDSL
10
+ # Builder for cab blocks
11
+ class CabBuilder
12
+ attr_reader :cab_name, :cab_address, :cab_acceleration
13
+
14
+ def name(value)
15
+ @cab_name = value
16
+ end
17
+
18
+ def address(value)
19
+ @cab_address = value
20
+ end
21
+
22
+ def acceleration(value)
23
+ @cab_acceleration = value
24
+ end
25
+
26
+ def build(logger:)
27
+ Cab.new(
28
+ name: @cab_name || "",
29
+ address: @cab_address || 0,
30
+ acceleration: @cab_acceleration || 5,
31
+ logger: logger
32
+ )
33
+ end
34
+ end
35
+
36
+ # Builder for sector blocks
37
+ class SectorBuilder
38
+ attr_reader :sector_id, :sector_name, :sector_speed_limit
39
+
40
+ def id(value)
41
+ @sector_id = value
42
+ end
43
+
44
+ def name(value)
45
+ @sector_name = value
46
+ end
47
+
48
+ def speed_limit(value)
49
+ @sector_speed_limit = value
50
+ end
51
+
52
+ def build
53
+ LayoutSector.new(
54
+ id: @sector_id,
55
+ name: @sector_name || "",
56
+ speed_limit: @sector_speed_limit || 10
57
+ )
58
+ end
59
+ end
60
+
61
+ # Builder for proximity_sensor blocks
62
+ class ProximitySensorBuilder
63
+ attr_reader :sensor_id, :sensor_sector_id
64
+
65
+ def id(value)
66
+ @sensor_id = value
67
+ end
68
+
69
+ def sector_id(value)
70
+ @sensor_sector_id = value
71
+ end
72
+
73
+ def build
74
+ ProximitySensor.new(
75
+ id: @sensor_id,
76
+ sector_id: @sensor_sector_id
77
+ )
78
+ end
79
+ end
80
+
81
+ # Builder for dcc blocks
82
+ class DCCBuilder
83
+ attr_reader :dcc_host, :dcc_port
84
+
85
+ def host(value)
86
+ @dcc_host = value
87
+ end
88
+
89
+ def port(value)
90
+ @dcc_port = value
91
+ end
92
+
93
+ def build(logger:)
94
+ DCC::Client.new(
95
+ @dcc_host,
96
+ @dcc_port,
97
+ logger: logger
98
+ )
99
+ end
100
+ end
101
+
102
+ # Main layout builder
103
+ class LayoutBuilder
104
+ attr_reader :layout
105
+
106
+ def initialize(name:, logger: Logger.new(STDOUT))
107
+ @layout = Layout.new(name: name)
108
+ @logger = logger
109
+ end
110
+
111
+ def cab(&block)
112
+ builder = CabBuilder.new
113
+ builder.instance_eval(&block)
114
+ @layout.add_cab(builder.build(logger: @logger))
115
+ end
116
+
117
+ def sector(&block)
118
+ builder = SectorBuilder.new
119
+ builder.instance_eval(&block)
120
+ @layout.add_sector(builder.build)
121
+ end
122
+
123
+ def proximity_sensor(&block)
124
+ builder = ProximitySensorBuilder.new
125
+ builder.instance_eval(&block)
126
+ @layout.add_proximity_sensor(builder.build)
127
+ end
128
+
129
+ def dcc(&block)
130
+ builder = DCCBuilder.new
131
+ builder.instance_eval(&block)
132
+ @layout.set_dcc(builder.build(logger: @logger))
133
+ end
134
+ end
135
+
136
+ # Top-level DSL method
137
+ def layout(name:, logger: Logger.new(STDOUT), &block)
138
+ builder = LayoutBuilder.new(name: name, logger: logger)
139
+ builder.instance_eval(&block)
140
+ builder.layout
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,31 @@
1
+ require_relative 'layout_dsl'
2
+
3
+ module SignalBox
4
+ class LayoutLoader
5
+ extend LayoutDSL
6
+
7
+ # Load a layout file and return the Layout object
8
+ # @param file_path [String] Path to the layout definition file
9
+ # @param logger [Logger] Logger instance to use
10
+ # @return [Layout] The loaded layout
11
+ def self.load(file_path, logger: Logger.new(STDOUT))
12
+ unless File.exist?(file_path)
13
+ raise "Layout file not found: #{file_path}"
14
+ end
15
+
16
+ logger.info "[LAYOUT] Loading layout from #{file_path}"
17
+
18
+ # Read and evaluate the layout file in the context of this class
19
+ # This makes the `layout` method available
20
+ content = File.read(file_path)
21
+ layout_obj = self.instance_eval(content, file_path)
22
+
23
+ logger.info "[LAYOUT] Loaded: #{layout_obj}"
24
+ logger.info "[LAYOUT] Cabs: #{layout_obj.cabs.map(&:name).join(', ')}"
25
+ logger.info "[LAYOUT] Sectors: #{layout_obj.sectors.map(&:name).join(', ')}"
26
+ logger.info "[LAYOUT] Sensors: #{layout_obj.proximity_sensors.map(&:id).join(', ')}"
27
+
28
+ layout_obj
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module SignalBox
2
+ class LayoutSector
3
+ attr_reader :name, :id, :speed_limit
4
+
5
+ def initialize(name: "", id: "", speed_limit: 10)
6
+ @speed_limit = speed_limit
7
+ @name = name
8
+ @id = id
9
+ end
10
+
11
+ def == (other)
12
+ other.is_a?(LayoutSector) && other.id == @id
13
+ end
14
+
15
+ def to_s
16
+ "Sector(name: #{@name}, id: #{@id})"
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ module SignalBox
2
+ class ProximitySensor
3
+ attr_reader :id, :sector_id
4
+
5
+ def initialize(id:, sector_id:)
6
+ @id = id
7
+ @sector_id = sector_id
8
+ end
9
+
10
+ def ==(other)
11
+ other.is_a?(ProximitySensor) && other.id == @id
12
+ end
13
+
14
+ def to_s
15
+ "ProximitySensor(id: #{@id}, sector_id: #{@sector_id})"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,107 @@
1
+ require 'json'
2
+
3
+ module SignalBox
4
+ class SensorClientHandler
5
+ DETECTION_THRESHOLD = 1500
6
+
7
+ def initialize(layout_controller:, mutex:, logger:)
8
+ @layout_controller = layout_controller
9
+ @mutex = mutex
10
+ @logger = logger
11
+ end
12
+
13
+ def handle_client(client)
14
+ begin
15
+ client.sync = true
16
+ peer = client.peeraddr
17
+ @logger.info "Client connected from #{peer[2]}:#{peer[1]}"
18
+
19
+ sensor_id = nil
20
+
21
+ while (line = client.gets)
22
+ line = line.strip
23
+ next if line.empty?
24
+
25
+ @logger.debug "Raw line: #{line}"
26
+
27
+ parts = line.split(" ")
28
+ cmd = parts.shift
29
+
30
+ case cmd
31
+ when "HELLO"
32
+ handle_hello(parts)
33
+
34
+ when "READING"
35
+ sensor_id = parts.shift
36
+ handle_reading(sensor_id, parts)
37
+
38
+ when "MANUAL_SECTOR_ADVANCE"
39
+ handle_manual_sector_advance(client)
40
+
41
+ when "STATUS"
42
+ handle_status(client)
43
+
44
+ when "SET_TARGET_SPEED"
45
+ cab_address = parts.shift.to_i
46
+ speed = parts.shift.to_i
47
+ handle_set_target_speed(client, cab_address, speed)
48
+
49
+ else
50
+ @logger.warn "[WARN] Unknown cmd: #{line}"
51
+ end
52
+ end
53
+ rescue => e
54
+ @logger.warn "Client thread error: #{e}\n#{e.backtrace.join("\n")}"
55
+ ensure
56
+ client.close rescue nil
57
+ @logger.debug "Client disconnected"
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def handle_hello(parts)
64
+ esp32_id = parts[0] || "unknown"
65
+ fw = parts[1] || "?"
66
+ @logger.info "[HELLO] ESP32 connected: id=#{esp32_id} fw=#{fw}"
67
+ end
68
+
69
+ def handle_reading(sensor_id, parts)
70
+ kv = parts.reject(&:empty?).filter_map { |p| p.split("=", 2) if p.include?("=") }.to_h
71
+ avg = kv["avg"].to_i
72
+
73
+ @logger.info "[READING] id=#{sensor_id} avg=#{avg}"
74
+
75
+ # Detect train passage (rising edge detection)
76
+ @mutex.synchronize do
77
+ if avg < DETECTION_THRESHOLD
78
+ @layout_controller.sector_proximity_sensor_triggered(sensor_id: sensor_id)
79
+ end
80
+ end
81
+ end
82
+
83
+ def handle_manual_sector_advance(client)
84
+ @logger.info "[MANUAL] Manual sector advance requested"
85
+ @mutex.synchronize do
86
+ @layout_controller.advance_sector
87
+ client.puts "OK"
88
+ end
89
+ end
90
+
91
+ def handle_status(client)
92
+ @logger.info "[STATUS] Status request received"
93
+ @mutex.synchronize do
94
+ status = @layout_controller.status
95
+ client.puts JSON.generate(status)
96
+ end
97
+ end
98
+
99
+ def handle_set_target_speed(client, cab_address, speed)
100
+ @logger.info "[SET_TARGET_SPEED] Cab #{cab_address} -> #{speed}"
101
+ @mutex.synchronize do
102
+ success = @layout_controller.set_target_speed(cab_address: cab_address, speed: speed)
103
+ client.puts success ? "OK" : "ERROR"
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalBox
4
+ VERSION = "0.1.0"
5
+ end
data/lib/signalbox.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add lib directory to load path
4
+ $LOAD_PATH.unshift(__dir__) unless $LOAD_PATH.include?(__dir__)
5
+
6
+ require "signalbox/version"
7
+
8
+ # Core classes
9
+ require "signalbox/cab"
10
+ require "signalbox/layout"
11
+ require "signalbox/layout_sector"
12
+ require "signalbox/proximity_sensor"
13
+
14
+ # DCC communication
15
+ require "signalbox/dcc/client"
16
+
17
+ # Controllers
18
+ require "signalbox/layout_controller"
19
+
20
+ # DSL and loaders
21
+ require "signalbox/layout_dsl"
22
+ require "signalbox/layout_loader"
23
+
24
+ # Server components
25
+ require "signalbox/sensor_client_handler"
26
+ require "signalbox/config_store"
27
+
28
+ module SignalBox
29
+ class Error < StandardError; end
30
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: signalbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff McFadden
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: SignalBox is a distributed control system for DCC model railroads. It
55
+ provides a Ruby-based server that receives sensor events from ESP32 nodes, applies
56
+ control logic, and sends DCC-EX commands to control trains.
57
+ email:
58
+ - ''
59
+ executables:
60
+ - signalbox-conductor
61
+ - signalbox-server
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - README.md
66
+ - exe/signalbox-conductor
67
+ - exe/signalbox-server
68
+ - lib/signalbox.rb
69
+ - lib/signalbox/cab.rb
70
+ - lib/signalbox/config_store.rb
71
+ - lib/signalbox/dcc/client.rb
72
+ - lib/signalbox/layout.rb
73
+ - lib/signalbox/layout_controller.rb
74
+ - lib/signalbox/layout_dsl.rb
75
+ - lib/signalbox/layout_loader.rb
76
+ - lib/signalbox/layout_sector.rb
77
+ - lib/signalbox/proximity_sensor.rb
78
+ - lib/signalbox/sensor_client_handler.rb
79
+ - lib/signalbox/version.rb
80
+ homepage: https://github.com/jeffmcfadden/signalbox
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.0.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.7.2
99
+ specification_version: 4
100
+ summary: DCC model railroad automation framework with sensor-driven control
101
+ test_files: []