rsmp 0.43.2 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubocop.yaml +1 -1
  3. data/.github/workflows/sus.yaml +1 -1
  4. data/.gitignore +0 -1
  5. data/CHANGELOG.md +12 -1
  6. data/Gemfile +0 -2
  7. data/Gemfile.lock +10 -88
  8. data/README.md +30 -35
  9. data/Rakefile +2 -2
  10. data/config/supervisor.yaml +2 -1
  11. data/config/tlc.yaml +2 -2
  12. data/documentation/configuration.md +12 -11
  13. data/documentation/message_distribution.md +1 -2
  14. data/documentation/tasks.md +1 -2
  15. data/exe/rsmp +1 -2
  16. data/lib/rsmp/cli.rb +62 -8
  17. data/lib/rsmp/component/component.rb +0 -4
  18. data/lib/rsmp/component/component_base.rb +15 -2
  19. data/lib/rsmp/component/component_proxy.rb +1 -1
  20. data/lib/rsmp/component/components.rb +22 -1
  21. data/lib/rsmp/convert/export/json_schema/outputs.rb +19 -0
  22. data/lib/rsmp/convert/export/json_schema/values.rb +7 -5
  23. data/lib/rsmp/convert/export/json_schema.rb +15 -3
  24. data/lib/rsmp/helpers/deep_merge.rb +2 -2
  25. data/lib/rsmp/message.rb +32 -0
  26. data/lib/rsmp/node/site/site.rb +34 -10
  27. data/lib/rsmp/node/supervisor/modules/configuration.rb +32 -5
  28. data/lib/rsmp/node/supervisor/modules/connection.rb +0 -2
  29. data/lib/rsmp/node/supervisor/supervisor.rb +0 -7
  30. data/lib/rsmp/options/options.rb +55 -6
  31. data/lib/rsmp/options/schemas/site.json +6 -3
  32. data/lib/rsmp/options/schemas/supervisor.json +5 -2
  33. data/lib/rsmp/options/schemas/supervisor_site.json +5 -2
  34. data/lib/rsmp/options/site_options.rb +3 -2
  35. data/lib/rsmp/options/supervisor_options.rb +3 -1
  36. data/lib/rsmp/proxy/modules/acknowledgements.rb +2 -0
  37. data/lib/rsmp/proxy/modules/receive.rb +5 -2
  38. data/lib/rsmp/proxy/modules/state.rb +1 -0
  39. data/lib/rsmp/proxy/modules/versions.rb +90 -15
  40. data/lib/rsmp/proxy/proxy.rb +52 -3
  41. data/lib/rsmp/proxy/site/modules/status.rb +5 -3
  42. data/lib/rsmp/proxy/site/site_proxy.rb +68 -35
  43. data/lib/rsmp/proxy/site/sxl_selection.rb +54 -0
  44. data/lib/rsmp/proxy/supervisor/supervisor_proxy.rb +54 -18
  45. data/lib/rsmp/schema/core_sxl_resolution.rb +69 -0
  46. data/lib/rsmp/schema/message_resolution.rb +104 -0
  47. data/lib/rsmp/schema/validation.rb +57 -0
  48. data/lib/rsmp/schema.rb +87 -32
  49. data/lib/rsmp/schema_error.rb +7 -1
  50. data/lib/rsmp/sxl/interface.rb +48 -0
  51. data/lib/rsmp/sxl/registry.rb +55 -0
  52. data/lib/rsmp/sxl/site_interface.rb +10 -0
  53. data/lib/rsmp/sxl/supervisor_interface.rb +21 -0
  54. data/lib/rsmp/tlc/detector_logic.rb +2 -2
  55. data/lib/rsmp/tlc/signal_group.rb +2 -2
  56. data/lib/rsmp/tlc/site_interface.rb +10 -0
  57. data/lib/rsmp/tlc/{traffic_controller_proxy.rb → supervisor_interface.rb} +19 -34
  58. data/lib/rsmp/tlc/traffic_controller.rb +10 -2
  59. data/lib/rsmp/tlc/traffic_controller_site.rb +4 -2
  60. data/lib/rsmp/tlc.rb +10 -0
  61. data/lib/rsmp/version.rb +1 -1
  62. data/lib/rsmp.rb +8 -1
  63. data/rsmp.gemspec +5 -5
  64. data/schemas/core/3.3.0/aggregated_status.json +25 -0
  65. data/schemas/core/3.3.0/aggregated_status_request.json +9 -0
  66. data/schemas/core/3.3.0/alarm.json +71 -0
  67. data/schemas/core/3.3.0/alarm_acknowledge.json +11 -0
  68. data/schemas/core/3.3.0/alarm_issue.json +44 -0
  69. data/schemas/core/3.3.0/alarm_request.json +3 -0
  70. data/schemas/core/3.3.0/alarm_suspend_resume.json +3 -0
  71. data/schemas/core/3.3.0/alarm_suspended_resumed.json +44 -0
  72. data/schemas/core/3.3.0/command_request.json +24 -0
  73. data/schemas/core/3.3.0/command_response.json +35 -0
  74. data/schemas/core/3.3.0/component_list.json +24 -0
  75. data/schemas/core/3.3.0/core.json +40 -0
  76. data/schemas/core/3.3.0/definitions.json +133 -0
  77. data/schemas/core/3.3.0/message_ack.json +11 -0
  78. data/schemas/core/3.3.0/message_not_ack.json +15 -0
  79. data/schemas/core/3.3.0/rsmp.json +142 -0
  80. data/schemas/core/3.3.0/status.json +21 -0
  81. data/schemas/core/3.3.0/status_request.json +5 -0
  82. data/schemas/core/3.3.0/status_response.json +41 -0
  83. data/schemas/core/3.3.0/status_subscribe.json +31 -0
  84. data/schemas/core/3.3.0/status_unsubscribe.json +5 -0
  85. data/schemas/core/3.3.0/status_update.json +41 -0
  86. data/schemas/core/3.3.0/version.json +144 -0
  87. data/schemas/core/3.3.0/watchdog.json +9 -0
  88. data/schemas/tlc/1.0.10/rsmp.json +2 -1
  89. data/schemas/tlc/1.0.10/sxl.yaml +1 -0
  90. data/schemas/tlc/1.0.10/sxl_index.json +356 -0
  91. data/schemas/tlc/1.0.13/rsmp.json +2 -1
  92. data/schemas/tlc/1.0.13/sxl.yaml +1 -0
  93. data/schemas/tlc/1.0.13/sxl_index.json +436 -0
  94. data/schemas/tlc/1.0.14/rsmp.json +2 -1
  95. data/schemas/tlc/1.0.14/sxl.yaml +1 -0
  96. data/schemas/tlc/1.0.14/sxl_index.json +468 -0
  97. data/schemas/tlc/1.0.15/rsmp.json +2 -1
  98. data/schemas/tlc/1.0.15/sxl.yaml +1 -0
  99. data/schemas/tlc/1.0.15/sxl_index.json +508 -0
  100. data/schemas/tlc/1.0.7/rsmp.json +2 -1
  101. data/schemas/tlc/1.0.7/sxl.yaml +1 -0
  102. data/schemas/tlc/1.0.7/sxl_index.json +356 -0
  103. data/schemas/tlc/1.0.8/rsmp.json +2 -1
  104. data/schemas/tlc/1.0.8/sxl.yaml +1 -0
  105. data/schemas/tlc/1.0.8/sxl_index.json +356 -0
  106. data/schemas/tlc/1.0.9/rsmp.json +2 -1
  107. data/schemas/tlc/1.0.9/sxl.yaml +1 -0
  108. data/schemas/tlc/1.0.9/sxl_index.json +356 -0
  109. data/schemas/tlc/1.1.0/rsmp.json +2 -1
  110. data/schemas/tlc/1.1.0/sxl.yaml +1 -0
  111. data/schemas/tlc/1.1.0/sxl_index.json +572 -0
  112. data/schemas/tlc/1.2.0/rsmp.json +2 -1
  113. data/schemas/tlc/1.2.0/sxl.yaml +1 -0
  114. data/schemas/tlc/1.2.0/sxl_index.json +571 -0
  115. data/schemas/tlc/1.2.1/rsmp.json +2 -1
  116. data/schemas/tlc/1.2.1/sxl.yaml +1 -0
  117. data/schemas/tlc/1.2.1/sxl_index.json +571 -0
  118. data/schemas/tlc/1.3.0/alarms/A0001.json +4 -0
  119. data/schemas/tlc/1.3.0/alarms/A0002.json +4 -0
  120. data/schemas/tlc/1.3.0/alarms/A0003.json +4 -0
  121. data/schemas/tlc/1.3.0/alarms/A0004.json +4 -0
  122. data/schemas/tlc/1.3.0/alarms/A0005.json +4 -0
  123. data/schemas/tlc/1.3.0/alarms/A0006.json +4 -0
  124. data/schemas/tlc/1.3.0/alarms/A0007.json +34 -0
  125. data/schemas/tlc/1.3.0/alarms/A0008.json +30 -0
  126. data/schemas/tlc/1.3.0/alarms/A0009.json +4 -0
  127. data/schemas/tlc/1.3.0/alarms/A0010.json +4 -0
  128. data/schemas/tlc/1.3.0/alarms/A0101.json +4 -0
  129. data/schemas/tlc/1.3.0/alarms/A0201.json +35 -0
  130. data/schemas/tlc/1.3.0/alarms/A0202.json +35 -0
  131. data/schemas/tlc/1.3.0/alarms/A0301.json +92 -0
  132. data/schemas/tlc/1.3.0/alarms/A0302.json +115 -0
  133. data/schemas/tlc/1.3.0/alarms/A0303.json +92 -0
  134. data/schemas/tlc/1.3.0/alarms/A0304.json +115 -0
  135. data/schemas/tlc/1.3.0/alarms/alarms.json +287 -0
  136. data/schemas/tlc/1.3.0/commands/M0001.json +92 -0
  137. data/schemas/tlc/1.3.0/commands/M0002.json +69 -0
  138. data/schemas/tlc/1.3.0/commands/M0003.json +69 -0
  139. data/schemas/tlc/1.3.0/commands/M0004.json +51 -0
  140. data/schemas/tlc/1.3.0/commands/M0005.json +69 -0
  141. data/schemas/tlc/1.3.0/commands/M0006.json +69 -0
  142. data/schemas/tlc/1.3.0/commands/M0007.json +51 -0
  143. data/schemas/tlc/1.3.0/commands/M0008.json +87 -0
  144. data/schemas/tlc/1.3.0/commands/M0010.json +51 -0
  145. data/schemas/tlc/1.3.0/commands/M0011.json +51 -0
  146. data/schemas/tlc/1.3.0/commands/M0012.json +51 -0
  147. data/schemas/tlc/1.3.0/commands/M0013.json +51 -0
  148. data/schemas/tlc/1.3.0/commands/M0014.json +69 -0
  149. data/schemas/tlc/1.3.0/commands/M0015.json +69 -0
  150. data/schemas/tlc/1.3.0/commands/M0016.json +51 -0
  151. data/schemas/tlc/1.3.0/commands/M0017.json +51 -0
  152. data/schemas/tlc/1.3.0/commands/M0018.json +69 -0
  153. data/schemas/tlc/1.3.0/commands/M0019.json +87 -0
  154. data/schemas/tlc/1.3.0/commands/M0020.json +87 -0
  155. data/schemas/tlc/1.3.0/commands/M0021.json +51 -0
  156. data/schemas/tlc/1.3.0/commands/M0022.json +249 -0
  157. data/schemas/tlc/1.3.0/commands/M0023.json +51 -0
  158. data/schemas/tlc/1.3.0/commands/M0024.json +33 -0
  159. data/schemas/tlc/1.3.0/commands/M0103.json +72 -0
  160. data/schemas/tlc/1.3.0/commands/M0104.json +141 -0
  161. data/schemas/tlc/1.3.0/commands/command_requests.json +8 -0
  162. data/schemas/tlc/1.3.0/commands/command_responses.json +8 -0
  163. data/schemas/tlc/1.3.0/commands/commands.json +415 -0
  164. data/schemas/tlc/1.3.0/defs/definitions.json +133 -0
  165. data/schemas/tlc/1.3.0/defs/guards.json +24 -0
  166. data/schemas/tlc/1.3.0/rsmp.json +75 -0
  167. data/schemas/tlc/1.3.0/statuses/S0001.json +109 -0
  168. data/schemas/tlc/1.3.0/statuses/S0002.json +36 -0
  169. data/schemas/tlc/1.3.0/statuses/S0003.json +36 -0
  170. data/schemas/tlc/1.3.0/statuses/S0004.json +36 -0
  171. data/schemas/tlc/1.3.0/statuses/S0005.json +72 -0
  172. data/schemas/tlc/1.3.0/statuses/S0006.json +54 -0
  173. data/schemas/tlc/1.3.0/statuses/S0007.json +73 -0
  174. data/schemas/tlc/1.3.0/statuses/S0008.json +73 -0
  175. data/schemas/tlc/1.3.0/statuses/S0009.json +73 -0
  176. data/schemas/tlc/1.3.0/statuses/S0010.json +73 -0
  177. data/schemas/tlc/1.3.0/statuses/S0011.json +73 -0
  178. data/schemas/tlc/1.3.0/statuses/S0012.json +73 -0
  179. data/schemas/tlc/1.3.0/statuses/S0013.json +54 -0
  180. data/schemas/tlc/1.3.0/statuses/S0014.json +55 -0
  181. data/schemas/tlc/1.3.0/statuses/S0015.json +55 -0
  182. data/schemas/tlc/1.3.0/statuses/S0016.json +36 -0
  183. data/schemas/tlc/1.3.0/statuses/S0017.json +36 -0
  184. data/schemas/tlc/1.3.0/statuses/S0018.json +61 -0
  185. data/schemas/tlc/1.3.0/statuses/S0019.json +36 -0
  186. data/schemas/tlc/1.3.0/statuses/S0020.json +54 -0
  187. data/schemas/tlc/1.3.0/statuses/S0021.json +37 -0
  188. data/schemas/tlc/1.3.0/statuses/S0022.json +36 -0
  189. data/schemas/tlc/1.3.0/statuses/S0023.json +37 -0
  190. data/schemas/tlc/1.3.0/statuses/S0024.json +37 -0
  191. data/schemas/tlc/1.3.0/statuses/S0025.json +162 -0
  192. data/schemas/tlc/1.3.0/statuses/S0026.json +36 -0
  193. data/schemas/tlc/1.3.0/statuses/S0027.json +36 -0
  194. data/schemas/tlc/1.3.0/statuses/S0028.json +36 -0
  195. data/schemas/tlc/1.3.0/statuses/S0029.json +36 -0
  196. data/schemas/tlc/1.3.0/statuses/S0030.json +36 -0
  197. data/schemas/tlc/1.3.0/statuses/S0031.json +36 -0
  198. data/schemas/tlc/1.3.0/statuses/S0032.json +73 -0
  199. data/schemas/tlc/1.3.0/statuses/S0033.json +77 -0
  200. data/schemas/tlc/1.3.0/statuses/S0034.json +36 -0
  201. data/schemas/tlc/1.3.0/statuses/S0035.json +49 -0
  202. data/schemas/tlc/1.3.0/statuses/S0091.json +40 -0
  203. data/schemas/tlc/1.3.0/statuses/S0092.json +40 -0
  204. data/schemas/tlc/1.3.0/statuses/S0095.json +36 -0
  205. data/schemas/tlc/1.3.0/statuses/S0096.json +126 -0
  206. data/schemas/tlc/1.3.0/statuses/S0097.json +54 -0
  207. data/schemas/tlc/1.3.0/statuses/S0098.json +72 -0
  208. data/schemas/tlc/1.3.0/statuses/S0201.json +54 -0
  209. data/schemas/tlc/1.3.0/statuses/S0202.json +54 -0
  210. data/schemas/tlc/1.3.0/statuses/S0203.json +54 -0
  211. data/schemas/tlc/1.3.0/statuses/S0204.json +198 -0
  212. data/schemas/tlc/1.3.0/statuses/S0205.json +54 -0
  213. data/schemas/tlc/1.3.0/statuses/S0206.json +54 -0
  214. data/schemas/tlc/1.3.0/statuses/S0207.json +54 -0
  215. data/schemas/tlc/1.3.0/statuses/S0208.json +198 -0
  216. data/schemas/tlc/1.3.0/statuses/statuses.json +787 -0
  217. data/schemas/tlc/1.3.0/sxl.yaml +2297 -0
  218. data/schemas/tlc/1.3.0/sxl_index.json +578 -0
  219. metadata +157 -15
  220. data/.github/copilot-instructions.md +0 -33
  221. data/.rspec +0 -1
  222. data/cucumber.yml +0 -1
