asterism 0.7.1 → 0.9.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.
@@ -1,4 +1,5 @@
1
- # The credentials of one node or router as one file (CRuby only): the .tar
1
+ # The credentials of one node or router as one file, CRuby's part (the
2
+ # class itself is mrblib/credentials.rb, shared with the boards): the .tar
2
3
  # an Asterism console hands out when it issues a certificate ("Save all
3
4
  # (.tar)" on the certificate page; the archive is made in the browser that
4
5
  # made the key). It holds, at its top or in one directory:
@@ -23,130 +24,74 @@ require "json"
23
24
  require "stringio"
24
25
 
25
26
  module Asterism
27
+ # The portable class is mrblib/credentials.rb (the boards read the .tar
28
+ # with it); CRuby reads the file with the standard library and checks
29
+ # more: who may read the file, and the key pair (prepended, so the
30
+ # portable methods are not redefined).
26
31
  class Credentials
27
- # What a credentials file is missing or holds wrong.
28
- class Error < ::Asterism::Error; end
32
+ module CRuby
33
+ private
29
34
 
30
- TLS = "transport/link/tls"
31
- # The largest file read (the real ones are a few KB).
32
- MAX_SIZE = 1024 * 1024
33
-
34
- attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
35
-
36
- # Reads path (a String or Pathname of a .tar). warn: false skips the
37
- # check of who may read the file.
38
- def self.load(path, warn: true)
39
- new(path, warn: warn)
40
- end
41
-
42
- # A credentials file (a path or a Credentials) and the arguments of a
43
- # connection -> [locator, config Hash, credentials]. The locator given
44
- # wins over asterism.json's router; config given (a Hash) is laid over
45
- # the TLS keys. @api private
46
- def self.merge(credentials, locator, config)
47
- creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
48
- if config.is_a?(String)
49
- raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
35
+ def b64(pem)
36
+ [pem].pack("m0")
50
37
  end
51
- merged = creds.zenoh_config.merge(config || {})
52
- [locator || creds.router, merged, creds]
53
- end
54
-
55
- def initialize(path, warn: true)
56
- @path = path.to_s
57
- check_mode if warn
58
- files = read_tar
59
- @info = parse_info(files["asterism.json"])
60
- keys = files.keys.select { _1.end_with?(".key") }
61
- raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
62
- raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
63
- @name = @info["name"].to_s
64
- @name = File.basename(keys.first, ".key") if @name.empty?
65
- @key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
66
- @cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
67
- @ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
68
- @kind = @info["kind"]
69
- @router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
70
- @not_after = @info["not_after"]
71
- check_pair
72
- end
73
-
74
- # The zenoh configuration keys (a Hash for Session.open's config:):
75
- # the CA, the certificate and the key as base64, and mutual TLS.
76
- def zenoh_config
77
- {
78
- "#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
79
- "#{TLS}/connect_certificate_base64" => b64(@cert_pem),
80
- "#{TLS}/connect_private_key_base64" => b64(@key_pem),
81
- "#{TLS}/enable_mtls" => true
82
- }
83
- end
84
-
85
- def inspect
86
- "#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
87
- end
88
- alias to_s inspect
89
-
90
- private
91
38
 
92
- def b64(pem)
93
- [pem].pack("m0")
94
- end
95
-
96
- # Like ssh with a private key: a file that the group or others may read
97
- # is told about (it still works).
98
- def check_mode
99
- st = File.stat(@path)
100
- return if Gem.win_platform? || (st.mode & 0o077).zero?
101
- Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
102
- "(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
103
- rescue SystemCallError => e
104
- raise Error, "#{@path}: #{e.message}"
105
- end
39
+ # Like ssh with a private key: a file that the group or others may read
40
+ # is told about (it still works).
41
+ def check_mode
42
+ st = File.stat(@path)
43
+ return if Gem.win_platform? || (st.mode & 0o077).zero?
44
+ Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
45
+ "(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
46
+ rescue SystemCallError => e
47
+ raise Error, "#{@path}: #{e.message}"
48
+ end
106
49
 
