rsmp 0.51.3 → 0.52.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: 883735d537c281828ffe79225cf667fef84b88e2055e39820cc4213168f715ec
4
- data.tar.gz: 7c577e4ee8998cf1dbcb7308aa486122ba430d967375e4827030dd89c075f408
3
+ metadata.gz: cfba3255efe256eae701725584e74d80a7ba66fe94708ef9bdc76cbd678c7700
4
+ data.tar.gz: b0aebcd26383d1ed77fbcdd18351fecc0a7afcc962d582f17a3f488a53aaeb22
5
5
  SHA512:
6
- metadata.gz: ae9f566fd7a1f2bc9c16f59507abfd1becacb0326489fa5308363daaee89122cb32863cbabf187f033fa34d18dfb6648a6e9f1150678e1c32d60aaa2f898be55
7
- data.tar.gz: 6c2348ed50374b5d9f1b1b99313420d3190c588ac70bb562815c0b418e46ebd697fcdf8f8809915392ad8f42f70f5637b7154eb33df40d9d6695ce52b25c131a
6
+ metadata.gz: f7796d54dbed77a9d230a4e49f3ddadd22b3833b29fa05c882f9d7a6c540ed64ae55745f5b8dbe38a9fe095fa49b45684f9ebaf0a954d9fa021d892f2bdc5cec
7
+ data.tar.gz: 5bf194b21059fd35d82e41093c68658585fe083cc061f5759e98d95df1f45e5fbd0958051cb3dbac8b771f915fc0511c056f72c887339f1b5df6c60f73d6a57f
data/CHANGELOG.md CHANGED
@@ -719,3 +719,19 @@ Initial release.
719
719
 
720
720
  ## 0.51.1
721
721
  - cleanup semantic versioning handling of version strings and use canonical schema versions internally
722
+
723
+ ## 0.51.2
724
+ - bumb patch version for test purposes, no changes othewise
725
+
726
+ ## 0.51.3
727
+ - ensure socket shutdown when stopping node
728
+
729
+ ## 0.51.4
730
+ - don't update subscription list until unsubscribe is acknowledged
731
+
732
+ ## 0.52.0
733
+ - allow the proxy's `collect:` option to accept a Proc that builds a collector or queue before the handshake
734
+ - add optional `predicate:` filtering to collectors, including status collectors
735
+ - add `start` and `stop` aliases to queues for use with proxy collection factories
736
+ - fix status collectors to honor `component:` and `responses:`; the misspelled `reponses:` option is no longer used
737
+
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rsmp (0.51.3)
4
+ rsmp (0.52.0)
5
5
  async (~> 2.45)
6
6
  colorize (~> 1.1)
7
7
  io-endpoint (~> 0.17)
@@ -112,6 +112,118 @@ result = collector.collect do |message|
112
112
  end