@@ -0,0 +1,104 @@
1
+ module RSMP
2
+ # Resolves SXL schemas from message code ownership.
3
+ module Schema
4
+ MESSAGE_CODE_EXTRACTORS = {
5
+ 'StatusRequest' => ->(message) { status_codes(message) },
6
+ 'StatusSubscribe' => ->(message) { status_codes(message) },
7
+ 'StatusUnsubscribe' => ->(message) { status_codes(message) },
8
+ 'StatusResponse' => ->(message) { status_codes(message) },
9
+ 'StatusUpdate' => ->(message) { status_codes(message) },
10
+ 'CommandRequest' => ->(message) { request_command_codes(message) },
11
+ 'CommandResponse' => ->(message) { response_command_codes(message) },
12
+ 'Alarm' => ->(message) { alarm_codes(message) }
13
+ }.freeze
14
+
15
+ MESSAGE_CODE_KINDS = {
16
+ 'StatusRequest' => :statuses,
17
+ 'StatusSubscribe' => :statuses,
18
+ 'StatusUnsubscribe' => :statuses,
19
+ 'StatusResponse' => :statuses,
20
+ 'StatusUpdate' => :statuses,
21
+ 'CommandRequest' => :commands,
22
+ 'CommandResponse' => :commands,
23
+ 'Alarm' => :alarms
24
+ }.freeze
25
+
26
+ def self.message_codes(message)
27
+ extractor = MESSAGE_CODE_EXTRACTORS[message['type']]
28
+ extractor ? extractor.call(message).uniq : []
29
+ end
30
+
31
+ def self.status_codes(message)
32
+ (message['sS'] || []).map { |item| item['sCI'] }.compact
33
+ end
34
+
35
+ def self.request_command_codes(message)
36
+ (message['arg'] || []).map { |item| item['cCI'] }.compact
37
+ end
38
+
39
+ def self.response_command_codes(message)
40
+ (message['rvs'] || []).map { |item| item['cCI'] }.compact
41
+ end
42
+
43
+ def self.alarm_codes(message)
44
+ [message['aCId']].compact
45
+ end
46
+
47
+ def self.message_code_kind(message)
48
+ MESSAGE_CODE_KINDS[message['type']]
49
+ end
50
+
51
+ def self.sxl_defines_codes?(type, version, kind, codes, options)
52
+ version = sanitize_version(version.to_s) if options[:lenient]
53
+ catalogue = sxl_catalogue(type, version, kind)
54
+ prefix = sxl_prefix(type, version, options)
55
+ codes.all? do |code|
56
+ unprefixed = prefix && code.start_with?(prefix) ? code[prefix.length..] : code
57
+ catalogue.key?(code) || catalogue.key?(code.to_sym) ||
58
+ catalogue.key?(unprefixed) || catalogue.key?(unprefixed.to_sym)
59
+ end
60
+ end
61
+
62
+ def self.message_code_kind_name(kind)
63
+ {
64
+ statuses: 'status',
65
+ commands: 'command',
66
+ alarms: 'alarm'
67
+ }.fetch(kind) { kind.to_s.delete_suffix('s') }
68
+ end
69
+
70
+ def self.resolve_sxl(message, schemas:, **options)
71
+ kind = message_code_kind(message)
72
+ codes = message_codes(message)
73
+ return nil unless kind && codes.any?
74
+
75
+ matches = matching_sxl_schemas(schemas, kind, codes, options)
76
+ raise_if_no_sxl_match(kind, codes) if matches.empty?
77
+ raise_if_ambiguous_sxl_match(codes, matches) if matches.size > 1
78
+
79
+ matches.first
80
+ end
81
+
82
+ def self.matching_sxl_schemas(schemas, kind, codes, options)
83
+ sxl_schemas(schemas).select do |type, version|
84
+ sxl_defines_codes?(type, version, kind, codes, options)
85
+ rescue UnknownSchemaError
86
+ false
87
+ end
88
+ end
89
+
90
+ def self.sxl_schemas(schemas)
91
+ schemas.reject { |type, _version| type.to_sym == :core }
92
+ end
93
+
94
+ def self.raise_if_no_sxl_match(kind, codes)
95
+ raise UnknownMessageCodeError,
96
+ "No accepted SXL defines #{message_code_kind_name(kind)} code(s) #{codes.join(', ')}"
97
+ end
98
+
99
+ def self.raise_if_ambiguous_sxl_match(codes, matches)
100
+ names = matches.map { |type, version| "#{type} #{version}" }.join(', ')
101
+ raise AmbiguousMessageCodeError, "Message code(s) #{codes.join(', ')} match multiple accepted SXLs: #{names}"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,57 @@
1
+ module RSMP
2
+ # Provides JSON Schema validation for RSMP messages across core and SXL versions.
3
+ module Schema
4
+ def self.core_message_type?(message)
5
+ type = message['type']
6
+ %w[
7
+ MessageAck
8
+ MessageNotAck
9
+ Version
10
+ ComponentList
11
+ AggregatedStatus
12
+ AggregatedStatusRequest
13
+ Watchdog
14
+ ].include?(type)
15
+ end
16
+
17
+ def self.validate_core(message, schemas, options)
18
+ core_version = schemas[:core] || schemas['core']
19
+ raise ArgumentError, 'schemas must include core' unless core_version
20
+
21
+ schema = find_schema! :core, core_version, options
22
+ validate_using_schema(message, schema)
23
+ end
24
+
25
+ def self.validate_sxls(message, schemas, options)
26
+ sxl_schemas = schemas.reject { |type, _version| type.to_sym == :core }
27
+ return [] if sxl_schemas.empty? || core_message_type?(message)
28
+
29
+ resolved = resolve_sxl(message, schemas: schemas, **options)
30
+ return validate_resolved_sxl(message, resolved, schemas, options) if resolved
31
+
32
+ all_errors = []
33
+ sxl_schemas.each do |type, version|
34
+ schema = find_core_sxl_schema! type, version, schema_core_version(schemas), options
35
+ errors = validate_using_schema(message, schema)
36
+ return [] if errors.empty?
37
+
38
+ all_errors.concat errors
39
+ end
40
+ all_errors
41
+ end
42
+
43
+ # Core must pass. SXL-defined messages pass if at least one SXL schema passes.
44
+ def self.validate(message, schemas, options = {})
45
+ raise ArgumentError, 'message missing' unless message
46
+ raise ArgumentError, 'schemas missing' unless schemas
47
+ raise ArgumentError, 'schemas must be a Hash' unless schemas.is_a?(Hash)
48
+ raise ArgumentError, 'schemas cannot be empty' unless schemas.any?
49
+
50
+ errors = validate_core(message, schemas, options)
51
+ errors.concat validate_sxls(message, schemas, options) if errors.empty?
52
+ return nil if errors.empty?
53
+
54
+ errors
55
+ end
56
+ end
57
+ end
data/lib/rsmp/schema.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'json_schemer'
2
+ require 'json'
2
3
 