107
- # {basename => content} of the regular files, at the top or in one
108
- # directory.
109
- def read_tar
110
- data = File.binread(@path)
111
- raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
112
- files = {}
113
- Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
114
- tar.each do |entry|
115
- next unless entry.file?
116
- base = File.basename(entry.full_name)
117
- raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
118
- files[base] = entry.read.to_s
50
+ # {basename => content} of the regular files, at the top or in one
51
+ # directory.
52
+ def read_tar
53
+ data = File.binread(@path)
54
+ raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
55
+ files = {}
56
+ Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
57
+ tar.each do |entry|
58
+ next unless entry.file?
59
+ base = File.basename(entry.full_name)
60
+ raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
61
+ files[base] = entry.read.to_s
62
+ end
119
63
  end
64
+ raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
65
+ files
66
+ rescue SystemCallError => e
67
+ raise Error, "#{@path}: #{e.message}"
68
+ rescue Gem::Package::TarInvalidError, ArgumentError => e
69
+ raise Error, "#{@path}: not a tar file (#{e.message})"
120
70
  end
121
- raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
122
- files
123
- rescue SystemCallError => e
124
- raise Error, "#{@path}: #{e.message}"
125
- rescue Gem::Package::TarInvalidError, ArgumentError => e
126
- raise Error, "#{@path}: not a tar file (#{e.message})"
127
- end
128
71
 
129
- def parse_info(text)
130
- return {} if text.nil?
131
- h = JSON.parse(text)
132
- raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
133
- h
134
- rescue JSON::ParserError => e
135
- raise Error, "#{@path}: asterism.json: #{e.message}"
136
- end
72
+ def parse_info(text)
73
+ return {} if text.nil?
74
+ h = JSON.parse(text)
75
+ raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
76
+ h
77
+ rescue JSON::ParserError => e
78
+ raise Error, "#{@path}: asterism.json: #{e.message}"
79
+ end
137
80
 
138
- # The key belongs to the certificate, and the certificate has not
139
- # expired (a warning: zenoh refuses it anyway). Skipped without openssl.
140
- def check_pair
141
- require "openssl"
142
- cert = OpenSSL::X509::Certificate.new(@cert_pem)
143
- key = OpenSSL::PKey.read(@key_pem)
144
- raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
145
- Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
146
- rescue LoadError
147
- nil
148
- rescue OpenSSL::OpenSSLError => e
149
- raise Error, "#{@path}: #{e.message}"
81
+ # The key belongs to the certificate, and the certificate has not
82
+ # expired (a warning: zenoh refuses it anyway). Skipped without openssl.
83
+ def check_pair
84
+ require "openssl"
85
+ cert = OpenSSL::X509::Certificate.new(@cert_pem)
86
+ key = OpenSSL::PKey.read(@key_pem)
87
+ raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
88
+ Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
89
+ rescue LoadError
90
+ nil
91
+ rescue OpenSSL::OpenSSLError => e
92
+ raise Error, "#{@path}: #{e.message}"
93
+ end
150
94
  end
95
+ prepend CRuby
151
96
  end
152
97
  end
@@ -27,7 +27,7 @@ module Asterism
27
27
  # singleton class, so the shared methods themselves are not changed.
28
28
  module Locked
29
29
  %i[expose unexpose poll nodes [] call_async meta connected? reconnecting? exposed on_join on_leave off_join off_leave
30
- profile profile_async session].each do |m|
30
+ on_app_join on_app_leave off_app_join off_app_leave profile profile_async status status_async apps session].each do |m|
31
31
  define_method(m) do |*args, **kw, &blk|
32
32
  ::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
33
33
  end
@@ -73,9 +73,10 @@ module Asterism
73
73
  ::Asterism::LOCK.synchronize { super }
74
74
  end
75
75
 
76
- # The on_join / on_leave blocks (called from Asterism.poll): with a Net,
77
- # what they raise goes to its on_error and the other blocks still run.
78
- def tell(where, blk, node)
76
+ # The on_join / on_leave (and on_app_*) blocks (called from
77
+ # Asterism.poll): with a Net, what they raise goes to its on_error and
78
+ # the other blocks still run.
79
+ def tell(where, blk, node, app = nil)
79
80
  net = @net
