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 +7 -0
- data/README.md +24 -0
- data/exe/signalbox-conductor +210 -0
- data/exe/signalbox-server +165 -0
- data/lib/signalbox/cab.rb +27 -0
- data/lib/signalbox/config_store.rb +47 -0
- data/lib/signalbox/dcc/client.rb +180 -0
- data/lib/signalbox/layout.rb +41 -0
- data/lib/signalbox/layout_controller.rb +204 -0
- data/lib/signalbox/layout_dsl.rb +143 -0
- data/lib/signalbox/layout_loader.rb +31 -0
- data/lib/signalbox/layout_sector.rb +20 -0
- data/lib/signalbox/proximity_sensor.rb +18 -0
- data/lib/signalbox/sensor_client_handler.rb +107 -0
- data/lib/signalbox/version.rb +5 -0
- data/lib/signalbox.rb +30 -0
- metadata +101 -0
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
|
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: []
|