3
4
  # RSMP (Road Side Message Protocol) schema validation library.
4
5
  module RSMP
@@ -8,13 +9,20 @@ module RSMP
8
9
 
9
10
  def self.setup
10
11
  @schemas = {}
11
- schemas_path = File.expand_path(File.join(__dir__, '..', '..', 'schemas'))
12
+ @schema_paths = {}
13
+ @sxl_indexes = {}
14
+ @core_sxl_schemas = {}
15
+ schemas_path = schema_root_path
12
16
  Dir.glob("#{schemas_path}/*").select { |f| File.directory? f }.each do |type_path|
13
17
  type = File.basename(type_path).to_sym
14
18
  load_schema_type type, type_path
15
19
  end
16
20
  end
17
21
 
22
+ def self.schema_root_path
23
+ File.expand_path(File.join(__dir__, '..', '..', 'schemas'))
24
+ end
25
+
18
26
  # load an schema from a folder. schemas are organized by version, and contain
19
27
  # json schema files, with the entry point being rsmp.jspon, eg:
20
28
  # tlc
@@ -26,23 +34,43 @@ module RSMP
26
34
  #
27
35
  # an error is raised if the schema type already exists, and force is not set to true
28
36
  def self.load_schema_type(type, type_path, force: false)
29
- raise "Schema type #{type} already loaded" if @schemas[type] && force != true
37
+ type = type.to_sym
38
+ ensure_schema_type_available(type, force)
30
39
 
