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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +208 -8
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/survey.rb +204 -0
- data/lib/asterism/cli.rb +424 -0
- data/lib/asterism/cruby/credentials.rb +97 -0
- data/lib/asterism/cruby/objects.rb +72 -10
- data/lib/asterism/cruby/ros.rb +1 -1
- data/lib/asterism/cruby/zenoh.rb +13 -2
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +2 -2
- data/mrblib/asterism.rb +242 -24
- data/mrblib/credentials.rb +197 -0
- data/mrblib/profile.rb +1 -1
- data/mrblib/status.rb +198 -0
- metadata +22 -6
data/lib/asterism/cruby/zenoh.rb
CHANGED
|
@@ -69,8 +69,13 @@ module Asterism
|
|
|
69
69
|
|
|
70
70
|
# Opens a session (the arguments of Session.open) and wraps it in a
|
|
71
71
|
# Connection. With a block: yields it, closes it when the block ends
|
|
72
|
-
# (also on an exception) and returns the block's value.
|
|
73
|
-
|
|
72
|
+
# (also on an exception) and returns the block's value. credentials: a
|
|
73
|
+
# .tar from an Asterism console (Asterism::Credentials): mutual TLS with
|
|
74
|
+
# its certificates, to its router unless a locator is given.
|
|
75
|
+
def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, credentials: nil, **opts)
|
|
76
|
+
if credentials
|
|
77
|
+
locator, opts[:config], = ::Asterism::Credentials.merge(credentials, locator, opts[:config])
|
|
78
|
+
end
|
|
74
79
|
conn = Connection.new(Session.open(locator, **opts), interval: interval)
|
|
75
80
|
return conn unless block_given?
|
|
76
81
|
begin
|
|
@@ -358,6 +363,12 @@ module Asterism
|
|
|
358
363
|
@session.closed?
|
|
359
364
|
end
|
|
360
365
|
|
|
366
|
+
# True while the router is away and zenoh-c opens the connection
|
|
367
|
+
# again (a client session; what was declared comes back with it).
|
|
368
|
+
def reconnecting?
|
|
369
|
+
@session.reconnecting?
|
|
370
|
+
end
|
|
371
|
+
|
|
361
372
|
def zid
|
|
362
373
|
@session.zid
|
|
363
374
|
end
|
data/lib/asterism/version.rb
CHANGED
data/lib/asterism.rb
CHANGED
|
@@ -12,12 +12,12 @@ 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"
|
|
19
19
|
# The CRuby API on top (blocks, a receiving thread, Enumerators,
|
|
20
20
|
# pattern matching): CRuby only, see README "The CRuby API".
|
|
21
|
-
%w[runner zenoh objects ros].each do |f|
|
|
21
|
+
%w[runner credentials zenoh objects ros].each do |f|
|
|
22
22
|
require_relative "asterism/cruby/#{f}"
|
|
23
23
|
end
|
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
|
|
244
|
-
#
|
|
245
|
-
# only
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
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,9 +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
|
|
303
|
+
@was_reconnecting = false
|
|
304
|
+
@status_put = nil
|
|
292
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)
|
|
293
307
|
self
|
|
294
308
|
end
|
|
295
309
|
|
|
@@ -320,6 +334,20 @@ module Asterism
|
|
|
320
334
|
true
|
|
321
335
|
end
|
|
322
336
|
|
|
337
|
+
# True while the connection is lost and the Zenoh binding opens it again
|
|
338
|
+
# by itself (a client of a router: zenoh-pico built with reconnection on
|
|
339
|
+
# the boards, zenoh-c on CRuby). connected? stays true meanwhile, and once
|
|
340
|
+
# the router is back everything declared before is there again: the
|
|
341
|
+
# application token, the exposed objects, the liveliness watch, and what
|
|
342
|
+
# the application declared on Asterism.session. Meanwhile the other nodes
|
|
343
|
+
# leave (on_leave) and join again after it, calls raise Disconnected or
|
|
344
|
+
# time out, and puts reach nobody.
|
|
345
|
+
def self.reconnecting?
|
|
346
|
+
s = @session
|
|
347
|
+
return false if s.nil?
|
|
348
|
+
s.respond_to?(:reconnecting?) ? s.reconnecting? : false
|
|
349
|
+
end
|
|
350
|
+
|
|
323
351
|
def self.node_id
|
|
324
352
|
@node
|
|
325
353
|
end
|
|
@@ -338,8 +366,11 @@ module Asterism
|
|
|
338
366
|
def self.close
|
|
339
367
|
release
|
|
340
368
|
@known = nil
|
|
369
|
+
@known_apps = nil
|
|
341
370
|
@on_join = nil
|
|
342
371
|
@on_leave = nil
|
|
372
|
+
@on_app_join = nil
|
|
373
|
+
@on_app_leave = nil
|
|
343
374
|
nil
|
|
344
375
|
end
|
|
345
376
|
|
|
@@ -365,6 +396,8 @@ module Asterism
|
|
|
365
396
|
@queryable = nil
|
|
366
397
|
@watch = nil
|
|
367
398
|
@profile_queryable = nil
|
|
399
|
+
@status_queryable = nil
|
|
400
|
+
@status_put = nil
|
|
368
401
|
end
|
|
369
402
|
|
|
370
403
|
# The Zenoh session of the connection (an Asterism::Zenoh::Session), for
|
|
@@ -423,21 +456,39 @@ module Asterism
|
|
|
423
456
|
|
|
424
457
|
# Makes obj callable from other machines as <node>/<app>/<name>. Only the
|
|
425
458
|
# listed methods can be called. methods: is an Array of names, or a Hash
|
|
426
|
-
# 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.
|
|
427
465
|
# caller: a keyword name (caller: :from): each call passes the calling
|
|
428
466
|
# node's ID (Asterism.current_call.from) to the method as that keyword,
|
|
429
467
|
# and a call that brings the keyword itself is refused (an "arguments"
|
|
430
468
|
# error), so the value is always the one Asterism took from the call.
|
|
431
|
-
def self.expose(name, obj, methods:, caller: nil)
|
|
469
|
+
def self.expose(name, obj, methods:, caller: nil, doc: nil)
|
|
432
470
|
s = session!
|
|
433
471
|
name = check_name("object", name)
|
|
434
472
|
table = {}
|
|
473
|
+
info = {}
|
|
435
474
|
if methods.is_a?(Hash)
|
|
436
|
-
methods.each
|
|
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
|
|
437
484
|
else
|
|
438
485
|
methods.each { |m| table[m.to_s] = -1 }
|
|
439
486
|
end
|
|
440
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
|
|
441
492
|
table.each_key do |m|
|
|
442
493
|
raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
|
|
443
494
|
end
|
|
@@ -451,12 +502,60 @@ module Asterism
|
|
|
451
502
|
end
|
|
452
503
|
unexpose(name)
|
|
453
504
|
token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
|
|
454
|
-
@objects[name] = [obj, table, token, kw]
|
|
505
|
+
@objects[name] = [obj, table, token, kw, info, doc]
|
|
455
506
|
"#{@node}/#{@app}/#{name}"
|
|
456
507
|
rescue Asterism::Zenoh::Error => e
|
|
457
508
|
raise Disconnected, e.message
|
|
458
509
|
end
|
|
459
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
|
+
|
|
460
559
|
def self.unexpose(name)
|
|
461
560
|
entry = @objects ? @objects.delete(name.to_s) : nil
|
|
462
561
|
entry[2].close if entry
|
|
@@ -517,6 +616,88 @@ module Asterism
|
|
|
517
616
|
self
|
|
518
617
|
end
|
|
519
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
|
+
|
|
520
701
|
# Compares the nodes alive now with those told before and calls the
|
|
521
702
|
# blocks for the difference.
|
|
522
703
|
# @api private
|
|
@@ -527,6 +708,7 @@ module Asterism
|
|
|
527
708
|
# the connection is neither new nor lost): nothing can have changed.
|
|
528
709
|
return unless @nodes_changed
|
|
529
710
|
@nodes_changed = false
|
|
711
|
+
tell_apps
|
|
530
712
|
now = []
|
|
531
713
|
now_set = {}
|
|
532
714
|
if @session
|
|
@@ -580,10 +762,15 @@ module Asterism
|
|
|
580
762
|
end
|
|
581
763
|
end
|
|
582
764
|
|
|
583
|
-
# One block for one node (CRuby routes what it
|
|
765
|
+
# One block for one node, or one application (CRuby routes what it
|
|
766
|
+
# raises to on_error).
|
|
584
767
|
# @api private
|
|
585
|
-
def self.tell(_where, blk, node)
|
|
586
|
-
|
|
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
|
|
587
774
|
end
|
|
588
775
|
|
|
589
776
|
# @api private
|
|
@@ -594,13 +781,36 @@ module Asterism
|
|
|
594
781
|
lost("the connection was lost")
|
|
595
782
|
return false
|
|
596
783
|
end
|
|
784
|
+
back_again(s)
|
|
597
785
|
answer_calls
|
|
598
786
|
answer_profile
|
|
787
|
+
answer_status
|
|
599
788
|
follow_liveliness
|
|
600
789
|
!@session.nil?
|
|
601
790
|
rescue Asterism::Zenoh::Error => e
|
|
602
|
-
|
|
603
|
-
|
|
791
|
+
lost_if_closed(e.message)
|
|
792
|
+
!@session.nil?
|
|
793
|
+
end
|
|
794
|
+
|
|
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).
|
|
797
|
+
# @api private
|
|
798
|
+
def self.back_again(s)
|
|
799
|
+
now = s.respond_to?(:reconnecting?) && s.reconnecting?
|
|
800
|
+
was = @was_reconnecting
|
|
801
|
+
@was_reconnecting = now
|
|
802
|
+
return unless was && !now
|
|
803
|
+
tell_profile
|
|
804
|
+
tell_status(true)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# A Zenoh error during a call or a poll: the connection is gone only when
|
|
808
|
+
# the session says it is closed. While the binding reconnects, the
|
|
809
|
+
# session (and everything declared on it) stays.
|
|
810
|
+
# @api private
|
|
811
|
+
def self.lost_if_closed(reason)
|
|
812
|
+
s = @session
|
|
813
|
+
lost(reason) if s.nil? || s.closed?
|
|
604
814
|
end
|
|
605
815
|
|
|
606
816
|
# Polls until the block is true. Answers incoming calls meanwhile.
|
|
@@ -764,7 +974,7 @@ module Asterism
|
|
|
764
974
|
# The caller's node ID is the key's last chunk (docs/protocol.md 4.1).
|
|
765
975
|
g = s.get("#{ROOT}/#{path}/call/#{@node}", timeout_ms: timeout_ms, payload: payload)
|
|
766
976
|
rescue Asterism::Zenoh::Error => e
|
|
767
|
-
|
|
977
|
+
lost_if_closed(e.message)
|
|
768
978
|
raise Disconnected, e.message
|
|
769
979
|
end
|
|
770
980
|
Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
|
|
@@ -797,7 +1007,7 @@ module Asterism
|
|
|
797
1007
|
begin
|
|
798
1008
|
g = session!.get("#{ROOT}/#{path}/meta", timeout_ms: timeout_ms)
|
|
799
1009
|
rescue Asterism::Zenoh::Error => e
|
|
800
|
-
|
|
1010
|
+
lost_if_closed(e.message)
|
|
801
1011
|
raise Disconnected, e.message
|
|
802
1012
|
end
|
|
803
1013
|
f = Future.new(path, "meta", g, now_ms + timeout_ms, timeout_ms)
|
|
@@ -889,8 +1099,16 @@ module Asterism
|
|
|
889
1099
|
entry = @objects[name]
|
|
890
1100
|
return nil unless entry
|
|
891
1101
|
list = []
|
|
892
|
-
entry[
|
|
893
|
-
|
|
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
|
|
894
1112
|
end
|
|
895
1113
|
|
|
896
1114
|
MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# The credentials of one node or router as one file: the .tar an Asterism
|
|
2
|
+
# console hands out when it issues a certificate ("Save all (.tar)" on the
|
|
3
|
+
# certificate page; the archive is made in the browser that made the key).
|
|
4
|
+
# It holds, at its top or in one directory:
|
|
5
|
+
#
|
|
6
|
+
# <name>.key the private key (PKCS#8 PEM)
|
|
7
|
+
# <name>.pem the certificate (its common name is <name>)
|
|
8
|
+
# ca.pem the CA that signed it (and the router's certificate)
|
|
9
|
+
# zenoh.json5 an example configuration (for zenohd or another client)
|
|
10
|
+
# asterism.json {"name": ..., "router": "tls/host:port", "not_after": ..., ...}
|
|
11
|
+
#
|
|
12
|
+
# Asterism.connect(credentials: "/home/asterism/alice.tar", app: "game")
|
|
13
|
+
#
|
|
14
|
+
# The certificates go to zenoh as base64 (transport/link/tls/*_base64) with
|
|
15
|
+
# enable_mtls, and the router's locator and the node ID come from
|
|
16
|
+
# asterism.json unless given. This is the portable part, in plain Ruby for
|
|
17
|
+
# the boards (mruby / PicoRuby: no Regexp, no pack, no File.binread): the
|
|
18
|
+
# file is read whole and the tar taken apart with byteslice / getbyte.
|
|
19
|
+
# CRuby replaces the reading and adds checks (lib/asterism/cruby/credentials.rb:
|
|
20
|
+
# the standard library's tar reader, file modes, the key pair).
|
|
21
|
+
module Asterism
|
|
22
|
+
class Credentials
|
|
23
|
+
# What a credentials file is missing or holds wrong.
|
|
24
|
+
class Error < ::Asterism::Error; end
|
|
25
|
+
|
|
26
|
+
TLS = "transport/link/tls"
|
|
27
|
+
# The largest file read (the real ones are a few KB).
|
|
28
|
+
MAX_SIZE = 1024 * 1024
|
|
29
|
+
BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
30
|
+
|
|
31
|
+
attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
|
|
32
|
+
|
|
33
|
+
# Reads path (a String of a .tar). warn: false skips the check of who
|
|
34
|
+
# may read the file (CRuby; the boards have no file modes).
|
|
35
|
+
def self.load(path, warn: true)
|
|
36
|
+
new(path, warn: warn)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A credentials file (a path or a Credentials) and the arguments of a
|
|
40
|
+
# connection -> [locator, config Hash, credentials]. The locator given
|
|
41
|
+
# wins over asterism.json's router; config given (a Hash) is laid over
|
|
42
|
+
# the TLS keys. @api private
|
|
43
|
+
def self.merge(credentials, locator, config)
|
|
44
|
+
creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
|
|
45
|
+
if config.is_a?(String)
|
|
46
|
+
raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
|
|
47
|
+
end
|
|
48
|
+
merged = creds.zenoh_config
|
|
49
|
+
(config || {}).each { |k, v| merged[k] = v }
|
|
50
|
+
[locator || creds.router, merged, creds]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# {basename => content} of the regular files of a ustar archive (data,
|
|
54
|
+
# a String of its bytes), at its top or in one directory. @api private
|
|
55
|
+
def self.untar(data, path = "the file")
|
|
56
|
+
files = {}
|
|
57
|
+
pos = 0
|
|
58
|
+
total = data.bytesize
|
|
59
|
+
while pos + 512 <= total
|
|
60
|
+
break if data.getbyte(pos) == 0 # the end: blocks of zeros
|
|
61
|
+
name = field(data, pos, 100)
|
|
62
|
+
prefix = field(data, pos + 345, 155)
|
|
63
|
+
size_text = field(data, pos + 124, 12).strip
|
|
64
|
+
type = data.getbyte(pos + 156)
|
|
65
|
+
size = size_text.empty? ? 0 : size_text.to_i(8)
|
|
66
|
+
pos += 512
|
|
67
|
+
raise Error, "#{path}: not a tar file (a member runs past the end)" if pos + size > total
|
|
68
|
+
if type == 48 || type == 0 # '0' or NUL: a regular file
|
|
69
|
+
full = prefix.empty? ? name : "#{prefix}/#{name}"
|
|
70
|
+
base = full.split("/").last.to_s
|
|
71
|
+
raise Error, "#{path}: #{base} is in it twice" if files.key?(base)
|
|
72
|
+
files[base] = data.byteslice(pos, size) unless base.empty?
|
|
73
|
+
end
|
|
74
|
+
pos += (size + 511) / 512 * 512
|
|
75
|
+
end
|
|
76
|
+
raise Error, "#{path}: not a tar file with credentials in it" if files.empty?
|
|
77
|
+
files
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The text of a NUL-terminated header field. @api private
|
|
81
|
+
def self.field(data, pos, len)
|
|
82
|
+
n = 0
|
|
83
|
+
n += 1 while n < len && data.getbyte(pos + n) != 0
|
|
84
|
+
data.byteslice(pos, n).to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# base64 of s without line breaks (CRuby's [s].pack("m0")). @api private
|
|
88
|
+
def self.base64(s)
|
|
89
|
+
out = ""
|
|
90
|
+
n = s.bytesize
|
|
91
|
+
i = 0
|
|
92
|
+
while i < n
|
|
93
|
+
b0 = s.getbyte(i)
|
|
94
|
+
b1 = i + 1 < n ? s.getbyte(i + 1) : 0
|
|
95
|
+
b2 = i + 2 < n ? s.getbyte(i + 2) : 0
|
|
96
|
+
v = (b0 << 16) | (b1 << 8) | b2
|
|
97
|
+
out << BASE64[(v >> 18) & 63] << BASE64[(v >> 12) & 63]
|
|
98
|
+
out << (i + 1 < n ? BASE64[(v >> 6) & 63] : "=")
|
|
99
|
+
out << (i + 2 < n ? BASE64[v & 63] : "=")
|
|
100
|
+
i += 3
|
|
101
|
+
end
|
|
102
|
+
out
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The string fields of asterism.json without a JSON library (only for
|
|
106
|
+
# a build that has none). @api private
|
|
107
|
+
def self.json_strings(text)
|
|
108
|
+
h = {}
|
|
109
|
+
%w[format name kind router serial not_after fingerprint console issued_at].each do |k|
|
|
110
|
+
key = "\"#{k}\""
|
|
111
|
+
i = text.index(key)
|
|
112
|
+
next unless i
|
|
113
|
+
rest = text[i + key.size, text.size - i - key.size].to_s
|
|
114
|
+
colon = rest.index(":")
|
|
115
|
+
next unless colon
|
|
116
|
+
q1 = colon + 1
|
|
117
|
+
q1 += 1 while rest[q1] == " "
|
|
118
|
+
next unless rest[q1] == "\""
|
|
119
|
+
q2 = rest.index("\"", q1 + 1)
|
|
120
|
+
next unless q2
|
|
121
|
+
h[k] = rest[q1 + 1, q2 - q1 - 1]
|
|
122
|
+
end
|
|
123
|
+
h
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def initialize(path, warn: true)
|
|
127
|
+
@path = path.to_s
|
|
128
|
+
check_mode if warn
|
|
129
|
+
files = read_tar
|
|
130
|
+
@info = parse_info(files["asterism.json"])
|
|
131
|
+
keys = files.keys.select { |f| f.end_with?(".key") }
|
|
132
|
+
raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
|
|
133
|
+
raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
|
|
134
|
+
@name = @info["name"].to_s
|
|
135
|
+
@name = keys.first[0, keys.first.size - 4] if @name.empty?
|
|
136
|
+
@key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
|
|
137
|
+
@cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
|
|
138
|
+
@ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
|
|
139
|
+
@kind = @info["kind"]
|
|
140
|
+
@router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
|
|
141
|
+
@not_after = @info["not_after"]
|
|
142
|
+
check_pair
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The zenoh configuration keys (a Hash for Session.open's config:):
|
|
146
|
+
# the CA, the certificate and the key as base64, and mutual TLS.
|
|
147
|
+
def zenoh_config
|
|
148
|
+
{
|
|
149
|
+
"#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
|
|
150
|
+
"#{TLS}/connect_certificate_base64" => b64(@cert_pem),
|
|
151
|
+
"#{TLS}/connect_private_key_base64" => b64(@key_pem),
|
|
152
|
+
"#{TLS}/enable_mtls" => true
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inspect
|
|
157
|
+
"#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
|
|
158
|
+
end
|
|
159
|
+
alias to_s inspect
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def b64(pem)
|
|
164
|
+
Credentials.base64(pem)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The boards have no file modes (CRuby warns like ssh).
|
|
168
|
+
def check_mode; end
|
|
169
|
+
|
|
170
|
+
# The key pair is checked by CRuby (openssl); on the boards the router
|
|
171
|
+
# refuses a wrong one.
|
|
172
|
+
def check_pair; end
|
|
173
|
+
|
|
174
|
+
def read_tar
|
|
175
|
+
data = begin
|
|
176
|
+
::File.open(@path, "r") { |f| f.read }
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
raise Error, "#{@path}: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
raise Error, "#{@path}: cannot be read" if data.nil?
|
|
181
|
+
raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
|
|
182
|
+
Credentials.untar(data, @path)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def parse_info(text)
|
|
186
|
+
return {} if text.nil?
|
|
187
|
+
return Credentials.json_strings(text) unless Object.const_defined?(:JSON)
|
|
188
|
+
h = ::JSON.parse(text)
|
|
189
|
+
raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
|
|
190
|
+
h
|
|
191
|
+
rescue Error
|
|
192
|
+
raise
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
raise Error, "#{@path}: asterism.json: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/mrblib/profile.rb
CHANGED
|
@@ -282,7 +282,7 @@ module Asterism
|
|
|
282
282
|
begin
|
|
283
283
|
g = session!.get("#{ROOT}/#{path}", timeout_ms: ms, params: icon ? nil : "icon=0")
|
|
284
284
|
rescue Asterism::Zenoh::Error => e
|
|
285
|
-
|
|
285
|
+
lost_if_closed(e.message)
|
|
286
286
|
raise Disconnected, e.message
|
|
287
287
|
end
|
|
288
288
|
Future.new(path, "profile", g, now_ms + ms, ms)
|