rsmp 0.45.2 → 0.47.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ceec0bb561c61873341a4af09334ed0c6e0eb7bb1909b6fb38f2a350aaae8ca9
4
- data.tar.gz: 41f8df99b563e086770f502e69ff8bc8a8dc91b075aa9e50c2f0dd451b8bc0ad
3
+ metadata.gz: c206e0c53de4f523b03832b8821b698ea0f0440c11cdb51f5685382f4bdaebf5
4
+ data.tar.gz: 2b35690dbe819d58cf76fe82faf1baec3392e6ecf54fbcd71b0e15c4fd7b7887
5
5
  SHA512:
6
- metadata.gz: 46ee01791490e180283de79f9568b4d37ad8bef6bd228f2cee5c6eca7d95f568fad715f5f48314c1ebfec8610185bd027b7cc5e65701af248d2253d3569ff491
7
- data.tar.gz: 26b27b919cc42a3fdf337720bfb31c3168e8a2a95a0361b642efe27973352eaffbcb4e4c07ea3d55a6f0d4e59d57650c226ed603edcf6a9c45747bb1b3439c98
6
+ metadata.gz: 4e7c84bc1a7c35f5f58108db5803cc8f0a9abe58ab9edc83a09d778d62479bcc1e0310eb2af9c0e9fe1cad75bf187303ba0d8759ff8b793dd15ae1abe118bcfe
7
+ data.tar.gz: 562f88737283e91ab232c68c70344c9f668e5d08b0aa488b24ac21af57d07c30936b297c647aa00bedc7dae585faf8ed6be3593849c3f63721b1974b809b1696
data/CHANGELOG.md CHANGED
@@ -670,3 +670,22 @@ Initial release.
670
670
  - fix issues with config normalization
671
671
  - make collector cancellation raise pending errors
672
672
  - replace Cucumber/Aruba CLI tests with sus tests that call the Thor CLI directly
673
+
674
+ ## 0.45.1
675
+ - fix CLI execution by using relative requires
676
+ - update schema generation and generated schemas
677
+ - support patterns in list item types
678
+ - resolve SXL schema core definitions dynamically
679
+ - add CLI tests and split message tests
680
+
681
+ ## 0.45.2
682
+ - update gems
683
+ - add AGENTS.md
684
+ - replace em dashes to avoid UTF issues
685
+
686
+ ## 0.46.0
687
+ - add outgoing message buffering for sites
688
+
689
+ ## 0.47.0
690
+ - add cli command for checking configs
691
+ - tighten config schemas
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rsmp (0.45.2)
4
+ rsmp (0.47.0)
5
5
  async (~> 2.39)
6
6
  colorize (~> 1.1)
7
7
  io-endpoint (~> 0.17)
@@ -46,10 +46,36 @@ sxls:
46
46
  components:
47
47
  main:
48
48
  TC:
49
+ message_buffer:
50
+ max_messages: 10000
51
+ statuses: true
49
52
  log:
50
53
  json: true
51
54
  ```
52
55
 
56
+ ## Message Buffer
57
+
58
+ Sites buffer outgoing alarm and aggregated status messages while a supervisor connection is down. Status updates are buffered according to `message_buffer.statuses`, which defaults to `true`.
59
+
60
+ ```yaml
61
+ message_buffer:
62
+ max_messages: 10000
63
+ statuses: true
64
+ ```
65
+
66
+ `statuses: true` buffers all subscribed status updates during communication disruption. To buffer only selected statuses, provide selectors:
67
+
68
+ ```yaml
69
+ message_buffer:
70
+ statuses:
71
+ - sCI: S0001
72
+ n: signalgroupstatus
73
+ ```
74
+
75
+ Use `statuses: false` or an empty list to avoid buffering status updates. Subscriptions for statuses that are not buffered are removed when the connection is lost.
76
+
77
+ The current implementation uses an in-memory buffer per supervisor connection. Buffered messages survive reconnects while the process keeps running, but are lost if the process exits, crashes, or the host loses power. The RSMP core specification requires the outgoing communication buffer to survive communication failure and power outage, so this is not yet a complete persistent buffer implementation.
78
+
53
79
  ## Example: supervisor YAML
54
80
 
55
81
  ```yaml
data/lib/rsmp/cli.rb CHANGED
@@ -20,6 +20,32 @@ module RSMP
20
20
  end
21
21
  end
22
22
 
23
+ # CLI subcommands for RSMP configuration validation.
24
+ class ConfigCLI < Thor
25
+ namespace :config
26
+ desc 'check PATH...', 'Validate RSMP site or supervisor config files'
27
+ method_option :type, type: :string, aliases: '-t', default: 'auto',
28
+ enum: ['auto'] + RSMP::Config.types,
29
+ banner: 'Config type: auto, site, tlc or supervisor'
30
+ def check(*paths)
31
+ if paths.empty?
32
+ puts 'Error: config check requires at least one path'
33
+ exit 1
34
+ end
35
+
36
+ valid = true
37
+ paths.each do |path|
38
+ RSMP::Config.load_file(path, type: options[:type])
39
+ puts 'OK'
40
+ rescue RSMP::ConfigurationError => e
41
+ valid = false
42
+ puts "Error: #{e.message}"
43
+ end
44
+
45
+ exit 1 unless valid
46
+ end
47
+ end
48
+
23
49
  # CLI commands for running RSMP site and supervisor.