31
40
  @schemas[type] = {}
32
- Dir.glob("#{type_path}/*").select { |f| File.directory? f }.each do |schema_path|
33
- version = File.basename(schema_path)
34
- file_path = File.join(schema_path, 'rsmp.json')
35
- next unless File.exist? file_path
36
-
37
- @schemas[type][version] = JSONSchemer.schema(
38
- Pathname.new(File.join(schema_path, 'rsmp.json'))
39
- )
40
- end
41
+ @schema_paths ||= {}
42
+ @schema_paths[type] = {}
43
+ clear_sxl_index(type)
44
+ clear_core_sxl_schemas(type)
45
+ schema_version_paths(type_path).each { |schema_path| load_schema_version(type, schema_path) }
46
+ end
47
+
48
+ def self.ensure_schema_type_available(type, force)
49
+ raise "Schema type #{type} already loaded" if @schemas[type] && force != true
50
+ end
51
+
52
+ def self.schema_version_paths(type_path)
53
+ Dir.glob("#{type_path}/*").select { |path| File.directory? path }
54
+ end
55
+
56
+ def self.load_schema_version(type, schema_path)
57
+ version = File.basename(schema_path)
58
+ file_path = File.join(schema_path, 'rsmp.json')
59
+ return unless File.exist? file_path
60
+
61
+ @schemas[type][version] = JSONSchemer.schema(Pathname.new(file_path))
62
+ @schema_paths[type][version] = schema_path
63
+ clear_sxl_index(type, version)
64
+ clear_core_sxl_schemas(type, version)
41
65
  end
