asterism 0.7.0 → 0.8.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.
@@ -0,0 +1,424 @@
1
+ # The command line of the gem (exe/asterism): look at an Asterism network
2
+ # from CRuby, and call an object when asked to.
3
+ #
4
+ # asterism nodes --router tcp/192.0.2.2:7447
5
+ # asterism tree --credentials admin.tar
6
+ # asterism objects 'games/games/*'
7
+ # asterism watch # joins, leaves, statuses as they come
8
+ # asterism ping games/games/othello1 # the time of a round trip (meta)
9
+ # asterism call games/games/othello1 view '"<token>"'
10
+ # asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
11
+ #
12
+ # It is one more Asterism node: it connects with Asterism.connect (as the
13
+ # certificate's name with --credentials, as cli-<6 hex digits> otherwise)
14
+ # and reads what docs/protocol.md lets anyone read. It calls a method only
15
+ # with `call` (and the MCP bridge only the methods --allow names). What it
16
+ # can see is what the router lets it see: with a console's .tar, its role.
17
+ require "asterism"
18
+ require "optparse"
19
+ require "json"
20
+ require "securerandom"
21
+ require_relative "cli/literal"
22
+ require_relative "cli/survey"
23
+ require_relative "cli/allow"
24
+
25
+ module Asterism
26
+ class CLI
27
+ USAGE = <<~TEXT
28
+ Usage: asterism COMMAND [options]
29
+
30
+ Commands (they only read, except call):
31
+ nodes the nodes, their applications and profiles
32
+ objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
33
+ tree nodes > applications (with their status) > objects (with their methods)
34
+ watch nodes, applications and objects coming and going, and status changes
35
+ ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
36
+ --call METHOD [ARG...] times a call instead (echo is the same command)
37
+ call PATH METHOD [ARG...]
38
+ calls a method; each ARG is JSON, else a Ruby literal, else text
39
+ mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
40
+ and the methods --allow names
41
+ version the gem's version
42
+
43
+ Where the network is (one of them):
44
+ --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
45
+ --credentials TAR a console's .tar (mutual TLS; its router and name)
46
+ TEXT
47
+
48
+ def self.start(argv, out: $stdout, err: $stderr)
49
+ new(out: out, err: err).run(argv.dup)
50
+ end
51
+
52
+ def initialize(out: $stdout, err: $stderr)
53
+ @out = out
54
+ @err = err
55
+ @opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
56
+ end
57
+
58
+ def run(argv)
59
+ cmd = argv.shift
60
+ case cmd
61
+ when nil, "help", "-h", "--help"
62
+ @out.puts USAGE
63
+ return cmd.nil? ? 2 : 0
64
+ when "version", "--version", "-v"
65
+ @out.puts "asterism #{::Asterism::VERSION}"
66
+ return 0
67
+ end
68
+ unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
69
+ @err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
70
+ return 2
71
+ end
72
+ rest = parser(cmd).parse(argv)
73
+ send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
74
+ rescue OptionParser::ParseError, ArgumentError => e
75
+ @err.puts "asterism: #{e.message}"
76
+ 2
77
+ rescue ::Asterism::Disconnected => e
78
+ @err.puts "asterism: cannot reach the network: #{e.message}"
79
+ 1
80
+ rescue ::Asterism::Error => e
81
+ @err.puts "asterism: #{e.class}: #{e.message}"
82
+ 1
83
+ rescue Interrupt
84
+ 0
85
+ end
86
+
87
+ def parser(cmd)
88
+ OptionParser.new do |o|
89
+ o.banner = "Usage: asterism #{cmd} [options]"
90
+ o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
91
+ o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
92
+ o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
93
+ o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
94
+ o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
95
+ o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
96
+ o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
97
+ o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
98
+ case cmd
99
+ when "watch"
100
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
101
+ when "ping", "echo"
102
+ o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
103
+ o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
104
+ o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
105
+ when "call"
106
+ o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
107
+ when "mcp"
108
+ o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
109
+ "(repeatable)") { @opts[:allow] << _1 }
110
+ end
111
+ end
112
+ end
113
+
114
+ # ------------------------------------------------------------ connecting
115
+
116
+ # Connects, starts the receiving thread, waits for the tokens and yields
117
+ # the Net and a Survey.
118
+ def connect(app_prefix: "cli")
119
+ router = @opts[:router]
120
+ creds = @opts[:credentials]
121
+ peer = @opts[:peer]
122
+ if router.nil? && creds.nil? && peer.nil?
123
+ raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
124
+ end
125
+ node = @opts[:node]
126
+ node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
127
+ app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
128
+ kw = { node: node, app: app }
129
+ kw[:credentials] = creds if creds
130
+ locator = router
131
+ if peer
132
+ locator = peer
133
+ kw[:mode] = :peer
134
+ end
135
+ ::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
136
+ ::Asterism.connect(locator, **kw) do |net|
137
+ net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
138
+ net.start
139
+ survey = Survey.new(timeout: @opts[:timeout])
140
+ survey.settle(@opts[:settle])
141
+ yield net, survey
142
+ end
143
+ end
144
+
145
+ def emit_json(v)
146
+ @out.puts JSON.pretty_generate(v)
147
+ end
148
+
149
+ # ------------------------------------------------------------ nodes
150
+
151
+ def cmd_nodes(_rest)
152
+ connect do |_net, sv|
153
+ tree = sv.tree(meta: false)
154
+ if @opts[:json]
155
+ emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
156
+ elsif tree.empty?
157
+ @out.puts "(no other node is visible)"
158
+ else
159
+ tree.each do |n|
160
+ @out.puts "#{n['node']}#{profile_text(n['profile'])}"
161
+ n["apps"].each do |a|
162
+ @out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
163
+ "#{" status #{compact(a['status'])}" if a.key?('status')}"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ 0
169
+ end
170
+
171
+ # ------------------------------------------------------------ objects
172
+
173
+ def cmd_objects(rest)
174
+ pattern = rest.shift || "**"
175
+ connect do |_net, sv|
176
+ paths = sv.objects(pattern)
177
+ metas = sv.metas(paths)
178
+ if @opts[:json]
179
+ emit_json(paths.map do |p|
180
+ h = { "path" => p, "methods" => metas[p] }
181
+ h["doc"] = sv.object_doc(p) if sv.object_doc(p)
182
+ h
183
+ end)
184
+ elsif paths.empty?
185
+ @out.puts "(no object matches #{pattern})"
186
+ else
187
+ paths.each do |p|
188
+ @out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
189
+ @out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
190
+ # The methods that tell what they do, one a line (doc's first line).
191
+ (metas[p] || []).each do |m|
192
+ @out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
193
+ end
194
+ end
195
+ end
196
+ end
197
+ 0
198
+ end
199
+
200
+ # ------------------------------------------------------------ tree
201
+
202
+ def cmd_tree(_rest)
203
+ connect do |_net, sv|
204
+ tree = sv.tree
205
+ if @opts[:json]
206
+ emit_json(tree)
207
+ elsif tree.empty?
208
+ @out.puts "(no other node is visible)"
209
+ else
210
+ tree.each do |n|
211
+ @out.puts "#{n['node']}#{profile_text(n['profile'])}"
212
+ n["apps"].each do |a|
213
+ @out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
214
+ a["objects"].each do |o|
215
+ @out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+ 0
222
+ end
223
+
224
+ # ------------------------------------------------------------ watch
225
+
226
+ # Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
227
+ # on_join / on_leave), applications and objects (their liveliness
228
+ # tokens), statuses (their puts) and profiles (their puts). The
229
+ # statuses there at the start come first.
230
+ def cmd_watch(_rest)
231
+ connect do |net, sv|
232
+ own = "#{net.node_id}/#{net.app}"
233
+ sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
234
+ net.on_join { |n| event("join", n, what: "node") }
235
+ net.on_leave { |n| event("leave", n, what: "node") }
236
+ net.session.liveliness_watch("asterism/**") do |key, alive|
237
+ # The tokens alive at the start come first, in one burst: not news.
238
+ next unless @watch_started
239
+ path = key.delete_prefix("asterism/")
240
+ next if path == own || path.start_with?("#{own}/")
241
+ what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
242
+ event(alive ? "join" : "leave", path, what: what) if what
243
+ end
244
+ net.session.subscribe(Survey::STATUS_ALL) do |sample|
245
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
246
+ event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
247
+ end
248
+ net.session.subscribe(Survey::PROFILES_ALL) do |sample|
249
+ node = sample.key.split("/")[1]
250
+ pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
251
+ event("profile", node, profile: pr) if pr && node != net.node_id
252
+ end
253
+ sleep 0.3
254
+ @watch_started = true
255
+ @err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
256
+ deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
257
+ loop do
258
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
259
+ break unless net.connected?
260
+ sleep 0.05
261
+ end
262
+ end
263
+ 0
264
+ end
265
+
266
+ def event(kind, path, what: nil, status: nil, profile: nil, now: false)
267
+ t = Time.now
268
+ if @opts[:json]
269
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
270
+ h["what"] = what if what
271
+ h["status"] = status if status
272
+ h["profile"] = profile if profile
273
+ h["now"] = true if now
274
+ @out.puts JSON.generate(h)
275
+ else
276
+ mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
277
+ text = case kind
278
+ when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
279
+ when "profile" then "profile #{path}#{profile_text(profile)}"
280
+ else "#{what} #{path}"
281
+ end
282
+ @out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
283
+ end
284
+ @out.flush
285
+ end
286
+
287
+ # ------------------------------------------------------------ ping
288
+
289
+ # Round trips to PATH: the meta of an object (no method runs), the
290
+ # profile of a node, or with --call a method (with the ARGs given).
291
+ def cmd_ping(rest)
292
+ path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
293
+ args = rest.map { Literal.parse(_1) }
294
+ chunks = path.split("/")
295
+ raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
296
+ raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
297
+ what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
298
+ times = []
299
+ connect do |_net, _sv|
300
+ ms = (@opts[:timeout] * 1000).round
301
+ @opts[:count].times do |i|
302
+ sleep @opts[:interval] if i.positive?
303
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
304
+ ok = begin
305
+ ping_once(path, chunks, args, ms)
306
+ true
307
+ rescue ::Asterism::RemoteError => e
308
+ @err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
309
+ true # it answered
310
+ rescue ::Asterism::TimeoutError => e
311
+ @err.puts "asterism: #{e.message}"
312
+ false
313
+ end
314
+ dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
315
+ times << dt if ok
316
+ @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
317
+ end
318
+ end
319
+ if @opts[:json]
320
+ emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
321
+ "ms" => times.map { _1.round(2) } })
322
+ elsif times.any?
323
+ @out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
324
+ times.min, times.sum / times.size, times.max)
325
+ end
326
+ times.empty? ? 1 : 0
327
+ end
328
+
329
+ def ping_once(path, chunks, args, ms)
330
+ if @opts[:call]
331
+ ::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
332
+ elsif chunks.size == 3
333
+ raw = ::Asterism.meta(path, ms)
334
+ raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
335
+ else
336
+ ::Asterism.profile_async(path, false, nil, ms).raw_value
337
+ end
338
+ end
339
+
340
+ # ------------------------------------------------------------ call
341
+
342
+ def cmd_call(rest)
343
+ path = rest.shift
344
+ method = rest.shift
345
+ raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
346
+ ::Asterism.split_path(path)
347
+ args = rest.map { Literal.parse(_1) }
348
+ kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
349
+ raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
350
+ kwargs = kwargs.transform_keys(&:to_sym)
351
+ code = 0
352
+ connect do |_net, _sv|
353
+ px = ::Asterism[path, timeout: @opts[:timeout]]
354
+ begin
355
+ v = px.asterism_call(method, *args, **kwargs)
356
+ @opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
357
+ rescue ::Asterism::RemoteError => e
358
+ if @opts[:json]
359
+ @out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
360
+ "message" => e.remote_message } })
361
+ else
362
+ @err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
363
+ end
364
+ code = 1
365
+ rescue ::Asterism::TimeoutError => e
366
+ @err.puts "asterism: #{e.message}" \
367
+ "#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
368
+ 'it looks the same as no answer)' if @opts[:credentials]}"
369
+ code = 1
370
+ end
371
+ end
372
+ code
373
+ end
374
+
375
+ # ------------------------------------------------------------ mcp
376
+
377
+ def cmd_mcp(_rest)
378
+ begin
379
+ require_relative "cli/mcp"
380
+ rescue LoadError => e
381
+ raise unless e.message.include?("mcp")
382
+ @err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
383
+ return 1
384
+ end
385
+ connect(app_prefix: "mcp") do |net, sv|
386
+ MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
387
+ end
388
+ 0
389
+ end
390
+
391
+ # ------------------------------------------------------------ text
392
+
393
+ def profile_text(pr)
394
+ return "" unless pr
395
+ parts = [pr["kind"], pr["host"]].compact.join("/")
396
+ name = pr["name"] ? " \"#{pr['name']}\"" : ""
397
+ " (#{parts})#{name}"
398
+ end
399
+
400
+ def methods_text(list)
401
+ list.map { |m| signature(m) }.join(" ")
402
+ end
403
+
404
+ # name(arg, arg) with the names the meta tells, else name(2) / name(*).
405
+ def signature(m)
406
+ return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
407
+ m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
408
+ end
409
+
410
+ def compact(v)
411
+ JSON.generate(json_safe(v))
412
+ end
413
+
414
+ # A value as JSON can hold it (bytes as a note).
415
+ def json_safe(v)
416
+ case v
417
+ when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
418
+ when Array then v.map { json_safe(_1) }
419
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
420
+ else v
421
+ end
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,97 @@
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
3
+ # an Asterism console hands out when it issues a certificate ("Save all
4
+ # (.tar)" on the certificate page; the archive is made in the browser that
5
+ # made the key). It holds, at its top or in one directory:
6
+ #
7
+ # <name>.key the private key (PKCS#8 PEM)
8
+ # <name>.pem the certificate (its common name is <name>)
9
+ # ca.pem the CA that signed it (and the router's certificate)
10
+ # zenoh.json5 an example configuration (for zenohd or another client)
11
+ # asterism.json {"name": ..., "router": "tls/host:port", "not_after": ..., ...}
12
+ #
13
+ # Asterism.connect(credentials: "alice.tar", app: "game") { |net| ... }
14
+ # Asterism::Zenoh.open(credentials: "alice.tar") { |s| ... }
15
+ # Asterism::ROS.connect(credentials: "alice.tar") { |ros| ... }
16
+ #
17
+ # The file is read into memory (Gem::Package::TarReader, the standard
18
+ # library): nothing is written anywhere. The certificates go to zenoh as
19
+ # base64 (transport/link/tls/*_base64) with enable_mtls, the router's
20
+ # locator and the node ID come from asterism.json unless given, and a file
21
+ # that others may read is warned about, as ssh does with a key.
22
+ require "rubygems/package"
23
+ require "json"
24
+ require "stringio"
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).
31
+ class Credentials
32
+ module CRuby
33
+ private
34
+
35
+ def b64(pem)
36
+ [pem].pack("m0")
37
+ end
38
+
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
49
+
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
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})"
70
+ end
71
+
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
80
+
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
94
+ end
95
+ prepend CRuby
96
+ end
97
+ end
@@ -26,8 +26,8 @@ module Asterism
26
26
  # The portable API, entered under LOCK. Prepended to the Asterism module's