80
81
  return super unless net
81
82
  net.runner.guard(where) { super }
@@ -162,11 +163,12 @@ module Asterism
162
163
  def exposed = ::Asterism.exposed
163
164
 
164
165
  # Asterism.expose: methods: an Array of names or a Hash name => number
165
- # of arguments; caller: :from passes the calling node's ID as that
166
+ # of arguments (or => {args: [names], doc: "text"}); doc: the object's
167
+ # own text (both go to the meta); caller: :from passes the calling node's ID as that
166
168
  # keyword. The object's methods run on the receiving thread (or in
167
169
  # run, or in whatever polls).
168
- def expose(name, obj, methods:, caller: nil)
169
- ::Asterism.expose(name, obj, methods: methods, caller: caller)
170
+ def expose(name, obj, methods:, caller: nil, doc: nil)
171
+ ::Asterism.expose(name, obj, methods: methods, caller: caller, doc: doc)
170
172
  end
171
173
 
172
174
  # Asterism.current_call: the call being answered on this thread, or nil.
@@ -187,6 +189,34 @@ module Asterism
187
189
  ::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
188
190
  end
189
191
 
192
+ # Asterism.status: this application's status (a Hash: the whole
193
+ # status; keywords: those fields). Without arguments, returns it.
194
+ def status(h = nil, **fields)
195
+ ::Asterism.status(h, **fields)
196
+ end
197
+
198
+ # Asterism.status_of: another application's status, or nil. Waits like
199
+ # a call (for the receiving thread's ticks while it runs).
200
+ def status_of(node, app, timeout: nil, timeout_ms: nil)
201
+ ::Asterism.status_of(node, app, timeout: timeout, timeout_ms: timeout_ms)
202
+ end
203
+
204
+ # on_status(pattern = "*/*") { |node, app, status| }: the statuses put
205
+ # by the applications whose "<node>/<app>" matches the pattern (Zenoh
206
+ # wildcards: * one chunk, ** several), as they change, on the receiving
207
+ # thread. Only changes come: ask status_of for the one there now.
208
+ # Returns the subscription (close it to stop).
209
+ def on_status(pattern = "*/*", &blk)
210
+ raise ArgumentError, "on_status needs a block" unless blk
211
+ session.subscribe("#{::Asterism::ROOT}/#{pattern}/#{::Asterism::Status::KEY}") do |sample|
212
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
213
+ blk.call(*got) if got
214
+ end
215
+ end
216
+
217
+ # Asterism.apps: the applications alive now, "<node>/<app>".
218
+ def apps(pattern = "*/*") = ::Asterism.apps(pattern)
219
+
190
220
  # A proxy for <node>/<app>/<object>; timeout: in seconds (or
191
221
  # timeout_ms:).
192
222
  def [](path, timeout: nil, timeout_ms: nil)
@@ -217,6 +247,29 @@ module Asterism
217
247
  self
218
248
  end
219
249
 
250
+ # on_app_join { |node, app| } / on_app_leave { |node, app| }: an
251
+ # application appeared on the network / is gone (Asterism.on_app_join),
252
+ # on the receiving thread. What they raise goes to on_error.
253
+ def on_app_join(&blk)
254
+ ::Asterism.on_app_join(&blk)
255
+ self
256
+ end
257
+
258
+ def on_app_leave(&blk)
259
+ ::Asterism.on_app_leave(&blk)
260
+ self
261
+ end
262
+
263
+ def off_app_join(blk)
264
+ ::Asterism.off_app_join(blk)
265
+ self
266
+ end
267
+
268
+ def off_app_leave(blk)
269
+ ::Asterism.off_app_leave(blk)
270
+ self
271
+ end
272
+
220
273
  # Removes a block given to on_join / on_leave (keep the Proc).
221
274
  def off_join(blk)
222
275
  ::Asterism.off_join(blk)
@@ -3,5 +3,5 @@ module Asterism
3
3
  # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