42
66
 
43
67
  # remove a schema type
44
68
  def self.remove_schema_type(type)
69
+ type = type.to_sym
45
70
  schemas.delete type
71
+ @schema_paths&.delete type
72
+ clear_sxl_index(type)
73
+ clear_core_sxl_schemas(type)
46
74
  end
47
75
 
48
76
  # get schemas types
@@ -174,37 +202,64 @@ module RSMP
174
202
  find_schema(type, version, options) != nil
175
203
  end
176
204
 
205
+ def self.sxl_metadata(type, version, options = {})
206
+ version = sanitize_version version if options[:lenient]
207
+ find_schema! type, version
208
+
209
+ sxl_index(type, version).fetch('meta', {})
210
+ end
211
+
212
+ def self.sxl_prefix(type, version, options = {})
213
+ sxl_metadata(type, version, options)['prefix']
214
+ end
215
+
177
216
  # return a catalogue of statuses for a particular schema type and version
178
217
  # returns a hash of { status_code_id_sym => [arg_name_sym, ...] }
179
- # raises an error if the schema type/version is not found, or has no sxl.yaml
218
+ # raises an error if the schema type/version is not found, or has no status catalogue
180
219
  def self.status_catalogue(type, version)
220
+ sxl_catalogue(type, version, :statuses).transform_keys(&:to_sym).transform_values do |status|
221
+ status.fetch('arguments', []).map(&:to_sym)
222
+ end
223
+ end
224
+
225
+ def self.sxl_catalogue(type, version, kind)
181
226
  find_schema! type, version