24
50
  class CLI < Thor
25
51
  desc 'version', 'Show version'
@@ -69,6 +95,7 @@ module RSMP
69
95
  end
70
96
 
71
97
  register SchemaCLI, 'schema', 'schema COMMAND', 'SXL schema commands'
98
+ register ConfigCLI, 'config', 'config COMMAND', 'Configuration commands'
72
99
 
73
100
  no_commands do
74
101
  def load_site_configuration
@@ -0,0 +1,63 @@
1
+ module RSMP
2
+ # Validation helpers for RSMP runtime configuration hashes and files.
3
+ module Config
4
+ class << self
5
+ def validate(settings = {}, type:, source: nil, log_settings: nil)
6
+ options_class_for(type).new(settings, source: source, log_settings: log_settings)
7
+ end
8
+
9
+ def load_file(path, type:)
10
+ raise RSMP::ConfigurationError, 'not found' unless File.exist?(path)
11
+ raise RSMP::ConfigurationError, 'is not a file' unless File.file?(path)
12
+ raise RSMP::ConfigurationError, 'must be a YAML file (.yml or .yaml)' unless yaml_file?(path)
13
+
14
+ raw = YAML.load_file(path)
15
+ raise RSMP::ConfigurationError, "Config #{path} must be a hash" unless raw.is_a?(Hash) || raw.nil?
16
+
17
+ raw ||= {}
18
+ settings = raw.dup
19
+ log_settings = settings.delete('log') || {}
20
+ validate(settings, type: resolve_type(type, settings), log_settings: log_settings)
21
+ rescue Psych::SyntaxError => e
22
+ raise RSMP::ConfigurationError, "Cannot read config file #{path}: #{e}"
23
+ end
24
+
25
+ def types
26
+ %w[site supervisor tlc]
27
+ end
28
+
29
+ private
30
+
31
+ def yaml_file?(path)
32
+ %w[.yml .yaml].include?(File.extname(path).downcase)
33
+ end
34
+
35
+ def resolve_type(type, settings)
36
+ type = type.to_s
37
+ return infer_type(settings) if type == 'auto'
38
+
39
+ type
40
+ end
41
+
42
+ def infer_type(settings)
43
+ return 'supervisor' if settings.key?('sites')
44
+ return 'site' if settings.key?('supervisors')
45
+
46
+ raise RSMP::ConfigurationError, 'Cannot infer config type; use --type site, --type tlc or --type supervisor'
47
+ end
48
+
49
+ def options_class_for(type)
50
+ case type.to_s
51
+ when 'site'
52
+ RSMP::Site::Options
53
+ when 'supervisor'
54
+ RSMP::Supervisor::Options
55
+ when 'tlc'
56
+ RSMP::TLC::TrafficControllerSite::Options
57
+ else
58
+ raise RSMP::ConfigurationError, "Unknown config type #{type.inspect}, expected one of #{types.join(', ')}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -96,10 +96,9 @@ module RSMP
96
96
 
97
97
  def run
98
98
  log_site_starting
99
- @proxies.each do |proxy|
100
- proxy.start
101
- proxy.wait
102
- end
99
+ start_status_timer
100
+ @proxies.each(&:start)
101
+ @proxies.each(&:wait)
103
102
  end
104
103
 
105
104
  def build_proxies
@@ -119,7 +118,7 @@ module RSMP
119
118
 
120
119
  def aggregated_status_changed(component, _options = {})
121
120
  @proxies.each do |proxy|
122
- proxy.send_aggregated_status component if proxy.ready?
121
+ proxy.send_aggregated_status component
123
122
  end
124
123
  end
125
124
 
@@ -137,10 +136,50 @@ module RSMP
137
136
 
138
137
  def send_alarm(alarm)
139
138
  @proxies.each do |proxy|
140
- proxy.send_message alarm if proxy.ready? && proxy.receive_alarms?
139
+ proxy.send_message alarm if proxy.receive_alarms?
140
+ end
141
+ end
142
+
143
+ def start_status_timer
144
+ return if @status_timer
145
+
146
+ interval = @site_settings['intervals']['timer'] || 1
147
+ log "Starting site status timer with interval #{interval} seconds", level: :debug
148
+ @status_timer = @task.async do |task|
149
+ task.annotate 'site status timer'
150
+ run_status_timer task, interval
151
+ end
152
+ end
153
+
154
+ def run_status_timer(task, interval)
155
+ next_time = Time.now.to_f
156
+ loop do
157
+ now = Clock.now
158
+ tick_status_subscriptions now
159
+ rescue StandardError => e
160
+ distribute_error e, level: :internal
161
+ ensure
162
+ next_time += interval
163
+ duration = next_time - Time.now.to_f
164
+ task.sleep duration
141
165
  end
142
166
  end
143
167
 
168
+ def tick_status_subscriptions(now)
169
+ @proxies.each { |proxy| proxy.status_update_timer now }
170
+ end
171
+
172
+ def stop_status_timer
173
+ @status_timer&.stop
174
+ ensure
175
+ @status_timer = nil
176
+ end
177
+
178
+ def stop_subtasks
179
+ stop_status_timer
180
+ super
181
+ end
182
+
144
183
  def connect_to_supervisor(_task, supervisor_settings)