4
  # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
5
  # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
- VERSION = "0.7.1"
6
+ VERSION = "0.9.0"
7
7
  end
data/lib/asterism.rb CHANGED
@@ -12,7 +12,7 @@ require "asterism/zenoh"
12
12
  require_relative "asterism/version"
13
13
 
14
14
  # The same order as an mrbgem's mrblib (alphabetical).
15
- %w[asterism cdr future profile proxy ros].each do |f|
15
+ %w[asterism cdr credentials future profile proxy ros status].each do |f|
16
16
  require_relative "../mrblib/#{f}"
17
17
  end
18
18
  require_relative "asterism/cruby"
data/mrblib/asterism.rb CHANGED
@@ -13,10 +13,12 @@
13
13
  # reply {"value" => v} or
14
14
  # {"error" => {"kind" => k, "class" => c, "message" => m}}
15
15
  # asterism/<node>/<app>/<object>/meta
16
- # get: reply {"methods" => [{"name" => n, "arity" => a}, ...]}
16
+ # get: reply {"methods" => [{"name" => n, "arity" => a,
17
+ # "args" => [...], "doc" => "..."}, ...], "doc" => "..."}
17
18
  # asterism/<node>/<app> liveliness: the application is up
18
19
  # asterism/<node>/<app>/<object> liveliness: the object is exposed
19
20
  # asterism/<node>/@profile get: reply the node's profile (profile.rb)
21
+ # asterism/<node>/<app>/@status get: reply the application's status (status.rb)
20
22
  #
21
23
  # Everything is polled: nothing runs behind the application's back. A call
22
24
  # that waits for its answer keeps polling meanwhile and answers the calls
@@ -240,16 +242,23 @@ module Asterism
240
242
  # Opens the connection. locator, mode: and listen: go to
241
243
  # Asterism::Zenoh::Session.open (client of a router by default; mode: :peer
242
244
  # with or without listen: for no router), and so do config: (a Hash of
243
- # "key/path" => value or a JSON5 String, e.g. TLS certificates) and
244
- # connect_timeout: (seconds) when they are given (CRuby's asterism-zenoh
245
- # only; the boards raise ArgumentError). node: is this machine's ID and
246
- # app: this application's name. check_timeout: how long to look for
247
- # another copy of the same <node>/<app> (seconds, CHECK_TIMEOUT). Raises
248
- # Disconnected when it cannot connect, and Error when the same
249
- # <node>/<app> is already on the network.
250
- def self.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
251
- check_timeout: nil)
245
+ # "key/path" => value or a JSON5 String, e.g. TLS certificates; the boards
246
+ # take the TLS keys only) and connect_timeout: (seconds; CRuby's
247
+ # asterism-zenoh only, the boards raise ArgumentError) when they are given.
248
+ # credentials: a .tar from an Asterism console (Asterism::Credentials, a
249
+ # path or a loaded one): its certificates go to zenoh (mutual TLS), and
250
+ # locator and node: default to its router and name. node: is this
251
+ # machine's ID and app: this application's name. check_timeout: how long
252
+ # to look for another copy of the same <node>/<app> (seconds,
253
+ # CHECK_TIMEOUT). Raises Disconnected when it cannot connect, and Error
254
+ # when the same <node>/<app> is already on the network.
255
+ def self.connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
256
+ check_timeout: nil, credentials: nil)
252
257
  raise Error, "already connected (Asterism.close first)" if connected?
258
+ if credentials
259
+ locator, config, creds = Credentials.merge(credentials, locator, config)
260
+ node ||= creds.name
261
+ end
253
262
  release
254
263
  node = check_name("node", node)
255
264
  app = check_name("app", app)
@@ -279,7 +288,8 @@ module Asterism
279
288
  @session = s
280
289
  @node = node
281
290
  @app = app
282
- @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil]
291
+ @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil,
292
+ # {method name => meta fields}, the object's doc or nil]
283
293
  @alive = {} # "node/app/object" => node (from liveliness)
284
294
  @apps = {} # "node/app" => node (from liveliness)