27
27
  # singleton class, so the shared methods themselves are not changed.
28
28
  module Locked
29
- %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave
30
- profile profile_async session].each do |m|
29
+ %i[expose unexpose poll nodes [] call_async meta connected? reconnecting? exposed on_join on_leave off_join off_leave
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
@@ -36,8 +36,16 @@ module Asterism
36
36
  # With a block: yields an Asterism::Net and closes when the block ends
37
37
  # (also on an exception); returns the block's value. Without a block,
38
38
  # as before (returns Asterism).
39
- def connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: nil,
40
- &blk)
39
+ #
40
+ # credentials: a .tar from an Asterism console (Asterism::Credentials):
41
+ # its certificates go to zenoh (mutual TLS), and the locator and node:
42
+ # default to its router and name (given ones win).
43
+ def connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
44
+ check_timeout: nil, credentials: nil, &blk)
45
+ if credentials
46
+ locator, config, creds = ::Asterism::Credentials.merge(credentials, locator, config)
47
+ node ||= creds.name
48
+ end
41
49
  ::Asterism::LOCK.synchronize do
42
50
  super(locator, node: node, app: app, mode: mode, listen: listen, config: config,
43
51
  connect_timeout: connect_timeout, check_timeout: check_timeout)
@@ -65,9 +73,10 @@ module Asterism
65
73
  ::Asterism::LOCK.synchronize { super }