145
184
  proxy = build_proxy({
146
185
  site: self,
@@ -10,6 +10,8 @@ module RSMP
10
10
 
11
11
  def self.load_file(path, validate: true)
12
12
  raise RSMP::ConfigurationError, "Config #{path} not found" unless File.exist?(path)
13
+ raise RSMP::ConfigurationError, "Config #{path} is not a file" unless File.file?(path)
14
+ raise RSMP::ConfigurationError, "Config #{path} must be a YAML file (.yml or .yaml)" unless yaml_file?(path)
13
15
 
14
16
  raw = YAML.load_file(path)
15
17
  raise RSMP::ConfigurationError, "Config #{path} must be a hash" unless raw.is_a?(Hash) || raw.nil?
@@ -21,27 +23,37 @@ module RSMP
21
23
  raise RSMP::ConfigurationError, "Cannot read config file #{path}: #{e}"
22
24
  end
23
25
 
26
+ def self.yaml_file?(path)
27
+ %w[.yml .yaml].include?(File.extname(path).downcase)
28
+ end
29
+
24
30
  def initialize(options = nil, source: nil, log_settings: nil, validate: true, **extra)
25
31
  options = extra if options.nil? && extra.any?
26
32
  @source = source
27
33
  @log_settings = normalize(log_settings || {})
34
+ validate_log_settings! if validate
28
35
  config = normalize_config(options || {})
29
36
  validate!(config) if validate
30
37
  @data = normalize(apply_defaults(config))
31
38
  end
32
39
 
33
- def defaults
34
- {}
35
- end
40
+ def defaults = {}
36
41
 
37
- def schema_file
38
- nil
39
- end
42
+ def schema_file = nil
43
+
44
+ def schema_path = schema_file && File.join(SCHEMAS_PATH, schema_file)
40
45
 
41
- def schema_path
42
- return unless schema_file
46
+ def log_schema_path = File.join(SCHEMAS_PATH, 'log.json')
43
47
 
44
- File.join(SCHEMAS_PATH, schema_file)
48
+ def validate_log_settings!
49
+ return unless File.exist?(log_schema_path)
50
+
51
+ schemer = JSONSchemer.schema(Pathname.new(log_schema_path))
52
+ errors = schemer.validate(@log_settings).to_a
53
+ return if errors.empty?
54
+
55
+ message = errors.map { |error| format_error(error) }.join("\n")
56
+ raise RSMP::ConfigurationError, "Invalid log configuration#{source_suffix}:\n#{message}"
45
57
  end
46
58
 
47
59
  def validate!(data = @data)
@@ -0,0 +1,129 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "config_common.json",
4
+ "$defs": {
5
+ "endpoint": {
6
+ "type": "object",
7
+ "properties": {
8
+ "ip": { "type": "string" },
9
+ "port": { "type": ["integer", "string"] }
10
+ },
11
+ "required": ["ip", "port"],
12
+ "additionalProperties": false
13
+ },
14
+ "intervals": {
15
+ "type": "object",
16
+ "properties": {
17
+ "timer": { "type": "number" },
18
+ "watchdog": { "type": "number" },
19
+ "reconnect": { "type": "number" },
20
+ "after_connect": { "type": "number" }
21
+ },
22
+ "additionalProperties": false
23
+ },
24
+ "timeouts": {
25
+ "type": "object",
26
+ "properties": {
27
+ "connect": { "type": "number" },
28
+ "ready": { "type": "number" },
29
+ "watchdog": { "type": "number" },
30
+ "acknowledgement": { "type": "number" },
31
+ "command": { "type": "number" },
32
+ "command_timeout": { "type": "number" },
33
+ "status_response": { "type": "number" }
34
+ },
35
+ "additionalProperties": false
36
+ },
37
+ "component_settings": {
38
+ "type": ["object", "null"],
39
+ "properties": {
40
+ "name": { "type": "string" },
41
+ "ntsOId": { "type": "string" },
42
+ "xNId": { "type": "string" },
43
+ "type": { "type": "string" }
44
+ },
45
+ "additionalProperties": false
46
+ },
47
+ "components": {
48
+ "type": "object",
49
+ "additionalProperties": {
50
+ "type": ["object", "null"],
51
+ "additionalProperties": { "$ref": "#/$defs/component_settings" }
52
+ }
53
+ },
54
+ "security_codes": {
55
+ "type": "object",
56
+ "additionalProperties": { "type": "string" }
57
+ },
58
+ "signal_plans": {
59
+ "type": "object",
60
+ "additionalProperties": {
61
+ "type": "object",
62
+ "properties": {
63
+ "cycle_time": { "type": "number" },
64
+ "states": {
65
+ "type": "object",
66
+ "additionalProperties": { "type": "string" }
67
+ },
68
+ "dynamic_bands": {
69
+ "type": "object",
70
+ "additionalProperties": { "type": "integer" }
71
+ }
72
+ },
73
+ "additionalProperties": false
74
+ }
75
+ },
76
+ "input_programming_action": {
77
+ "type": ["object", "null"],
78
+ "properties": {
79
+ "raise_alarm": { "type": "string" },
80
+ "component": { "type": "string" }
81
+ },
82
+ "additionalProperties": false
83
+ },
84
+ "inputs": {
85
+ "type": "object",
86
+ "properties": {
87
+ "total": { "type": "integer", "minimum": 1 },
88
+ "programming": {
89
+ "oneOf": [
90
+ {
91
+ "type": "object",
92
+ "additionalProperties": { "$ref": "#/$defs/input_programming_action" }
93
+ },
94
+ {
95
+ "type": "array",
96
+ "items": { "$ref": "#/$defs/input_programming_action" }
97
+ }
98
+ ]
99
+ }
100
+ },
101
+ "additionalProperties": false
102
+ },
103
+ "message_buffer": {
104
+ "type": "object",
105
+ "properties": {
106
+ "max_messages": { "type": "integer", "minimum": 1 },
107
+ "statuses": {
108
+ "oneOf": [
109
+ { "type": "boolean" },
110
+ {
111
+ "type": "array",
112
+ "items": {
113
+ "type": "object",
114
+ "properties": {
115
+ "cId": { "type": "string" },
116
+ "sCI": { "type": "string" },
117
+ "n": { "type": "string" }
118
+ },
119
+ "required": ["sCI"],
120
+ "additionalProperties": false
121
+ }
122
+ }
123
+ ]
124
+ }
125
+ },
126
+ "additionalProperties": false
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "log.json",
4
+ "$defs": {
5
+ "display_field": {
6
+ "oneOf": [
7
+ { "type": "boolean" },
8
+ { "type": "integer", "minimum": 0 },
9
+ { "type": "string" }
10
+ ]
11
+ },
12
+ "message_filter": { "type": "boolean" },
13
+ "style_item": {
14
+ "oneOf": [
15
+ { "type": "string" },
16
+ {
17
+ "type": "object",
18
+ "properties": {
19
+ "color": { "type": "string" },
20
+ "mode": { "type": "string" }
21
+ },
22
+ "additionalProperties": false
23
+ }
24
+ ]
25
+ },
26
+ "style": {
27
+ "oneOf": [
28
+ { "type": "boolean" },
29
+ { "type": "string" },
30
+ {
31
+ "type": "object",
32
+ "additionalProperties": { "$ref": "#/$defs/style_item" }
33
+ }
34
+ ]
35
+ }
36
+ },
37
+ "type": "object",
38
+ "properties": {
39
+ "active": { "type": "boolean" },
40
+ "path": { "type": ["string", "null"] },
41
+ "stream": {},
42
+ "style": { "$ref": "#/$defs/style" },
43
+ "debug": { "type": "boolean" },
44
+ "info": { "type": "boolean" },
45
+ "statistics": { "type": "boolean" },
46
+ "hide_ip_and_port": { "type": "boolean" },
47
+ "acknowledgements": { "type": "boolean" },
48
+ "watchdogs": { "$ref": "#/$defs/message_filter" },
49
+ "alarms": { "$ref": "#/$defs/message_filter" },
50
+ "versions": { "$ref": "#/$defs/message_filter" },
51
+ "statuses": { "$ref": "#/$defs/message_filter" },
52
+ "commands": { "$ref": "#/$defs/message_filter" },
53
+ "aggregated_status": { "$ref": "#/$defs/message_filter" },
54
+ "tabs": { "type": ["string", "boolean", "null"] },
55
+ "prefix": { "$ref": "#/$defs/display_field" },
56
+ "index": { "$ref": "#/$defs/display_field" },
57
+ "author": { "$ref": "#/$defs/display_field" },
58
+ "timestamp": { "$ref": "#/$defs/display_field" },
59
+ "ip": { "$ref": "#/$defs/display_field" },
60
+ "port": { "$ref": "#/$defs/display_field" },
61
+ "site_id": { "$ref": "#/$defs/display_field" },
62
+ "component": { "$ref": "#/$defs/display_field" },
63
+ "direction": { "$ref": "#/$defs/display_field" },
64
+ "level": { "$ref": "#/$defs/display_field" },
65
+ "id": { "$ref": "#/$defs/display_field" },
66
+ "text": { "$ref": "#/$defs/display_field" },
67
+ "json": { "$ref": "#/$defs/display_field" },
68
+ "exception": { "$ref": "#/$defs/display_field" }
69
+ },
70
+ "additionalProperties": false
71
+ }
@@ -7,15 +7,7 @@
7
7
  "type": { "type": "string" },
8
8
  "supervisors": {
9
9
  "type": "array",
10
- "items": {
11
- "type": "object",
12
- "properties": {
13
- "ip": { "type": "string" },
14
- "port": { "type": ["integer", "string"] }
15
- },
16
- "required": ["ip", "port"],
17
- "additionalProperties": true
18
- }
10
+ "items": { "$ref": "config_common.json#/$defs/endpoint" }
19
11
  },
20
12
  "sxls": {
21
13
  "type": "object",
@@ -23,30 +15,16 @@
23
15
  "additionalProperties": { "type": "string" }
24
16
  },
25
17
  "core_version": { "type": "string" },
26
- "intervals": {
27
- "type": "object",
28
- "properties": {
29
- "timer": { "type": "number" },
30
- "watchdog": { "type": "number" },
31
- "reconnect": { "type": "number" }
32
- },
33
- "additionalProperties": true
34
- },
35
- "timeouts": {
36
- "type": "object",
37
- "properties": {
38
- "watchdog": { "type": "number" },
39
- "acknowledgement": { "type": "number" }
40
- },
41
- "additionalProperties": true
42
- },
18
+ "intervals": { "$ref": "config_common.json#/$defs/intervals" },
19
+ "timeouts": { "$ref": "config_common.json#/$defs/timeouts" },
43
20
  "send_after_connect": { "type": "boolean" },
44
- "components": { "type": "object" },
45
- "security_codes": { "type": "object" },
21
+ "message_buffer": { "$ref": "config_common.json#/$defs/message_buffer" },
22
+ "components": { "$ref": "config_common.json#/$defs/components" },
23
+ "security_codes": { "$ref": "config_common.json#/$defs/security_codes" },
46
24
  "startup_sequence": { "type": "string" },
47
- "signal_plans": { "type": "object" },
48
- "inputs": { "type": "object" },
25
+ "signal_plans": { "$ref": "config_common.json#/$defs/signal_plans" },
26
+ "inputs": { "$ref": "config_common.json#/$defs/inputs" },
49
27
  "live_output": { "type": ["string", "null"] }
50
28
  },
51
- "additionalProperties": true
29
+ "additionalProperties": false
52
30
  }
@@ -4,6 +4,7 @@
4
4
  "properties": {
5
5
  "port": { "type": ["integer", "string"] },
6
6
  "ip": { "type": "string" },
7
+ "proxy_type": { "type": "string" },
7
8
  "ips": {
8
9
  "oneOf": [
9
10
  { "type": "string" },
@@ -12,38 +13,11 @@
12
13
  },
13
14
  "site_id": { "type": "string" },
14
15
  "max_sites": { "type": "integer" },
15
- "default": {
16
- "type": "object",
17
- "properties": {
18
- "sxls": {
19
- "type": "object",
20
- "propertyNames": { "not": { "const": "core" } },
21
- "additionalProperties": { "type": "string" }
22
- },
23
- "core_version": { "type": "string" },
24
- "intervals": {
25
- "type": "object",
26
- "properties": {
27
- "timer": { "type": "number" },
28
- "watchdog": { "type": "number" }
29
- },
30
- "additionalProperties": true
31
- },
32
- "timeouts": {
33
- "type": "object",
34
- "properties": {
35
- "watchdog": { "type": "number" },
36
- "acknowledgement": { "type": "number" }
37
- },
38
- "additionalProperties": true
39
- }
40
- },
41
- "additionalProperties": true
42
- },
16
+ "default": { "$ref": "supervisor_site.json" },
43
17
  "sites": {
44
18
  "type": "object",
45
19
  "additionalProperties": { "$ref": "supervisor_site.json" }
46
20
  }
47
21
  },
48
- "additionalProperties": true
22
+ "additionalProperties": false
49
23
  }
@@ -8,42 +8,30 @@
8
8
  "propertyNames": { "not": { "const": "core" } },
9
9
  "additionalProperties": { "type": "string" }
10
10
  },