285
295
  @proxies = {}
@@ -287,10 +297,13 @@ module Asterism
287
297
  @lost = nil
288
298
  @known = [] # the other nodes told to on_join so far
289
299
  @known_set = {} # the same, as node => true
300
+ @known_apps = {} # "node/app" => true: the other applications told to on_app_join
290
301
  @node_list = [node] # what nodes returns (this node first), rebuilt on a change
291
302
  @nodes_changed = false # liveliness changed since tell_nodes last compared
292
303
  @was_reconnecting = false
304
+ @status_put = nil
293
305
  tell_profile # for those who follow the profiles (the reply is the queryable's)
306
+ tell_status(true) # a status set before connecting (status.rb)
294
307
  self
295
308
  end
296
309
 
@@ -353,8 +366,11 @@ module Asterism
353
366
  def self.close
354
367
  release
355
368
  @known = nil
369
+ @known_apps = nil
356
370
  @on_join = nil
357
371
  @on_leave = nil
372
+ @on_app_join = nil
373
+ @on_app_leave = nil
358
374
  nil
359
375
  end
360
376
 
@@ -380,6 +396,8 @@ module Asterism
380
396
  @queryable = nil
381
397
  @watch = nil
382
398
  @profile_queryable = nil
399
+ @status_queryable = nil
400
+ @status_put = nil
383
401
  end
384
402
 
385
403
  # The Zenoh session of the connection (an Asterism::Zenoh::Session), for
@@ -438,21 +456,39 @@ module Asterism
438
456
 
439
457
  # Makes obj callable from other machines as <node>/<app>/<name>. Only the
440
458
  # listed methods can be called. methods: is an Array of names, or a Hash
441
- # name => number of arguments (checked before calling; -1 for any).
459
+ # name => number of arguments (checked before calling; -1 for any), or
460
+ # name => {args: [names], doc: "text"} (docs/protocol.md 5): the
461
+ # arguments' names (their number is the number checked, unless arity:
462
+ # says otherwise) and what the method does, both told in the meta for
463
+ # the readers that show them (asterism mcp, the command line). doc: a
464
+ # text about the object itself, also in the meta.
442
465
  # caller: a keyword name (caller: :from): each call passes the calling
443
466
  # node's ID (Asterism.current_call.from) to the method as that keyword,
444
467
  # and a call that brings the keyword itself is refused (an "arguments"
445
468
  # error), so the value is always the one Asterism took from the call.
446
- def self.expose(name, obj, methods:, caller: nil)
469
+ def self.expose(name, obj, methods:, caller: nil, doc: nil)
447
470
  s = session!
448
471
  name = check_name("object", name)
449
472
  table = {}
473
+ info = {}
450
474
  if methods.is_a?(Hash)
451
- methods.each { |m, n| table[m.to_s] = n.to_i }
475
+ methods.each do |m, n|
476
+ if n.is_a?(Hash)
477
+ d = method_doc(m, n)
478
+ table[m.to_s] = d[0]
479
+ info[m.to_s] = d[1]
480
+ else
481
+ table[m.to_s] = n.to_i
482
+ end
483
+ end
452
484
  else
453
485
  methods.each { |m| table[m.to_s] = -1 }
454
486
  end
455
487
  raise ArgumentError, "no method to expose" if table.empty?
488
+ unless doc.nil?
489
+ raise ArgumentError, "doc: expects a String, got #{doc.class}" unless doc.is_a?(String)
490
+ raise ArgumentError, "doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
491
+ end
456
492
  table.each_key do |m|
457
493
  raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
458
494
  end
@@ -466,12 +502,60 @@ module Asterism
466
502
  end
467
503
  unexpose(name)
468
504
  token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
469
- @objects[name] = [obj, table, token, kw]
505
+ @objects[name] = [obj, table, token, kw, info, doc]
470
506
  "#{@node}/#{@app}/#{name}"
471
507
  rescue Asterism::Zenoh::Error => e
472
508
  raise Disconnected, e.message
473
509
  end
474
510
 