182
- schemas_path = File.expand_path(File.join(__dir__, '..', '..', 'schemas'))
183
- yaml_path = File.join(schemas_path, type.to_s, version, 'sxl.yaml')
184
- raise "No sxl.yaml for #{type} #{version}" unless File.exist?(yaml_path)
227
+ catalogue = sxl_index(type, version)[kind.to_s]
228
+ raise "No #{kind} catalogue for #{type} #{version}" unless catalogue
185
229
 
186
- sxl = RSMP::Convert::Import::YAML.read(yaml_path)
187
- sxl[:statuses].transform_keys(&:to_sym).transform_values do |status|
188
- (status['arguments'] || {}).keys.map(&:to_sym)
189
- end
230
+ catalogue
190
231
  end
191
232
 
192
- # validate using a particular schema and version
193
- # raises error if schema is not found
194
- # return nil if validation succeds, otherwise returns an array of errors
195
- def self.validate(message, schemas, options = {})
196
- raise ArgumentError, 'message missing' unless message
197
- raise ArgumentError, 'schemas missing' unless schemas
198
- raise ArgumentError, 'schemas must be a Hash' unless schemas.is_a?(Hash)
199
- raise ArgumentError, 'schemas cannot be empty' unless schemas.any?
233
+ def self.clear_sxl_index(type = nil, version = nil)
234
+ @sxl_indexes ||= {}
235
+ return @sxl_indexes.clear unless type
200
236
 
201
- errors = schemas.flat_map do |type, version|
202
- schema = find_schema! type, version, options
203
- validate_using_schema(message, schema)
237
+ type = type.to_sym
238
+ if version
239
+ @sxl_indexes.delete([type, version.to_s])
240
+ else
241
+ @sxl_indexes.delete_if { |key, _value| key.first == type }
204
242
  end
205
- return nil if errors.empty?
243
+ end
244
+
245
+ def self.sxl_index(type, version)
246
+ key = [type.to_sym, version.to_s]
247
+ @sxl_indexes ||= {}
248
+ @sxl_indexes[key] ||= load_sxl_index(type, version)
249
+ end
250
+
251
+ def self.load_sxl_index(type, version)
252
+ schema_path = @schema_paths&.dig(type.to_sym, version.to_s)
253
+ raise UnknownSchemaVersionError, "Unknown schema version #{type} #{version}" unless schema_path
206
254
 
207
- errors
255
+ index_path = File.join(schema_path, 'sxl_index.json')
256
+ raise Error, "Missing SXL index #{index_path}" unless File.exist?(index_path)
257
+
258
+ JSON.parse(File.read(index_path, encoding: 'UTF-8'))
208
259
  end
209
260
  end
210
261
  end
262
+
263
+ require_relative 'schema/core_sxl_resolution'
264
+ require_relative 'schema/message_resolution'
265
+ require_relative 'schema/validation'
@@ -1,6 +1,6 @@
1
1
  module RSMP
2
2
  module Schema
3
- # Base error class for rsmp_schema.
3
+ # Base error class for schema validation.
4
4
  class Error < StandardError
5
5
  end
6
6
 
@@ -15,5 +15,11 @@ module RSMP
15
15
  # Raised when the requested schema version does not exist.
16
16
  class UnknownSchemaVersionError < UnknownSchemaError
17
17
  end
18
+
19
+ class UnknownMessageCodeError < Error
20
+ end
21
+
22
+ class AmbiguousMessageCodeError < Error
23
+ end
18
24
  end
19
25
  end