11
+ "core_version": { "type": "string" },
12
+ "rsmp_versions": {
13
+ "oneOf": [
14
+ { "type": "string" },
15
+ { "type": "array", "items": { "type": "string" } }
16
+ ]
17
+ },
11
18
  "type": { "type": "string" },
12
- "components": { "type": "object" },
19
+ "components": { "$ref": "config_common.json#/$defs/components" },
13
20
  "supervisors": {
14
21
  "type": "array",
15
- "items": {
16
- "type": "object",
17
- "properties": {
18
- "ip": { "type": "string" },
19
- "port": { "type": ["integer", "string"] }
20
- },
21
- "required": ["ip", "port"],
22
- "additionalProperties": true
23
- }
24
- },
25
- "intervals": {
26
- "type": "object",
27
- "properties": {
28
- "timer": { "type": "number" },
29
- "watchdog": { "type": "number" },
30
- "reconnect": { "type": "number" },
31
- "after_connect": { "type": "number" }
32
- },
33
- "additionalProperties": true
34
- },
35
- "timeouts": {
36
- "type": "object",
37
- "properties": {
38
- "connect": { "type": "number" },
39
- "watchdog": { "type": "number" },
40
- "acknowledgement": { "type": "number" }
41
- },
42
- "additionalProperties": true
22
+ "items": { "$ref": "config_common.json#/$defs/endpoint" }
43
23
  },