113
113
  ```
114
114
 
115
+ ### Message predicates
116
+
117
+ Use `predicate:` for conditions beyond the message type, direction, and component
118
+ handled by `RSMP::Filter`. The Proc receives a whole message after the filter
119
+ accepts it. Returning `false` or `nil` skips the message before collection
120
+ callbacks or state-item matching; earlier item matches remain intact.
121
+
122
+ ```ruby
123
+ collector = RSMP::Collector.new(
124
+ proxy,
125
+ filter: RSMP::Filter.new(type: 'StatusUpdate', ingoing: true, outgoing: false),
126
+ predicate: ->(message) { message.is_a?(RSMP::StatusUpdate) && message.attribute('sS').any? },
127
+ num: 1,
128
+ timeout: 5
129
+ )
130
+ ```
131
+
132
+ Predicates also work with `StatusCollector`. They do not suppress failures such
133
+ as a rejection of the initiating request or an invalid peer message. `Filter`
134
+ admits `MessageNotAck` even when a different message type is requested, so a
135
+ predicate that accesses type-specific attributes should check the message type.
136
+
137
+ ### Status collection
138
+
139
+ `StatusCollector` matches wanted status items within incoming messages. Both
140
+ `StatusUpdate` and `StatusResponse` are accepted by default. Set `responses: false`
141
+ to accept updates only, or `updates: false` to accept responses only.
142
+
143
+ ```ruby
144
+ want = [{ 'sCI' => 'S0001', 'n' => 'signalgroupstatus' }]
145
+ collector = RSMP::StatusCollector.new(
146
+ proxy, want, component: 'C1', responses: false, timeout: 5
147
+ )
148
+ ```
149
+
150
+ `component:` filters the message's `cId`; the wanted items describe entries in
151
+ `sS`, which do not have their own component ID. Collection completes when all
152
+ wanted items match, rather than after a fixed number of messages.
153
+
154
+ ### Collection during proxy setup
155
+
156
+ The proxy's `collect:` option accepts the existing collector options hash:
157
+
158
+ ```ruby
159
+ proxy_options = {
160
+ collect: {
161
+ filter: RSMP::Filter.new(type: 'StatusUpdate', ingoing: true, outgoing: false),
162
+ num: 1,
163
+ timeout: 5
164
+ }
165
+ }
166
+ ```
167
+
168
+ It also accepts a Proc that receives the new proxy and returns a collector or
169
+ queue. The proxy calls `start` on the returned object and exposes it through
170
+ `proxy.collector`. This starts receiving before the handshake can deliver
171
+ messages. Return an unstarted object; the factory runs before version negotiation
172
+ and should not rely on the proxy being fully initialized or connected.
173
+
174
+ ```ruby
175
+ proxy_options = {
176
+ collect: proc do |proxy|
177
+ RSMP::StatusCollector.new(
178
+ proxy,
179
+ [{ 'sCI' => 'S0001', 'n' => 'signalgroupstatus' }],
180
+ component: 'C1', responses: false, timeout: 5
181
+ )
182
+ end
183
+ }
184
+ ```
185
+
186
+ Pass these options when creating the proxy. Use `proxy.collector.wait` or
187
+ `wait!` for a collector, or `wait_for_message` or `wait_for_message!` for a queue.
188
+
189
+ ## Queues
190
+
191
+ `RSMP::Queue` retains accepted messages until consumed, including messages that
192
+ arrive before the caller starts waiting. Each wait returns one message, rather
193
+ than a collection. `start` and `stop` are aliases for `start_receiving` and
194
+ `stop_receiving`, so a queue can also be returned by a proxy's `collect:` factory.
195
+
196
+ ```ruby
197
+ queue = RSMP::Queue.new(proxy, filter: RSMP::Filter.new(type: 'StatusUpdate'))
198
+ queue.start
199
+ begin
200
+ result = queue.wait_for_message(timeout: 5)
201
+ puts result.value if result.success?
202
+ ensure
203
+ queue.stop
204
+ end
205
+ ```
206
+
207
+ A queue stays registered until stopped. Its `timeout:` applies to an individual
208
+ wait: expiry returns a failure with code `:timeout`, or raises
209
+ `RSMP::OperationError` through `wait_for_message!`.
210
+
211
+ An enclosing Async timeout raised while waiting in the queue is converted in
212
+ the same way, even when no queue timeout is supplied:
213
+
214
+ ```ruby
215
+ begin
216
+ message = Async::Task.current.with_timeout(5) { queue.wait_for_message! }
217
+ rescue RSMP::OperationError => error
218
+ raise unless error.failure.code == :timeout
219
+
220
+ warn 'The caller deadline expired'
221
+ end
222
+ ```
223
+
224
+ This example assumes the queue is already started. Both timeout sources use the
225
+ same failure code; `failure.cause` retains the original Async exception.
226
+
115
227
  ## Sending and collecting atomically
116
228
 
117
229
  High-level send methods follow the same non-bang/bang convention:
@@ -16,6 +16,18 @@ RSMP uses option classes to handle configuration for sites and supervisors. Thes
16
16
 
17
17
  Each class applies defaults and validates the configuration against a JSON Schema located in `lib/rsmp/options/schemas/`.
18
18
 
19
+ Field types and unknown keys are checked before defaults are applied. The `required_settings.json`
20
+ and `required_supervisor_settings.json` schemas then check required settings after defaults and
21
+ supervisor site inheritance are resolved:
22
+
23
+ - Every configured signal plan must have a `cycle_time`.
24
+ - Configured components must include exactly one `main` component. Sites supply a default main
25
+ component when omitted; supervisor site settings can inherit it from `default`.
26
+ - A site in `server` role must have a `port`, either explicitly or from its first supervisor endpoint.
27
+ With `supervisors: []`, the port must be explicit.
28
+
29
+ `inputs.programming: {}` is allowed and means no programmed input actions.
30
+
19
31
  ## Version strings
20
32
 
21
33
  Core 3.3.0 and later use exact `MAJOR.MINOR.PATCH` version strings for both Core and SXL negotiation.
@@ -7,6 +7,9 @@ module RSMP
7
7
 
8
8
  attr_reader :messages, :m_id, :initiator
9
9
 
10
+ # :predicate optionally receives each message accepted by the filter. A falsey
11
+ # result skips the message before collection callbacks or item matching.
12
+ # Rejection and invalid-message failures are handled independently of it.
10
13
  def initialize(distributor, options = {})
11
14
  initialize_receiver distributor, filter: options[:filter]
12
15
  @options = {
@@ -17,6 +20,7 @@ module RSMP
17
20
  }.deep_merge(options)
18
21
  @timeout = options[:timeout]
19
22
  @num = options[:num]
23
+ @predicate = options[:predicate]
20
24
  @initiator = options[:initiator]
21
25
  @m_id = options[:m_id] || @initiator&.attributes&.dig('mId')
22
26
  make_title(options[:title])
@@ -131,6 +135,8 @@ module RSMP
131
135
  def perform_match?(message)
132
136
  return false if reject_not_ack?(message)
133
137
  return false unless acceptable?(message)
138
+ return false if @predicate && !@predicate.call(message)
139
+ return false unless active?
134
140
 
135
141
  if @block
136
142
  status = Array(@block.call(message))
@@ -7,6 +7,10 @@ module RSMP
7
7
 
8
8
  attr_reader :messages
9
9
 
10
+ # Allow queues to be started by a proxy's collect factory, like collectors.
11
+ alias start start_receiving
12
+ alias stop stop_receiving
13
+
10
14
  def initialize(distributor, filter: nil)
11
15
  initialize_receiver distributor, filter: filter
12
16
  @condition = Async::Notification.new
@@ -4,11 +4,11 @@ module RSMP
4
4
  def initialize(proxy, want, options = {})
5
5
  type = []
6
6
  type << 'StatusUpdate' unless options[:updates] == false
7
- type << 'StatusResponse' unless options[:reponses] == false
7
+ type << 'StatusResponse' unless options[:responses] == false
8
8
 
9
9
  super(proxy, want, options.merge(
10
10
  title: 'status response',
11
- filter: RSMP::Filter.new(ingoing: true, outgoing: false, type: type)
11
+ filter: RSMP::Filter.new(ingoing: true, outgoing: false, type: type, component: options[:component])
12
12
  ))
13
13
  end
14
14
 
@@ -34,7 +34,9 @@ module RSMP
34
34
  validate_log_settings! if validate
35
35
  config = normalize_config(options || {})
36
36
  validate!(config) if validate
37
- @data = normalize(apply_defaults(config))
37
+ config = apply_defaults(config)
38
+ validate_effective!(config) if validate
39
+ @data = normalize(config)
38
40
  end
39
41
 
40
42
  def defaults = {}
@@ -56,10 +58,10 @@ module RSMP
56
58
  raise RSMP::ConfigurationError, "Invalid log configuration#{source_suffix}:\n#{message}"
57
59
  end
58
60
 
59
- def validate!(data = @data)
60
- return unless schema_path && File.exist?(schema_path)
61
+ def validate!(data = @data, path: schema_path)
62
+ return unless path && File.exist?(path)
61
63
 
62
- schemer = JSONSchemer.schema(Pathname.new(schema_path))
64
+ schemer = JSONSchemer.schema(Pathname.new(path))
63
65
  errors = schemer.validate(data).to_a
64
66
  return if errors.empty?
65
67
 
@@ -86,6 +88,8 @@ module RSMP
86
88
 
87
89
  private
88
90
 
91
+ def validate_effective!(_config); end
92
+
89
93
  def apply_defaults(options)
90
94
  defaults.deep_merge(options)
91
95
  end
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Validate after applying defaults and inherited site settings; structural validation uses the other config schemas.",
4
+ "type": "object",
5
+ "properties": {
6
+ "components": {
7
+ "type": "object",
8
+ "required": ["main"],
9
+ "properties": {
10
+ "main": {
11
+ "type": "object",
12
+ "minProperties": 1,
13
+ "maxProperties": 1
14
+ }
15
+ }
16
+ },
17
+ "signal_plans": {
18
+ "type": "object",
19
+ "additionalProperties": {
20
+ "type": "object",
21
+ "required": ["cycle_time"]
22
+ }
23
+ }
24
+ },
25
+ "if": {
26
+ "required": ["connection_role"],
27
+ "properties": { "connection_role": { "const": "server" } }
28
+ },
29
+ "then": { "required": ["port"] }
30
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Validate each site after merging supervisor defaults, without changing the stored overrides.",
4
+ "type": "object",
5
+ "properties": {
6
+ "default": { "$ref": "required_settings.json" },
7
+ "sites": {
8
+ "type": "object",
9
+ "additionalProperties": { "$ref": "required_settings.json" }
10
+ }
11
+ }
12
+ }
@@ -23,6 +23,10 @@ module RSMP
23
23
 
24
24
  private
25
25
 
26
+ def validate_effective!(config)
27
+ validate!(config, path: File.join(SCHEMAS_PATH, 'required_settings.json'))
28
+ end
29
+
26
30
  def default_message_buffer
27
31
  {
28
32
  'max_messages' => 10_000,
@@ -54,7 +58,8 @@ module RSMP
54
58
  defaults = defaults()
55
59
  defaults['components']['main'] = options['components']['main'] if options.dig('components', 'main')
56
60
  data = defaults.deep_merge(options)
57
- data['port'] ||= data.dig('supervisors', 0, 'port')
61
+ port = data.dig('supervisors', 0, 'port')
62
+ data['port'] ||= port if port
58
63
  data
59
64
  end
60
65
  end
@@ -28,6 +28,15 @@ module RSMP
28
28
  def schema_file
29
29
  'supervisor.json'
30
30
  end
31
+
32
+ private
33
+
34
+ def validate_effective!(config)
35
+ base = config['default'] || {}
36
+ sites = (config['sites'] || {}).transform_values { |settings| base.deep_merge(settings) }
37
+ effective = config.merge('sites' => sites)
38
+ validate!(effective, path: File.join(SCHEMAS_PATH, 'required_supervisor_settings.json'))
39
+ end
31
40
  end
32
41
  end
33
42
  end
@@ -103,6 +103,8 @@ module RSMP
103
103
  component_list_acknowledged
104
104
  when 'StatusSubscribe'
105
105
  status_subscribe_acknowledged original
106
+ when 'StatusUnsubscribe'
107
+ status_unsubscribe_acknowledged original
106
108
  end
107
109
 
108
110
  check_outgoing_acknowledged original
@@ -106,9 +106,15 @@ module RSMP
106
106
  @rejected_sxls = []
107
107
  @receive_alarms = true
108
108
  @site_settings = nil # can't pick until we know the site id
109
- return unless options[:collect]
109
+ collect = options[:collect]
110
+ return unless collect
111
+
112
+ @collector = if collect.is_a?(Proc)
113
+ collect.call(self)
114
+ else
115
+ RSMP::Collector.new self, collect
116
+ end
110
117
 
111
- @collector = RSMP::Collector.new self, options[:collect]
112
118
  @collector.start
113
119
  end
114
120
 
@@ -3,6 +3,16 @@ module RSMP
3
3
  module Modules
4
4
  # Handles status requests, responses, subscriptions and updates
5
5
  module Status
6
+ def clear
7
+ super
8
+ @pending_status_unsubscriptions = {}
9
+ end
10
+
11
+ def process_not_ack(message)
12
+ @pending_status_unsubscriptions.delete(message.attribute('oMId'))
13
+ super
14
+ end
15
+
6
16
  # Build and send a StatusRequest. Returns Result<StatusRequest>.
7
17
  def request_status(status_list, component: nil, m_id: nil, validate: true)
8
18
  readiness = validate_ready 'request status'
@@ -75,9 +85,9 @@ module RSMP
75
85
  subscribe_list.each do |item|
76
86
  code = item['sCI']
77
87
  name = item['n']
78
- sub = ensure_subscription_path(component_id, code, name)
79
- sub['uRt'] = item['uRt']
80
- sub['sOc'] = item['sOc']
88
+ ensure_subscription_path(component_id, code, name)
89
+ # Replace the record so an older unsubscribe ACK cannot remove it.
90
+ @status_subscriptions[component_id][code][name] = item.slice('uRt', 'sOc')
81
91
  end
82
92
  end
83
93
 
@@ -147,20 +157,46 @@ module RSMP
147
157
  def unsubscribe_to_status(status_list, component: nil, validate: nil)
148
158
  component ||= main.c_id
149
159
 
150
- status_list.each do |item|
151
- remove_subscription_item(component, item['sCI'], item['n'])
152
- end
153
-
154
160
  # Local subscription state must be cleaned up even when the peer has
155
161
  # already disconnected. In that case there is nothing left to send.
156
- return Result.success(nil) unless ready?
162
+ unless ready?
163
+ status_list.each do |item|
164
+ remove_subscription_item(component, item['sCI'], item['n'])
165
+ end
166
+ return Result.success(nil)
167
+ end
157
168
 
158
169
  message = RSMP::StatusUnsubscribe.new({
159
170
  'cId' => component,
160
171
  'sS' => status_list
161
172
  })
162
173
  apply_nts_message_attributes message
163
- send_message(message, validate: validate).map(&:message)
174
+ track_status_unsubscribe(message)
175
+ delivery = send_message(message, validate: validate)
176
+ delivery.map(&:message)
177
+ ensure
178
+ @pending_status_unsubscriptions.delete(message.m_id) if message && !delivery&.success?
179
+ end
180
+
181
+ def track_status_unsubscribe(message)
182
+ component = message.attribute('cId')
183
+ @pending_status_unsubscriptions[message.m_id] = message.attribute('sS').map do |item|
184
+ code = item['sCI']
185
+ name = item['n']
186
+ [code, name, @status_subscriptions.dig(component, code, name)]
187
+ end
188
+ end
189
+
190
+ def status_unsubscribe_acknowledged(original)
191
+ component = original.attribute('cId')
192
+ subscriptions = @pending_status_unsubscriptions.delete(original.m_id)
193
+ return unless subscriptions
194
+
195
+ subscriptions.each do |code, name, subscription|
196
+ next unless @status_subscriptions.dig(component, code, name).equal?(subscription)
197
+
198
+ remove_subscription_item(component, code, name)
199
+ end
164
200
  end
165
201
 
166
202
  def unsubscribe_to_status!(...)
@@ -190,6 +190,8 @@ module RSMP
190
190
  end
191
191
 
192
192
  def programming_hash_to_array(normalized)
193
+ return [] if normalized.empty?
194
+
193
195
  max_key = normalized.keys.max
194
196
  program_array = Array.new(max_key + 1)
195
197
  normalized.each do |index, value|
data/lib/rsmp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module RSMP
2
- VERSION = '0.51.3'.freeze
2
+ VERSION = '0.52.0'.freeze
3
3
  end
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.51.3
4
+ version: 0.52.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Tin
@@ -218,6 +218,8 @@ files:
218
218
  - lib/rsmp/options/options.rb
219
219
  - lib/rsmp/options/schemas/config_common.json
220
220
  - lib/rsmp/options/schemas/log.json
221
+ - lib/rsmp/options/schemas/required_settings.json
222
+ - lib/rsmp/options/schemas/required_supervisor_settings.json
221
223
  - lib/rsmp/options/schemas/site.json
222
224
  - lib/rsmp/options/schemas/supervisor.json
223
225
  - lib/rsmp/options/schemas/supervisor_site.json