511
+ # The longest doc text (bytes) and the most argument names a method may
512
+ # tell in its meta (docs/protocol.md 5).
513
+ DOC_MAX = 2048
514
+ ARGS_MAX = 16
515
+
516
+ # A method's {args:, doc:, arity:} at expose: [the number of arguments
517
+ # checked, its meta fields {"args", "doc"}].
518
+ # @api private
519
+ def self.method_doc(m, h)
520
+ args = nil
521
+ doc = nil
522
+ arity = nil
523
+ h.each do |k, v|
524
+ ks = k.to_s
525
+ if ks == "args" then args = v
526
+ elsif ks == "doc" then doc = v
527
+ elsif ks == "arity" then arity = v
528
+ else raise ArgumentError, "#{m}: unknown key #{ks} (args:, doc:, arity:)"
529
+ end
530
+ end
531
+ out = {}
532
+ unless args.nil?
533
+ raise ArgumentError, "#{m}: args: expects an Array of names" unless args.is_a?(Array)
534
+ raise ArgumentError, "#{m}: args: at most #{ARGS_MAX} names" if args.size > ARGS_MAX
535
+ names = []
536
+ args.each do |a|
537
+ raise ArgumentError, "#{m}: args: expects names, got #{a.inspect}" unless a.is_a?(String) || a.is_a?(Symbol)
538
+ a = a.to_s
539
+ raise ArgumentError, "#{m}: args: an empty name" if a.empty? || a.bytesize > 64
540
+ names << a
541
+ end
542
+ out["args"] = names
543
+ end
544
+ unless doc.nil?
545
+ raise ArgumentError, "#{m}: doc: expects a String" unless doc.is_a?(String)
546
+ raise ArgumentError, "#{m}: doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
547
+ out["doc"] = doc
548
+ end
549
+ n = if !arity.nil? then arity.to_i
550
+ elsif !args.nil? then args.size
551
+ else -1
552
+ end
553
+ if n >= 0 && !args.nil? && args.size != n
554
+ raise ArgumentError, "#{m}: #{args.size} names in args: for #{n} arguments"
555
+ end
556
+ [n, out]
557
+ end
558
+
475
559
  def self.unexpose(name)
476
560
  entry = @objects ? @objects.delete(name.to_s) : nil
477
561
  entry[2].close if entry
@@ -532,6 +616,88 @@ module Asterism
532
616
  self
533
617
  end
534
618
 
619
+ # on_app_join { |node, app| }: an application of another node appeared
620
+ # (its token asterism/<node>/<app>); on_app_leave { |node, app| }: it is
621
+ # gone. A node may run several applications: each comes and goes on its
622
+ # own, and the node stays (on_join / on_leave) while one of them or an
623
+ # object is there. The applications there already join on the first
624
+ # polls after connecting; when the connection is lost, every application
625
+ # known until then leaves. Called from Asterism.poll, as on_join;
626
+ # Asterism.close forgets them.
627
+ def self.on_app_join(&blk)
628
+ raise ArgumentError, "on_app_join needs a block" unless blk
629
+ (@on_app_join ||= []) << blk
630
+ self
631
+ end
632
+
633
+ def self.on_app_leave(&blk)
634
+ raise ArgumentError, "on_app_leave needs a block" unless blk
635
+ (@on_app_leave ||= []) << blk
636
+ self
637
+ end
638
+
639
+ def self.off_app_join(blk)
640
+ @on_app_join.delete(blk) if @on_app_join
641
+ self
642
+ end
643
+
644
+ def self.off_app_leave(blk)
645
+ @on_app_leave.delete(blk) if @on_app_leave
646
+ self
647
+ end
648
+
649
+ # Compares the applications alive now with those told before and calls
650
+ # the on_app_* blocks for the difference (this application left out).
651
+ # @api private
652
+ def self.tell_apps
653
+ known = @known_apps
654
+ return if known.nil?
655
+ now = {}
656
+ if @session
657
+ own = "#{@node}/#{@app}"
658
+ keys = @apps.keys
659
+ i = 0
660
+ while i < keys.size
661
+ now[keys[i]] = true unless keys[i] == own
662
+ i += 1
663
+ end
664
+ end
665
+ joined = []
666
+ left = []
667
+ keys = now.keys
668
+ i = 0
669
+ while i < keys.size
670
+ joined << keys[i] unless known[keys[i]]
671
+ i += 1
672
+ end
673
+ keys = known.keys
674
+ i = 0
675
+ while i < keys.size
676
+ left << keys[i] unless now[keys[i]]
677
+ i += 1
678
+ end
679
+ @known_apps = @session ? now : nil
680
+ tell_apps_each("on_app_join", @on_app_join, joined)
681
+ tell_apps_each("on_app_leave", @on_app_leave, left)
682
+ end
683
+
684
+ # @api private
685
+ def self.tell_apps_each(where, blocks, names)
686
+ return if blocks.nil? || names.empty?
687
+ i = 0
688
+ while i < names.size
689
+ cut = names[i].index("/")
690
+ node = names[i][0, cut]
691
+ app = names[i][cut + 1, names[i].size]
692
+ j = 0
693
+ while j < blocks.size
694
+ tell(where, blocks[j], node, app)
695
+ j += 1
696
+ end
697
+ i += 1
698
+ end
699
+ end
700
+
535
701
  # Compares the nodes alive now with those told before and calls the