66
74
  end
67
75
 
68
- # The on_join / on_leave blocks (called from Asterism.poll): with a Net,
69
- # what they raise goes to its on_error and the other blocks still run.
70
- 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)
71
80
  net = @net
72
81
  return super unless net
73
82
  net.runner.guard(where) { super }
@@ -148,16 +157,18 @@ module Asterism
148
157
  def node_id = ::Asterism.node_id
149
158
  def app = ::Asterism.app
150
159
  def connected? = ::Asterism.connected?
160
+ def reconnecting? = ::Asterism.reconnecting?
151
161
  def lost_reason = ::Asterism.lost_reason
152
162
  def nodes = ::Asterism.nodes
153
163
  def exposed = ::Asterism.exposed
154
164
 
155
165
  # Asterism.expose: methods: an Array of names or a Hash name => number
156
- # 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
157
168
  # keyword. The object's methods run on the receiving thread (or in
158
169
  # run, or in whatever polls).
159
- def expose(name, obj, methods:, caller: nil)
160
- ::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)
161
172
  end
162
173
 
163
174
  # Asterism.current_call: the call being answered on this thread, or nil.
@@ -178,6 +189,34 @@ module Asterism
178
189
  ::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
179
190
  end
180
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
+
181
220
  # A proxy for <node>/<app>/<object>; timeout: in seconds (or
182
221
  # timeout_ms:).
183
222
  def [](path, timeout: nil, timeout_ms: nil)
@@ -208,6 +247,29 @@ module Asterism
208
247
  self
209
248
  end
210
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
+
211
273
  # Removes a block given to on_join / on_leave (keep the Proc).
212
274
  def off_join(blk)
213
275
  ::Asterism.off_join(blk)
@@ -27,7 +27,7 @@ module Asterism
27
27
  # Opens a Zenoh session and makes a Connection for nodes on it. domain:
28
28
  # the ROS domain of the nodes made by ros.node. type_path: directories
29
29
  # with generated types to add to Asterism::ROS::TYPE_PATH. The other
30
- # options go to Asterism::Zenoh::Session.open. With a block: yields the
30
+ # options (credentials: among them) go to Asterism::Zenoh.open. With a block: yields the
31
31
  # Connection, closes it when the block ends and returns its value.
32
32
  def self.connect(locator = nil, domain: 0, type_path: nil, interval: Runner::DEFAULT_INTERVAL, **opts)
33
33
  Array(type_path).each { |d| TYPE_PATH << File.expand_path(d) unless TYPE_PATH.include?(File.expand_path(d)) }