24
+ "intervals": { "$ref": "config_common.json#/$defs/intervals" },
25
+ "timeouts": { "$ref": "config_common.json#/$defs/timeouts" },
44
26
  "send_after_connect": { "type": "boolean" },
27
+ "receive_alarms": { "type": "boolean" },
28
+ "message_buffer": { "$ref": "config_common.json#/$defs/message_buffer" },
45
29
  "skip_validation": { "type": "array", "items": { "type": "string" } },
46
- "security_codes": { "type": "object" }
30
+ "security_codes": { "$ref": "config_common.json#/$defs/security_codes" },
31
+ "startup_sequence": { "type": "string" },
32
+ "signal_plans": { "$ref": "config_common.json#/$defs/signal_plans" },
33
+ "inputs": { "$ref": "config_common.json#/$defs/inputs" },
34
+ "live_output": { "type": ["string", "null"] }
47
35
  },
48
- "additionalProperties": true
36
+ "additionalProperties": false
49
37
  }
@@ -12,8 +12,7 @@
12
12
  "inputs": { "type": "object" },
13
13
  "live_output": { "type": ["string", "null"] },
14
14
  "type": { "type": "string" }
15
- },
16
- "additionalProperties": true
15
+ }
17
16
  }
18
17
  ]
19
18
  }