@@ -0,0 +1,48 @@
1
+ require 'forwardable'
2
+
3
+ module RSMP
4
+ module SXL
5
+ # Base interface for SXL-specific behavior on a proxy connection.
6
+ class Interface
7
+ extend Forwardable
8
+
9
+ def_delegators :proxy, :send_message, :send_message_and_collect, :validate_ready, :log
10
+
11
+ attr_reader :proxy, :name, :version
12
+
13
+ def initialize(proxy:, name:, version:)
14
+ @proxy = proxy
15
+ @name = name
16
+ @version = version
17
+ end
18
+
19
+ def node
20
+ proxy.node
21
+ end
22
+
23
+ def components
24
+ proxy.respond_to?(:components) ? proxy.components : proxy.site.components
25
+ end
26
+
27
+ def main
28
+ proxy.respond_to?(:main) ? proxy.main : proxy.site.main
29
+ end
30
+
31
+ def sxl_version
32
+ version
33
+ end
34
+
35
+ def core_version
36
+ proxy.core_version
37
+ end
38
+
39
+ def use_soc?
40
+ return false unless core_version
41
+
42
+ RSMP::Proxy.version_meets_requirement?(core_version, '>=3.1.5')
43
+ end
44
+
45
+ def validate_message!(_message); end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ module RSMP
2
+ module SXL
3
+ # Registry of SXL interface classes keyed by SXL name and connection side.
4
+ module Registry
5
+ @interfaces = {}
6
+
7
+ def self.register(name, side, klass)
8
+ @interfaces[[name.to_s, side.to_sym]] = klass
9
+ end
10
+
11
+ def self.register_interface(klass)
12
+ register sxl_name_for(klass), side_for(klass), klass
13
+ end
14
+
15
+ def self.fetch(name, side)
16
+ @interfaces[[name.to_s, side.to_sym]]
17
+ end
18
+
19
+ def self.build(proxy, sxl, side)
20
+ klass = fetch(sxl['name'], side) || default_class(side)
21
+ klass.new(proxy: proxy, name: sxl['name'], version: sxl['version'])
22
+ end
23
+
24
+ def self.build_for(proxy, sxl)
25
+ case proxy
26
+ when RSMP::SiteProxy
27
+ build(proxy, sxl, :supervisor)
28
+ when RSMP::SupervisorProxy
29
+ build(proxy, sxl, :site)
30
+ else
31
+ raise ArgumentError, "Unknown proxy class #{proxy.class}"
32
+ end
33
+ end
34
+
35
+ def self.default_class(side)
36
+ side.to_sym == :site ? SiteInterface : SupervisorInterface
37
+ end
38
+
39
+ def self.side_for(klass)
40
+ return :site if klass < SiteInterface
41
+ return :supervisor if klass < SupervisorInterface
42
+
43
+ raise ArgumentError, "Cannot infer SXL interface side for #{klass}"
44
+ end
45
+
46
+ def self.sxl_name_for(klass)
47
+ namespace = klass.name.split('::')[0...-1].join('::')
48
+ owner = Object.const_get(namespace)
49
+ return owner.sxl_name if owner.respond_to?(:sxl_name)
50
+
51
+ raise ArgumentError, "Cannot infer SXL name for #{klass}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,10 @@
1
+ module RSMP
2
+ module SXL
3
+ # SXL interface used by a site-side proxy.
4
+ class SiteInterface < Interface
5
+ def process_message(message)
6
+ proxy.process_sxl_request message
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,21 @@
1
+ require 'forwardable'
2
+
3
+ module RSMP
4
+ module SXL
5
+ # SXL interface used by a supervisor-side proxy.
6
+ class SupervisorInterface < Interface
7
+ extend Forwardable
8
+
9
+ def_delegators :proxy,
10
+ :request_status,
11
+ :request_status_and_collect,
12
+ :subscribe_to_status,
13
+ :subscribe_to_status_and_collect,
14
+ :unsubscribe_to_status,
15
+ :send_command,
16
+ :send_command_and_collect
17
+
18
+ def process_message(_message); end
19
+ end
20
+ end
21
+ end
@@ -4,8 +4,8 @@ module RSMP
4
4
  class DetectorLogic < Component
5
5
  attr_reader :forced, :value
6
6
 
7
- def initialize(node:, id:)
8
- super(node: node, id: id, grouped: false)
7
+ def initialize(node:, id:, type: nil, name: nil)
8
+ super(node: node, id: id, type: type, name: name, grouped: false)
9
9
  @forced = 0
10
10
  @value = 0
11
11
  end
@@ -5,8 +5,8 @@ module RSMP
5
5
  attr_reader :plan, :state
6
6
 
7
7
  # plan is a string, with each character representing a signal phase at a particular second in the cycle
8
- def initialize(node:, id:)
9
- super(node: node, id: id, grouped: false)
8
+ def initialize(node:, id:, type: nil, name: nil)
9
+ super(node: node, id: id, type: type, name: name, grouped: false)
10
10
  end
11
11
 
12
12
  def timer
@@ -0,0 +1,10 @@
1
+ module RSMP
2
+ # Traffic Light Controller SXL support.
3
+ module TLC
4
+ # Site-side TLC SXL interface.
5
+ class SiteInterface < RSMP::SXL::SiteInterface
6
+ end
7
+
8
+ RSMP::SXL::Registry.register_interface SiteInterface
9
+ end
10
+ end
@@ -1,11 +1,8 @@
1
- # Proxy for handling communication with a remote Traffic Light Controller (TLC).
2
- # Provides high-level methods for interacting with TLC functionality.
3
- # Acts as a mirror of the remote TLC by automatically subscribing to status updates.
4
-
5
1
  module RSMP
2
+ # Traffic Light Controller SXL support.
6
3
  module TLC
7
- # Proxy for handling communication with a remote traffic light controller.
8
- class TrafficControllerProxy < SiteProxy
4
+ # Supervisor-side TLC SXL interface.
5
+ class SupervisorInterface < RSMP::SXL::SupervisorInterface
9
6
  include Proxy::Control
10
7
  include Proxy::IO
11
8
  include Proxy::Plans
@@ -16,16 +13,7 @@ module RSMP
16
13
  attr_reader :timeplan_source, :timeplan, :timeouts,