536
702
  # blocks for the difference.
537
703
  # @api private
@@ -542,6 +708,7 @@ module Asterism
542
708
  # the connection is neither new nor lost): nothing can have changed.
543
709
  return unless @nodes_changed
544
710
  @nodes_changed = false
711
+ tell_apps
545
712
  now = []
546
713
  now_set = {}
547
714
  if @session
@@ -595,10 +762,15 @@ module Asterism
595
762
  end
596
763
  end
597
764
 
598
- # One block for one node (CRuby routes what it raises to on_error).
765
+ # One block for one node, or one application (CRuby routes what it
766
+ # raises to on_error).
599
767
  # @api private
600
- def self.tell(_where, blk, node)
601
- blk.call(node)
768
+ def self.tell(_where, blk, node, app = nil)
769
+ if app.nil?
770
+ blk.call(node)
771
+ else
772
+ blk.call(node, app)
773
+ end
602
774
  end
603
775
 
604
776
  # @api private
@@ -612,6 +784,7 @@ module Asterism
612
784
  back_again(s)
613
785
  answer_calls
614
786
  answer_profile
787
+ answer_status
615
788
  follow_liveliness
616
789
  !@session.nil?
617
790
  rescue Asterism::Zenoh::Error => e
@@ -619,14 +792,16 @@ module Asterism
619
792
  !@session.nil?
620
793
  end
621
794
 
622
- # After the binding opened a lost connection again: tell the profile
623
- # again (a put made while the router was away reached nobody).
795
+ # After the binding opened a lost connection again: tell the profile and
796
+ # the status again (a put made while the router was away reached nobody).
624
797
  # @api private
625
798
  def self.back_again(s)
626
799
  now = s.respond_to?(:reconnecting?) && s.reconnecting?
627
800
  was = @was_reconnecting
628
801
  @was_reconnecting = now
629
- tell_profile if was && !now
802
+ return unless was && !now
803
+ tell_profile
804
+ tell_status(true)
630
805
  end
631
806
 
632
807
  # A Zenoh error during a call or a poll: the connection is gone only when
@@ -924,8 +1099,16 @@ module Asterism
924
1099
  entry = @objects[name]
925
1100
  return nil unless entry
926
1101
  list = []
927
- entry[1].each { |m, n| list << { "name" => m, "arity" => n } }
928
- { "methods" => list }
1102
+ info = entry[4] || {}
1103
+ entry[1].each do |m, n|
1104
+ e = { "name" => m, "arity" => n }
1105
+ extra = info[m]
1106
+ extra.each { |k, v| e[k] = v } if extra
1107
+ list << e
1108
+ end
1109
+ out = { "methods" => list }
1110
+ out["doc"] = entry[5] if entry[5]
1111
+ out
929
1112
  end
930
1113
 
931
1114
  MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"