@@ -21,6 +21,7 @@ module RSMP
21
21
  'acknowledgement' => 2
22
22
  },
23
23
  'send_after_connect' => true,
24
+ 'message_buffer' => default_message_buffer,
24
25
  'components' => {
25
26
  'main' => {
26
27
  'C1' => {}
@@ -35,6 +36,13 @@ module RSMP
35
36
 
36
37
  private
37
38
 
39
+ def default_message_buffer
40
+ {
41
+ 'max_messages' => 10_000,
42
+ 'statuses' => true
43
+ }
44
+ end
45
+
38
46
  def apply_defaults(options)
39
47
  defaults = defaults()
40
48
  defaults['components']['main'] = options['components']['main'] if options.dig('components', 'main')
@@ -11,8 +11,13 @@ module RSMP
11
11
  distribute_error error.exception("#{str} #{message.json}")
12
12
  end
13
13
 
14
- def send_message(message, reason = nil, validate: true, force: false)
15
- raise NotReady if !force && !connected?
14
+ def send_message(message, reason = nil, validate: true, force: false, buffer: true)
15
+ unless force || connected?
16
+ error = NotReady.new
17
+ raise error unless buffer
18
+
19
+ return buffer_message(message, error)
20
+ end
16
21
  raise IOError unless @protocol
17
22
 
18
23
  message.direction = :out
@@ -22,15 +27,18 @@ module RSMP
22
27
  expect_acknowledgement message
23
28
  distribute message
24
29
  log_send message, reason
25
- rescue IOError
26
- buffer_message message
30
+ rescue NotReady, IOError => e
31
+ raise e unless buffer
32
+
33
+ buffer_message message, e
27
34
  rescue SchemaError, RSMP::Schema::Error => e
28
35
  handle_send_schema_error(message, e)
29
36
  end
30
37
 
31
- def buffer_message(message)
32
- # TODO
33
- # log "Cannot send #{message.type} because the connection is closed.", message: message, level: :error
38
+ def buffer_message(message, error = nil)
39
+ str = "Cannot send #{message.type} because the connection is closed."
40
+ log str, message: message, level: :error
41
+ raise error if error
34
42
  end
35
43
 
36
44
  def log_send(message, reason = nil)
@@ -125,13 +125,14 @@ module RSMP
125
125
  end
126
126
 
127
127
  def unsubscribe_to_status(status_list, component: nil, validate: nil)
128
- validate_ready 'unsubscribe to status'
129
128
  component ||= main.c_id
130
129
 
131
130
  status_list.each do |item|
132
131
  remove_subscription_item(component, item['sCI'], item['n'])
133
132
  end
134
133
 
134
+ return unless ready? # if the connection is don't we skip sending
135
+
135
136
  message = RSMP::StatusUnsubscribe.new({
136
137
  'cId' => component,
137
138
  'sS' => status_list
@@ -13,7 +13,7 @@ module RSMP
13
13
  def send_aggregated_status(component, m_id: nil)
14
14
  m_id ||= RSMP::Message.make_m_id
15
15
 
16
- se = if Proxy.version_meets_requirement?(core_version, '<=3.1.2')
16
+ se = if core_version && Proxy.version_meets_requirement?(core_version, '<=3.1.2')
17
17
  component.aggregated_status_bools.map { |bool| bool ? 'true' : 'false' }
18
18
  else
19
19
  component.aggregated_status_bools
@@ -0,0 +1,139 @@
1
+ module RSMP
2
+ class SupervisorProxy < Proxy
3
+ module Modules
4
+ # In-memory outgoing communication buffer for site-originated messages.
5
+ module MessageBuffer
6
+ def message_buffer_settings
7
+ @site_settings['message_buffer'] || {}
8
+ end
9
+
10
+ def message_buffer_max_messages
11
+ message_buffer_settings['max_messages'] || 10_000
12
+ end
13
+
14
+ def status_buffer_selectors
15
+ return message_buffer_settings['statuses'] if message_buffer_settings.key? 'statuses'
16
+
17
+ []
18
+ end
19
+
20
+ def status_buffer_selector?(component_id, status)
21
+ selectors = status_buffer_selectors
22
+ return true if selectors == true
23
+ return false unless selectors.is_a?(Array)
24
+
25
+ selectors.any? { |selector| status_buffer_selector_matches?(selector, component_id, status) }
26
+ end
27
+
28
+ def status_buffer_selector_matches?(selector, component_id, status)
29
+ selector = selector.transform_keys(&:to_s)
30
+ component_matches = !selector['cId'] || selector['cId'] == component_id
31
+ code_matches = !selector['sCI'] || selector['sCI'] == status['sCI']
32
+ name_matches = !selector['n'] || selector['n'] == status['n']
33
+ component_matches && code_matches && name_matches
34
+ end
35
+
36
+ def clone_message(message, attributes = message.attributes)
37
+ message.class.new(JSON.parse(JSON.generate(attributes)))
38
+ end
39
+
40
+ def site_originated_buffer_candidate?(message)
41
+ message.is_a?(RSMP::AggregatedStatus) ||
42
+ message.is_a?(RSMP::AlarmIssue) ||
43
+ message.is_a?(RSMP::AlarmSuspended) ||
44
+ message.is_a?(RSMP::AlarmResumed) ||
45
+ message.is_a?(RSMP::AlarmAcknowledged) ||
46
+ message.is_a?(RSMP::StatusUpdate)
47
+ end
48
+
49
+ def prepare_status_update_for_buffer(message, core_version:, for_send:)
50
+ attributes = JSON.parse(JSON.generate(message.attributes))
51
+ component_id = attributes['cId']
52
+ attributes['sS'] = attributes['sS'].select { |status| status_buffer_selector?(component_id, status) }
53
+ return if attributes['sS'].empty?
54
+
55
+ if for_send && core_version && version_meets_requirement?(core_version, '>=3.2.0')
56
+ attributes['sS'].each { |status| status['q'] = 'old' }
57
+ end
58
+ clone_message message, attributes
59
+ end
60
+
61
+ def normalize_aggregated_status_buffer(states, core_version)
62
+ if version_meets_requirement?(core_version, '<=3.1.2')
63
+ states.map { |item| item == true || item.to_s == 'true' ? 'true' : 'false' }
64
+ else
65
+ states.map { |item| item == true || item.to_s == 'true' }
66
+ end
67
+ end
68
+
69
+ def prepare_aggregated_status_for_buffer(message, core_version:, for_send:)
70
+ attributes = JSON.parse(JSON.generate(message.attributes))
71
+ if for_send && core_version && attributes['se']
72
+ attributes['se'] = normalize_aggregated_status_buffer(attributes['se'], core_version)
73
+ end
74
+ clone_message message, attributes
75
+ end
76
+
77
+ def prepare_message_for_buffer(message, core_version: @core_version, for_send: false)
78
+ return unless site_originated_buffer_candidate? message
79
+ return false if message.is_a?(RSMP::StatusUpdate) && status_buffer_selectors == false
80
+ return false if message.is_a?(RSMP::Alarm) && !receive_alarms?
81
+
82
+ if message.is_a? RSMP::AggregatedStatus
83
+ prepare_aggregated_status_for_buffer message, core_version: core_version, for_send: for_send
84
+ elsif message.is_a? RSMP::StatusUpdate
85
+ prepare_status_update_for_buffer message, core_version: core_version, for_send: for_send
86
+ else
87
+ clone_message message
88
+ end
89
+ end
90
+
91
+ def buffer_message(message, error = nil)
92
+ prepared = prepare_message_for_buffer message, core_version: @core_version
93
+ if prepared
94
+ enqueue_buffered_message prepared
95
+ elsif site_originated_buffer_candidate? message
96
+ log "Discarded #{message.type}; it is not configured for buffering", message: message, level: :warning
97
+ else
98
+ super
99
+ end
100
+ rescue NotReady, IOError
101
+ raise error if error
102
+ end
103
+
104
+ def enqueue_buffered_message(message)
105
+ while @message_buffer.size >= message_buffer_max_messages
106
+ dropped = @message_buffer.shift
107
+ log "Dropped buffered #{dropped.type}; message buffer is full", message: dropped, level: :warning
108
+ end
109
+ @message_buffer << message
110
+ log "Buffered #{message.type}; #{message_buffer.size} message(s) queued", message: message, level: :warning
111
+ message
112
+ end
113
+
114
+ def flush_message_buffer
115
+ return if @message_buffer.empty?
116
+
117
+ queued = @message_buffer
118
+ @message_buffer = []
119
+ log "Sending #{queued.size} buffered message(s)", level: :info
120
+ queued.each_with_index do |message, index|
121
+ break unless flush_buffered_message(message, queued, index)
122
+ end
123
+ end
124
+
125
+ def flush_buffered_message(message, queued, index)
126
+ prepared = prepare_message_for_buffer message, core_version: @core_version, for_send: true
127
+ return true unless prepared
128
+
129
+ send_message prepared, 'from buffer', buffer: false
130
+ true
131
+ rescue NotReady, IOError
132
+ @message_buffer = queued[index..] + @message_buffer
133
+ log "Stopped sending buffered messages; #{message_buffer.size} message(s) remain queued", level: :warning
134
+ false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -101,6 +101,21 @@ module RSMP
101
101
  acknowledge message
102
102
  end
103
103
 
104
+ def prune_unbuffered_status_subscriptions
105
+ @status_subscriptions.each_key.to_a.each do |component_id|
106
+ by_code = @status_subscriptions[component_id]
107
+ by_code.each_key.to_a.each do |code|
108
+ by_name = by_code[code]
109
+ by_name.delete_if do |name, _subscription|
110
+ status = { 'sCI' => code, 'n' => name }
111
+ !status_buffer_selector?(component_id, status)
112
+ end
113
+ by_code.delete(code) if by_name.empty?
114
+ end
115
+ @status_subscriptions.delete(component_id) if by_code.empty?
116
+ end
117
+ end
118
+
104
119
  def fetch_last_sent_status(component, code, name)
105
120
  @last_status_sent&.dig component, code, name
106
121
  end
@@ -7,8 +7,9 @@ module RSMP
7
7
  include Modules::Commands
8
8
  include Modules::Alarms
9
9
  include Modules::AggregatedStatus
10
+ include Modules::MessageBuffer
10
11
 
11
- attr_reader :supervisor_id, :site
12
+ attr_reader :supervisor_id, :site, :message_buffer
12
13
 
13
14
  def initialize(options)
14
15
  super(options.merge(node: options[:site]))
@@ -21,6 +22,7 @@ module RSMP
21
22
  @accepted_sxls = @sxls.dup
22
23
  @rejected_sxls = []
23
24
  @synthetic_id = Supervisor.build_id_from_ip_port @ip, @port
25
+ @message_buffer = []
24
26
  end
25
27
 
26
28
  # handle communication
@@ -51,6 +53,11 @@ module RSMP
51
53
  send_version_request @site_settings['site_id'], core_versions
52
54
  end
53
55
 
56
+ def close
57
+ prune_unbuffered_status_subscriptions
58
+ super
59
+ end
60
+
54
61
  # connect to the supervisor and initiate handshake supervisor
55
62
  def connect
56
63
  log "Connecting to supervisor at #{@ip}:#{@port}", level: :info
@@ -98,6 +105,7 @@ module RSMP
98
105
  send_all_aggregated_status
99
106
  send_active_alarms if receive_alarms?
100
107
  end
108
+ flush_message_buffer
101
109
  super
102
110
  end
103
111
 
@@ -170,11 +178,6 @@ module RSMP
170
178
  send_watchdog
171
179
  end
172
180
 
173
- def timer(now)
174
- super
175
- status_update_timer now if ready?
176
- end
177
-
178
181
  def process_version(message)
179
182
  return extraneous_version message if @version_determined
180
183
 
data/lib/rsmp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module RSMP
2
- VERSION = '0.45.2'.freeze
2
+ VERSION = '0.47.0'.freeze
3
3
  end
data/lib/rsmp.rb CHANGED
@@ -61,6 +61,7 @@ require_relative 'rsmp/options/options'
61
61
  require_relative 'rsmp/options/site_options'
62
62
  require_relative 'rsmp/options/supervisor_options'
63
63
  require_relative 'rsmp/options/traffic_controller_site_options'
64
+ require_relative 'rsmp/config'
64
65
  require_relative 'rsmp/proxy/modules/state'
65
66
  require_relative 'rsmp/proxy/modules/watchdogs'
66
67
  require_relative 'rsmp/proxy/modules/acknowledgements'
@@ -77,6 +78,7 @@ require_relative 'rsmp/proxy/supervisor/modules/status'
77
78
  require_relative 'rsmp/proxy/supervisor/modules/commands'
78
79
  require_relative 'rsmp/proxy/supervisor/modules/alarms'
79
80
  require_relative 'rsmp/proxy/supervisor/modules/aggregated_status'
81
+ require_relative 'rsmp/proxy/supervisor/modules/message_buffer'
80
82
  require_relative 'rsmp/proxy/supervisor/supervisor_proxy'
81
83
  require_relative 'rsmp/proxy/site/modules/status'
82
84
  require_relative 'rsmp/proxy/site/modules/aggregated_status'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rsmp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.2
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Tin
@@ -181,6 +181,7 @@ files:
181
181
  - lib/rsmp/component/component_base.rb
182
182
  - lib/rsmp/component/component_proxy.rb
183
183
  - lib/rsmp/component/components.rb
184
+ - lib/rsmp/config.rb
184
185
  - lib/rsmp/convert/export/json_schema.rb
185
186
  - lib/rsmp/convert/export/json_schema/items.rb
186
187
  - lib/rsmp/convert/export/json_schema/outputs.rb
@@ -204,6 +205,8 @@ files:
204
205
  - lib/rsmp/node/supervisor/supervisor.rb
205
206
  - lib/rsmp/node/task.rb
206
207
  - lib/rsmp/options/options.rb
208
+ - lib/rsmp/options/schemas/config_common.json
209
+ - lib/rsmp/options/schemas/log.json
207
210
  - lib/rsmp/options/schemas/site.json
208
211
  - lib/rsmp/options/schemas/supervisor.json
209
212
  - lib/rsmp/options/schemas/supervisor_site.json
@@ -228,6 +231,7 @@ files:
228
231
  - lib/rsmp/proxy/supervisor/modules/aggregated_status.rb
229
232
  - lib/rsmp/proxy/supervisor/modules/alarms.rb
230
233
  - lib/rsmp/proxy/supervisor/modules/commands.rb
234
+ - lib/rsmp/proxy/supervisor/modules/message_buffer.rb
231
235
  - lib/rsmp/proxy/supervisor/modules/status.rb
232
236
  - lib/rsmp/proxy/supervisor/supervisor_proxy.rb
233
237
  - lib/rsmp/schema.rb