17
14
  :functional_position, :yellow_flash, :traffic_situation
18
15
 
19
- # Backwards-compatible accessors expected by tests and callers
20
- def current_plan
21
- @timeplan
22
- end
23
-
24
- def plan_source
25
- @timeplan_source
26
- end
27
-
28
- def initialize(options)
16
+ def initialize(...)
29
17
  super
30
18
  @timeplan_source = nil
31
19
  @timeplan = nil
@@ -35,6 +23,14 @@ module RSMP
35
23
  @timeouts = node.supervisor_settings.dig('default', 'timeouts') || {}
36
24
  end
37
25
 
26
+ def current_plan
27
+ @timeplan
28
+ end
29
+
30
+ def plan_source
31
+ @timeplan_source
32
+ end
33
+
38
34
  def subscribe_to_timeplan
39
35
  validate_ready 'subscribe to timeplan'
40
36
 
@@ -49,32 +45,23 @@ module RSMP
49
45
  subscribe_to_status status_list, component: main.c_id
50
46
  end
51
47
 
52
- # Override status update processing to automatically store cached status values.
53
48
  def process_status_update(message)
54
- super
55
-
56
49
  status_values = message.attribute('sS')
57
50
  return unless status_values
58
51
 
59
52
  status_values.each { |item| cache_status_item(item) }
60
53
  end
61
54
 
62
- # Get all timeplan attributes stored in the main ComponentProxy.
63
- def timeplan_attributes
64
- main&.statuses&.dig('S0014') || {}
55
+ def process_message(message)
56
+ process_status_update message if message.is_a?(RSMP::StatusUpdate)
65
57
  end
66
58
 
67
- # Returns true if sOc (send on change) should be used.
68
- # sOc is supported in RSMP core version 3.1.5 and later.
69
- def use_soc?
70
- return false unless core_version
71
-
72
- RSMP::Proxy.version_meets_requirement?(core_version, '>=3.1.5')
59
+ def timeplan_attributes
60
+ main&.statuses&.dig('S0014') || {}
73
61
  end
74
62
 
75
63
  private
76
64
 
77
- # Automatically subscribe to key TLC statuses to keep proxy in sync.
78
65
  def auto_subscribe_to_statuses
79
66
  return unless main
80
67
 
@@ -82,17 +69,14 @@ module RSMP
82
69
  subscribe_to_key_statuses
83
70
  end
84
71
 
85
- # Look up security code for a given level from site settings.
86
- # Expects @site_settings['security_codes'] = { 1 => 'code1', 2 => 'code2' }
87
72
  def security_code_for(level)
88
- codes = @site_settings&.dig('security_codes') || {}
73
+ codes = proxy.site_settings&.dig('security_codes') || {}
89
74
  code = codes[level] || codes[level.to_s]
90
75
  raise ArgumentError, "Security code for level #{level} is not configured" unless code
91
76
 
92
77
  code
93
78
  end
94
79
 
95
- # Process a single status item and update the corresponding cached value.
96
80
  def cache_status_item(item)
97
81
  case item['sCI']
98
82
  when 'S0007' then @functional_position = item['s'] if item['n'] == 'status'
@@ -102,7 +86,6 @@ module RSMP
102
86
  end
103
87
  end
104
88
 
105
- # Update cached values for S0014 (timeplan) status attributes.
106
89
  def cache_s0014_attribute(item)
107
90
  case item['n']
108
91
  when 'status' then @timeplan = item['s'].to_i
@@ -110,5 +93,7 @@ module RSMP
110
93
  end
111
94
  end
112
95
  end
96
+
97
+ RSMP::SXL::Registry.register_interface SupervisorInterface
113
98
  end
114
99
  end
@@ -20,8 +20,16 @@ module RSMP
20
20
  attr_reader :pos, :cycle_time, :plan, :cycle_counter,
21
21
  :functional_position, :startup_sequence
22
22
 
23
- def initialize(node:, id:, ntsoid: nil, xnid: nil, **options)
24
- super(node: node, id: id, ntsoid: ntsoid, xnid: xnid, grouped: true)
23
+ def initialize(node:, id:, **options)
24
+ super(
25
+ node: node,
26
+ id: id,
27
+ type: options[:type],
28
+ name: options[:name],
29
+ ntsoid: options[:ntsoid],
30
+ xnid: options[:xnid],
31
+ grouped: true
32
+ )
25
33
  @signal_groups = []
26
34
  @detector_logics = []
27
35
  @plans = options[:signal_plans]
@@ -63,6 +63,8 @@ module RSMP
63
63
  when 'main'
64
64
  TrafficController.new node: self,
65
65
  id: id,
66
+ type: type,
67
+ name: settings['name'],
66
68
  ntsoid: settings['ntsOId'],
67
69
  xnid: settings['xNId'],
68
70
  startup_sequence: @startup_sequence,
@@ -70,11 +72,11 @@ module RSMP
70
72
  live_output: @site_settings['live_output'],
71
73
  inputs: @site_settings['inputs']
72
74
  when 'signal_group'
73
- group = SignalGroup.new node: self, id: id
75
+ group = SignalGroup.new node: self, id: id, type: type, name: settings['name']
74
76
  main.add_signal_group group
75
77
  group
76
78
  when 'detector_logic'
77
- logic = DetectorLogic.new node: self, id: id
79
+ logic = DetectorLogic.new node: self, id: id, type: type, name: settings['name']
78
80
  main.add_detector_logic logic
79
81
  logic
80
82
  end