asterism 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba14899dd37f9c4c21e148a2256d71545785da6f61a1f8f6701e2d0d189e9061
4
- data.tar.gz: 207e08ed177ffa93cd5c7173a84c31f519f59c8e076c6c641ccaa8db27712ea9
3
+ metadata.gz: d729d6c233d712c647e7fb1168f2587fa0e81eb1b176a11f12ac7c349f61a592
4
+ data.tar.gz: fce6e1102b0e4e2c404adf28a299c77efd548f6a07e92dc81a7b54a9efe1951e
5
5
  SHA512:
6
- metadata.gz: de0f85b8c36d4b3c991603004f151ea68952bd7fcc7fdbc76b992af80505b45be3612c5975e509e4f1415f553db8d91ea94940817c00768705bdbf1a011d45e6
7
- data.tar.gz: df7634a6c9453fb647c0f148199ff056c646451e3c839ac5cf02704db0ee7dfa7508d8aabdd9fde8005b8909dd6c7217f2d2b96a6e066ac6895d39d9a2af9481
6
+ metadata.gz: c2050d9890925ad4b0910f3e8063a7d2b6e9ef1e8f68ea3346bea26c02674cce7a6b0c2ee798d2940f2171d7c763c49dd8160a44b3d3204b13ccea813936430c
7
+ data.tar.gz: 862531b730d5bc6f00c673271fb4d1d8db6ecaaab11969a4be95234c09be4ba6eceed245b24102f490fda6608f946b5d30e8ce3b960255b197b95bcd5a4aa550
data/CHANGELOG.md CHANGED
@@ -6,6 +6,100 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
6
6
  bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
7
7
  version (a patch release of one may come without the other).
8
8
 
9
+ ## 0.7.1
10
+
11
+ Needs asterism-zenoh 0.4.1 or later (`Session#reconnecting?`).
12
+
13
+
14
+ - `credentials:` on CRuby (`Asterism.connect`, `Asterism::Zenoh.open`,
15
+ `Asterism::ROS.connect`): the one `.tar` an Asterism console's
16
+ certificate page saves (key, certificate, CA, `asterism.json`). It is read
17
+ in memory (`Asterism::Credentials`, no temporary files) and handed to
18
+ zenoh as `transport/link/tls/*_base64` with `enable_mtls`; the locator and
19
+ `node:` default to the router and the name in `asterism.json` (given ones
20
+ win). A tar the group or others may read is warned about. `rake
21
+ test:credentials` (docker) connects two nodes to a mutual-TLS zenohd with
22
+ a tar each.
23
+
24
+ - A client of a router keeps its connection across a router restart: the
25
+ bindings reopen it and declare everything again, and Asterism no longer
26
+ closes the connection on a Zenoh error while the session is reconnecting.
27
+ `Asterism.reconnecting?` (and `net.reconnecting?`) is true meanwhile; the
28
+ profile is told again once it is back. `rake test:reconnect` (docker)
29
+ restarts a zenohd under a CRuby and an mruby node.
30
+
31
+ ## 0.7.0
32
+
33
+ **This release breaks compatibility: it speaks wire protocol 1 only.** A
34
+ node of 0.7.0 and a node of 0.6.0 or earlier (protocol 0) do not see each
35
+ other's objects, and a call between them fails: a protocol 0 call to a
36
+ 0.7.0 node is refused with the error kind `protocol`, a 0.7.0 call to an
37
+ older node is not answered, and their replies are malformed answers to
38
+ each other. Move every node of a network to 0.7.0 together (boards,
39
+ browsers, consoles, CRuby programs). The Ruby API stays as it was, with
40
+ the additions below. Needs asterism-zenoh / picoruby-asterism-zenoh 0.4.0
41
+ (unchanged), and on mruby a MessagePack that reads and writes bin (README,
42
+ "Using it").
43
+
44
+ Protocol 1 (`docs/protocol.md`, normative; `docs/protocol_review.md` says
45
+ what was decided and why):
46
+
47
+ - A call is a get on `asterism/<node>/<app>/<object>/call/<caller>`, where
48
+ `<caller>` is the calling node's ID. Its payload is a map
49
+ `{"method" => name, "args" => [...], "kwargs" => {...}}` (args and
50
+ kwargs left out when empty) instead of the array `[method, args,
51
+ kwargs]`.
52
+ - A reply is `{"value" => value}` or `{"error" => {"kind" => ..., "class"
53
+ => ..., "message" => ...}}` instead of `["ok", value]` /
54
+ `["error", class, message]`. The kinds: `raised`, `not_exposed`,
55
+ `no_object`, `arguments`, `malformed`, `unsendable`, `nesting`,
56
+ `protocol`.
57
+ - The meta is `{"methods" => [{"name" => name, "arity" => n}, ...]}`.
58
+ - Binary Strings go as MessagePack bin (CRuby: ASCII-8BIT Strings, no more
59
+ compatibility mode; mruby: `String#b`), and a bin arrives as a binary
60
+ String. The profile's icon is a bin.
61
+ - The profile has `"protocol" => 1`.
62
+ - Each application declares its own liveliness token
63
+ `asterism/<node>/<app>`; the node token `asterism/<node>`, which the
64
+ applications of a node shared, is gone. A node is present while any of
65
+ its applications or objects is.
66
+ - A payload is exactly one value: bytes after it, ext types and integers
67
+ outside the signed 64-bit range make it malformed. CRuby raises
68
+ `EncodeError` before sending an Integer outside int64 (it raised
69
+ `RangeError` before).
70
+ - A protocol 0 call (on the five-chunk `.../call` key) is answered with the
71
+ error kind `protocol`.
72
+
73
+ Added
74
+
75
+ - `Asterism.current_call` while an exposed method runs for a call (per
76
+ thread on CRuby, `net.current_call` too): `from` (the calling node),
77
+ `verified` (`:claimed`; `:local` for a call from the same application),
78
+ `object`, `method`. `Asterism::CurrentCall`.
79
+ - `Asterism.expose(name, obj, methods:, caller: :from)` (and
80
+ `net.expose`): passes the calling node's ID as the keyword `from:` and
81
+ refuses a call that brings `from:` itself.
82
+ - `RemoteError#remote_kind`: the error's kind, as above (nil when the
83
+ answer did not say).
84
+ - `proxy.asterism_call(name, *args, **kw)` and `proxy.async.asterism_call`:
85
+ a call by the method's name, for names a proxy cannot forward (`display`,
86
+ `hash`, `class`, `fail`, ...) and names that are not Ruby identifiers.
87
+ - `Asterism::PROTOCOL` (1).
88
+ - The conformance tests (`test/protocol/`) are protocol 1's: 55 values, 54
89
+ incoming queries, 24 outgoing steps; CRuby and mruby pass them all.
90
+
91
+ Changed
92
+
93
+ - `proxy.asterism_meta` returns the new meta form.
94
+ - `Asterism.connect`'s duplicate check also finds a copy of the
95
+ application that exposes nothing (it has its application token now).
96
+ - `host_test/msgpack.rb` (the mruby test's MessagePack) writes a binary
97
+ String as bin and reads a bin as one; the mruby test build adds
98
+ mruby-encoding (to tell a binary String).
99
+ - The documentation of the wire protocol (docs/protocol.md,
100
+ docs/protocol_review.md) and the conformance tests (X0, unreleased in
101
+ 0.6.0) are rewritten for protocol 1.
102
+
9
103
  ## 0.6.0
10
104
 
11
105
  One connection for everything and a lighter `Asterism.poll`; every 0.5.x
data/README.md CHANGED
@@ -14,6 +14,7 @@ repository holds the pure Ruby layers, written once for every Ruby:
14
14
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0; tf2_msgs BSD-3-Clause; see NOTICE) |
15
15
  | `tools/` | The type generator `asterism_msggen.rb`, the Jazzy definitions it reads, `ros2_types.rb` (refreshes them from a ROS 2 image) |
16
16
  | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_session.rb` (`Asterism.session`, `on_join` / `on_leave`), `test_api_*.rb` (the CRuby API), `bench_poll.rb` (`rake bench:poll`) |
17
+ | `test/protocol/` | the conformance samples of the wire protocol (`samples.json`), the driver that checks an implementation against them, and the Ruby node it checks (`rake test:protocol`, `rake test:protocol_mruby`; [docs/protocol.md](docs/protocol.md)) |
17
18
  | `host_test/` | `rake test:mruby`: the shared layer on mruby (profiles between two mruby processes; `Asterism.session`, `on_join` / `on_leave` and the time of a poll against `peer.rb`) |
18
19
  | `examples/` | CRuby: `node.rb` (objects with a board), `node_polled.rb` (the same with the portable API of the boards), `ros2_talker.rb` (topics, a timer, services with ROS 2), `ros2_rover.rb` (drive a ROS 2 robot: `/cmd_vel`, `/odom`, `/imu`), `zenoh.rb` (plain Zenoh), `mujoco/` (MuJoCo run from CRuby through Fiddle, its robot exposed as Asterism objects; see its README) |
19
20
 
@@ -42,7 +43,15 @@ profiles ([Profiles](#profiles): what a node runs on, where, a name and an
42
43
  icon); 0.6.0 adds `Asterism.session`
43
44
  ([One connection](#one-connection-asterismsession): the object layer's
44
45
  session for plain Zenoh and ROS 2 too) and makes `Asterism.poll` cheap when
45
- nothing changed.
46
+ nothing changed. **0.7.0 speaks wire protocol 1 only**
47
+ ([docs/protocol.md](docs/protocol.md)): it does not talk to the object
48
+ layer of earlier versions, so every node of a network moves to 0.7.0
49
+ together. It adds who called (`Asterism.current_call`, `expose(...,
50
+ caller: :from)`; [Who called](#who-called)), the kind of a remote error
51
+ (`RemoteError#remote_kind`) and `proxy.asterism_call`.
52
+
53
+ A mechanism-by-mechanism comparison with dRuby and Rinda is in
54
+ [docs/comparison.md](docs/comparison.md).
46
55
 
47
56
  ## Which API?
48
57
 
@@ -78,7 +87,7 @@ time is taken, on both APIs and every Ruby; giving one twice raises
78
87
  CRuby (needs CRuby 3.2+ and a C compiler):
79
88
 
80
89
  ```
81
- gem install asterism # also installs asterism-zenoh (~> 0.4.0) and msgpack
90
+ gem install asterism # also installs asterism-zenoh (>= 0.4.1, < 1.0) and msgpack
82
91
  ruby -e 'require "asterism"'
83
92
  ```
84
93
 
@@ -91,7 +100,7 @@ build but not yet tested. To install from the repositories instead:
91
100
 
92
101
  ```
93
102
  (cd ../asterism-zenoh && rake gem) && rake gem
94
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.6.0.gem
103
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.7.1.gem
95
104
  ```
96
105
 
97
106
  From the working trees, without installing:
@@ -101,12 +110,14 @@ ruby -I lib -I ../asterism-zenoh/lib your_script.rb # after `rake compile` in
101
110
  ```
102
111
 
103
112
  `Asterism::ROS::TYPE_PATH` is `data/msgs` there; add your own directories.
104
- MessagePack sends every String as str (compatibility mode), as the boards
105
- do: their MessagePack does not read the bin type.
113
+ MessagePack sends a binary (ASCII-8BIT) String as bin and any other String
114
+ as str (protocol 1; [Values](#values)).
106
115
 
107
116
  mruby / PicoRuby: add this repository and picoruby-asterism-zenoh as gems
108
117
  (`conf.gem "path/to/asterism"`; a `MessagePack` module with `pack` /
109
- `unpack` is needed too), and put `data/msgs` on the device's file system at
118
+ `unpack` is needed too: for protocol 1 it must write a binary String
119
+ (`String#b`) as bin and read a bin as one, and refuse ext types, integers
120
+ outside int64 and bytes after the value), and put `data/msgs` on the device's file system at
110
121
  `/usr/share/asterism/msgs` (or add the place to
111
122
  `Asterism::ROS::TYPE_PATH`). Family mruby does this from pinned commits of
112
123
  these repositories.
@@ -120,6 +131,8 @@ rake test:objects # objects and Asterism::ROS between CRuby sessions over a
120
131
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
121
132
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
122
133
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
134
+ rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
135
+ rake test:protocol_mruby # the same driver against mruby (builds mruby; not part of `rake`)
123
136
  rake bench:poll # the time and allocations of one Asterism.poll by nodes and objects (prints a table)
124
137
  ```
125
138
 
@@ -251,7 +264,8 @@ end
251
264
  |---|---|
252
265
  | `Asterism.connect(...) { \|net\| }` | yields an `Asterism::Net`, `Asterism.close` when the block ends; returns the block's value. Without a block, as before |
253
266
  | `Asterism.net` | the `Net` of the connection made without a block (nil when not connected) |
254
- | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose`. The methods run on the receiving thread, in `run`, or in whatever polls |
267
+ | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose` (also `caller: :from`). The methods run on the receiving thread, in `run`, or in whatever polls |
268
+ | `net.current_call` | `Asterism.current_call`: the call being answered on this thread (the receiving thread while it answers), or nil |
255
269
  | `net[path, timeout: 2.0]` | a proxy (`timeout` in seconds, or `timeout_ms:`) |
256
270
  | `net.each(pattern = "**")` | the proxies alive now; an Enumerator without a block. With a block it returns their number (1.0: `self`, as Ruby's `each` does; `net.count` is the number) |
257
271
  | `net.on_join { \|node\| }` / `net.on_leave { \|node\| }` | `Asterism.on_join` / `on_leave` of the portable API (the same blocks, told from `Asterism.poll`), here on the receiving thread; what they raise goes to `on_error`. `net.off_join(proc)` / `off_leave(proc)` remove one |
@@ -279,6 +293,28 @@ Asterism.connect("tls/router.example.org:7448", node: "cruby", app: "demo", conf
279
293
  end
280
294
  ```
281
295
 
296
+ With an Asterism console, the certificate page saves everything as one
297
+ file ("Save all (.tar)": the key, the certificate, the CA, an example
298
+ configuration and `asterism.json` with the name and the router). Connect
299
+ with that one file:
300
+
301
+ ```ruby
302
+ Asterism.connect(credentials: "alice.tar", app: "game") do |net|
303
+ net["fmruby-aaaaaa/game/board"].state
304
+ end
305
+ Asterism::Zenoh.open(credentials: "alice.tar") { |s| s.put("asterism/alice/game/hello", "hi") }
306
+ Asterism::ROS.connect(credentials: "alice.tar") { |ros| ros.node("ruby_talker") }
307
+ ```
308
+
309
+ The gem reads the tar in memory (nothing is written; `Asterism::Credentials`)
310
+ and gives zenoh the CA, the certificate and the key as
311
+ `transport/link/tls/root_ca_certificate_base64`,
312
+ `connect_certificate_base64` and `connect_private_key_base64`, with
313
+ `enable_mtls`. The locator defaults to the router in `asterism.json` and
314
+ `node:` to the certificate's name; give either to use another. `config:`
315
+ (a Hash) is laid over these keys. A tar that the group or others may read
316
+ is warned about, as ssh does with a key: `chmod 600 alice.tar`.
317
+
282
318
  The router checks the certificate against its CA, and its ACL can tell the
283
319
  nodes apart by the certificate's common name. The name in the locator must
284
320
  be one the router's certificate is valid for (zenoh checks it; it is
@@ -416,21 +452,24 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
416
452
 
417
453
  | Call | Returns | Raises / notes |
418
454
  |---|---|---|
419
- | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5)` | `Asterism` | `locator`, `mode:`, `listen:` (and `config:` / `connect_timeout:` when given, CRuby only: TLS and the rest of zenoh's configuration; the boards raise `ArgumentError`) go to `Asterism::Zenoh::Session.open` (client of a router by default; `mode: :peer` with or without `listen:` without one). `node:` is this machine's ID, `app:` the application's name (one key chunk each: no `/ * $ ? #`, not starting with `@`; else `ArgumentError`). `Disconnected` when it cannot connect; `Error` when objects of the same `<node>/<app>` are already alive (a second copy of the application), or when already connected. Waits up to `check_timeout:` seconds for that check (a router answers at once; give more over a slow relay). Returns the module itself: on a board the module is the connection (one per application). The default is plain text without authentication; see [Over TLS](#over-tls-a-router-on-the-internet). |
420
- | `Asterism.expose(name, obj, methods:)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any). `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. |
455
+ | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil, check_timeout: 1.5)` (CRuby also `credentials:`, see [Over TLS](#over-tls-a-router-on-the-internet)) | `Asterism` | `locator`, `mode:`, `listen:` (and `config:` / `connect_timeout:` when given, CRuby only: TLS and the rest of zenoh's configuration; the boards raise `ArgumentError`) go to `Asterism::Zenoh::Session.open` (client of a router by default; `mode: :peer` with or without `listen:` without one). `node:` is this machine's ID, `app:` the application's name (one key chunk each: no `/ * $ ? #`, not starting with `@`; else `ArgumentError`). `Disconnected` when it cannot connect; `Error` when objects of the same `<node>/<app>` are already alive (a second copy of the application), or when already connected. Waits up to `check_timeout:` seconds for that check (a router answers at once; give more over a slow relay). Returns the module itself: on a board the module is the connection (one per application). The default is plain text without authentication; see [Over TLS](#over-tls-a-router-on-the-internet). |
456
+ | `Asterism.expose(name, obj, methods:, caller: nil)` | `"<node>/<app>/<name>"` | `methods:` is an Array of names, or a Hash `name => number of arguments` (checked before the call; `-1` or the Array form: any). `ArgumentError` when `obj` has no such public method. Exposing a name again replaces it. `caller: :from` passes the calling node's ID to every method as the keyword `from:` and refuses a call that brings `from:` itself ([Who called](#who-called)). |
457
+ | `Asterism.current_call` | `Asterism::CurrentCall` or nil | While an exposed method runs for a call: `from` (the calling node's ID), `verified` (`:claimed`, or `:local` for a call from this application), `object`, `method`. nil outside a call ([Who called](#who-called)). |
421
458
  | `Asterism.unexpose(name)` | true / false | |
422
459
  | `Asterism.poll` | true / false | Call from the update loop: polls Zenoh, answers the calls that came in, follows who is alive and calls the `on_join` / `on_leave` blocks. `false` once the connection is closed or lost. Cheap when nothing came (0.6.0): each queue is looked at by its count, and the list of nodes is made again only after a liveliness change. |
423
- | `Asterism.on_join { \|node\| }` / `Asterism.on_leave { \|node\| }` | `Asterism` | Another node appeared (its node token or one of its objects) / is gone (no token and no object left). The nodes there already join on the first polls after connecting; when the connection is lost, every node known until then leaves (on the next `Asterism.poll`). The blocks run from `Asterism.poll` only, never from the polling inside a waiting call; what they raise comes out of `Asterism.poll`. Each call adds a block; `Asterism.off_join(proc)` / `off_leave(proc)` remove one; `Asterism.close` forgets them (a lost connection does not). |
460
+ | `Asterism.on_join { \|node\| }` / `Asterism.on_leave { \|node\| }` | `Asterism` | Another node appeared (one of its applications or objects) / is gone (no application and no object left). The nodes there already join on the first polls after connecting; when the connection is lost, every node known until then leaves (on the next `Asterism.poll`). The blocks run from `Asterism.poll` only, never from the polling inside a waiting call; what they raise comes out of `Asterism.poll`. Each call adds a block; `Asterism.off_join(proc)` / `off_leave(proc)` remove one; `Asterism.close` forgets them (a lost connection does not). |
424
461
  | `Asterism[path, timeout: 2.0]` | `Proxy` | `path` is `<node>/<app>/<object>` without wildcards (`ArgumentError`). `timeout:` (seconds) or `timeout_ms:` is the time limit of its calls. Nothing is sent until a method is called. |
425
- | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (the proxy's time limit). `RemoteError` when it raised there or could not be called (not exposed: `NoMethodError`; wrong number of arguments: `ArgumentError`; no such object: `NameError`), `TimeoutError` when no answer came in time (also at once when nobody answers that key), `Disconnected`, `EncodeError` (before anything is sent) for a value MessagePack cannot carry. A block cannot be sent (`ArgumentError`). |
462
+ | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (the proxy's time limit). `RemoteError` when it raised there or could not be called (`remote_kind` says which: `raised`, or not exposed `not_exposed` (`NoMethodError`), wrong number of arguments `arguments` (`ArgumentError`), no such object `no_object` (`NameError`); [Errors](#errors)), `TimeoutError` when no answer came in time (also at once when nobody answers that key), `Disconnected`, `EncodeError` (before anything is sent) for a value MessagePack cannot carry. A block cannot be sent (`ArgumentError`). |
463
+ | `proxy.asterism_call(name, *args, **kw)` | the remote return value | The same call by the method's name (a String or Symbol): for names a proxy cannot forward (those `Object` and `Kernel` have: `display`, `hash`, `class`, `fail`, ...) and names that are not Ruby identifiers. `proxy.async.asterism_call(...)` returns a `Future`. |
426
464
  | `proxy.async.<method>(...)` | `Future` | Sends and returns at once. `EncodeError` / `Disconnected` are raised here. |
427
465
  | `future.done?` | true / false | Never waits (`Asterism.poll` moves it on). True once the answer came, the time ran out or the connection closed. |
428
466
  | `future.value` | the return value | Waits (polling) if not done; raises like a waiting call. `future.took` (seconds) / `took_ms`: time to the answer. |
429
467
  | `proxy.respond_to?(name)` / `proxy.remote_methods` | true / false, `[Symbol]` | From the object's meta (fetched once, `proxy.asterism_refresh` forgets it). `respond_to?` is false when the object does not answer; `remote_methods` raises then. (`proxy.methods` returns the same with a deprecation warning; from 1.0 it is `Object#methods`.) |
430
- | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [[name, arity], ...]}` |
468
+ | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [{"name" => name, "arity" => n}, ...]}` |
431
469
  | `Asterism.each(pattern = "**") { \|proxy\| }` | count (Array without a block) | The exposed objects alive now (this application's own included) whose `<node>/<app>/<object>` matches; `*` is one chunk, `**` any number. 1.0 returns the proxies from the block form instead of their number (Ruby's `each` returns its receiver); write `Asterism.each(pattern).size` for the count. |
432
470
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
433
471
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
472
+ | `Asterism.reconnecting?` | true / false | True while the router is away and the binding opens the connection again ([When the connection is lost](#when-the-connection-is-lost)). |
434
473
  | `Asterism.session` | `Asterism::Zenoh::Session` | The connection's own session, for put / subscribe / a ROS 2 node on the same connection ([One connection](#one-connection-asterismsession)). The same object until the connection closes. `Disconnected` when not connected. Its `close` does nothing (warns once); `Asterism.close` closes it. |
435
474
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
436
475
  | `Asterism.profile(kind:, name:, host:, icon:)` / `Asterism.profile` | Hash | This node's profile: sets the fields given and returns it; without arguments, returns it. See [Profiles](#profiles). |
@@ -444,19 +483,20 @@ and an icon. A console draws it with that; anyone can ask.
444
483
  ```ruby
445
484
  Asterism.profile(name: "Rover", icon: File.binread("rover.png")) # this node's (any time)
446
485
  Asterism.profile
447
- # => {"kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
486
+ # => {"protocol" => 1, "kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
448
487
  Asterism.profile_of("fmruby-aaaaaa")
449
- # => {"kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
488
+ # => {"protocol" => 1, "kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
450
489
  Asterism.profile_of("fmruby-aaaaaa", icon: false) # the same without the icon bytes
451
490
  Asterism.profile_of("an-older-node") # => nil
452
491
  ```
453
492
 
454
493
  | Field | Value | Default |
455
494
  |---|---|---|
495
+ | `protocol` | set by Asterism, never given: the wire protocol the node speaks (`Asterism::PROTOCOL`, 1). A node whose profile has none speaks protocol 0 (asterism before 0.7.0), which 0.7.0 does not talk to | |
456
496
  | `kind` | what Ruby runs the node: `family_mruby`, `picoruby`, `cruby`, `console` (a lower-case word: `a-z`, `0-9`, `_`, up to 32 bytes; other words are allowed) | `cruby` on CRuby, `picoruby` on mruby / PicoRuby; Family mruby's firmware sets `family_mruby` |
457
497
  | `host` | where it runs, e.g. `atoms3r`, `tab5`, `p4nano`, `browser`, `sim` (up to 32 bytes) | none; Family mruby's firmware sets it from the board |
458
498
  | `name` | a display name (up to 64 bytes) | none |
459
- | `icon` | the bytes of a PNG: at most 8 KB and 256x256 (32x32 is the size meant). PNG only: an SVG can carry scripts | the file at `Asterism.default_icon_path` when it is there: on CRuby the gem's `data/icon.png` (a ruby), on the boards `/usr/share/asterism/icon.png` |
499
+ | `icon` | the bytes of a PNG (a binary String; sent as a MessagePack bin): at most 8 KB and 256x256 (32x32 is the size meant). PNG only: an SVG can carry scripts | the file at `Asterism.default_icon_path` when it is there: on CRuby the gem's `data/icon.png` (a ruby), on the boards `/usr/share/asterism/icon.png` |
460
500
  | `icon_hash` | set by Asterism, never given: the icon's summary (8 hex digits: the Adler-32 of the PNG's chunk CRCs, so a board computes it from a few bytes) | |
461
501
 
462
502
  - **Setting.** `Asterism.profile(**fields)` changes the fields given and keeps
@@ -536,7 +576,7 @@ end
536
576
  another. Your own `put` does not come back to your own subscriber on the
537
577
  same session.
538
578
  - **Keys.** Keep your keys out of the object layer's: `asterism/<node>/<app>/**`
539
- is answered by its queryable (`get` there is a call), `asterism/<node>` and
579
+ is answered by its queryable (`get` there is a call), `asterism/<node>/<app>` and
540
580
  `asterism/<node>/<app>/<object>` are its liveliness tokens. Putting under
541
581
  `asterism/<node>/<app>/<object>/<anything>` (a state, a log) is fine:
542
582
  puts and subscriptions do not meet queryables or liveliness.
@@ -553,12 +593,18 @@ Asterism::Error (the Zenoh bindings define it)
553
593
  ├── Asterism::TimeoutError (no answer in time; old name Asterism::Timeout)
554
594
  │ └── Asterism::ROS::TimeoutError (old name Asterism::ROS::Timeout)
555
595
  ├── Asterism::EncodeError
556
- ├── Asterism::RemoteError (remote_class, remote_message)
596
+ ├── Asterism::RemoteError (remote_kind, remote_class, remote_message)
557
597
  ├── Asterism::ROS::UnknownType
558
598
  ├── Asterism::CDR::DecodeError
559
599
  └── Asterism::DeprecationError (only with Asterism.deprecations = :raise)
560
600
  ```
561
601
 
602
+ `RemoteError#remote_kind` says what happened on the other side, as a word
603
+ of the protocol (docs/protocol.md 4.4): `raised` (the method raised;
604
+ `remote_class` is its class there), `not_exposed`, `no_object`,
605
+ `arguments`, `malformed`, `unsendable`, `nesting`, `protocol` (the other
606
+ side speaks a different protocol); nil when the answer did not say.
607
+
562
608
  The old names `Asterism::Timeout` and `Asterism::ROS::Timeout` still work
563
609
  (they warn once). Inside `module Asterism`, `Timeout` is Ruby's `Timeout`
564
610
  module again. The ROS layer raises `Zenoh::ClosedError` when its session
@@ -646,8 +692,21 @@ a field, as before.
646
692
 
647
693
  ### When the connection is lost
648
694
 
649
- Asterism follows `Asterism::Zenoh`: the connection is closed when the router
650
- (or the only peer) goes away, and is not reopened by itself. From then on
695
+ A client of a router keeps its connection when the router goes away for a
696
+ while (it restarts, the line drops): the Zenoh binding opens the connection
697
+ again by itself (zenoh-c on CRuby; zenoh-pico built with reconnection on
698
+ the boards) and declares again everything that was declared, so the
699
+ application token, the exposed objects, the liveliness watch and what the
700
+ application declared on `Asterism.session` are all back without the
701
+ application doing anything. Meanwhile `Asterism.reconnecting?` is true,
702
+ `connected?` stays true, the other nodes leave (`on_leave`) and join again
703
+ after it (`on_join`), calls raise `Asterism::Disconnected` or time out, and
704
+ puts reach nobody. The profile is told again once it is back. Measured
705
+ with zenohd 1.10.1 restarted by `docker restart` (`rake test:reconnect`,
706
+ CRuby and mruby): back about 1 s after the router.
707
+
708
+ A peer connection (or a build without reconnection) is closed when the
709
+ peer (or the router) goes away, and is not reopened by itself. From then on
651
710
  `Asterism.poll` returns `false`, `connected?` is false, `lost_reason` says
652
711
  why, and calls raise `Asterism::Disconnected`. **`Asterism::Zenoh::Error`
653
712
  never comes out of Asterism; it is always wrapped in `Disconnected`** (the
@@ -658,22 +717,71 @@ session is closed, its entities raise `Zenoh::ClosedError`).
658
717
 
659
718
  ## Values
660
719
 
661
- Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (64 bit),
662
- `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a `String` (also
663
- as a Hash key: keyword arguments arrive as Symbols again). Anything else in
664
- the arguments raises `EncodeError` before sending; in a return value the
665
- caller gets `RemoteError` (`Asterism::EncodeError`). Nesting is limited to
666
- 16 levels.
720
+ Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (signed
721
+ 64 bit), `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a
722
+ `String` and arrives as one (also as a Hash key; keyword names become
723
+ Symbols again). A binary String (`String#b`: ASCII-8BIT on CRuby, the
724
+ binary flag on mruby) goes as a MessagePack bin and arrives as a binary
725
+ String; any other String goes as text (str). Anything else in the
726
+ arguments, or an Integer outside the signed 64-bit range, raises
727
+ `EncodeError` before sending; in a return value the caller gets
728
+ `RemoteError` (`unsendable`). Nesting is limited to 16 levels.
729
+
730
+ - Hash keys: use String (or Symbol) and Integer keys for anything another
731
+ language reads (Python and JavaScript refuse others by default).
732
+ - Times: send an Integer of nanoseconds since the epoch (or name the unit:
733
+ `timeout_ms`); there is no time type.
734
+ - A keyword given as `nil` and one left out differ: the callee's default
735
+ applies only to the one left out.
736
+ - A JavaScript caller's `1.0` arrives as the Integer `1`.
737
+
738
+ ## Who called
739
+
740
+ The caller's node ID travels in the call's key (`.../call/<caller>`). An
741
+ exposed method can read it:
742
+
743
+ ```ruby
744
+ class Rover
745
+ def stop
746
+ c = Asterism.current_call # nil outside a call
747
+ log("stop from #{c.from}") # c.verified: :claimed (:local from this app)
748
+ end
749
+
750
+ def drive(v, w, from:) # caller: :from fills from: in
751
+ log("drive from #{from}")
752
+ end
753
+ end
754
+ Asterism.expose("rover", Rover.new, methods: { stop: 0 })
755
+ Asterism.expose("wheels", Rover.new, methods: { drive: 2 }, caller: :from)
756
+ ```
757
+
758
+ - `Asterism.current_call` is set only while a method runs for a call (per
759
+ thread on CRuby). Calls answered while that method waits for its own
760
+ call come on top of it and go when they end.
761
+ - With `caller: :from`, a call that passes `from:` itself is refused
762
+ (`RemoteError`, `remote_kind` `arguments`): `from` is always the one
763
+ Asterism took from the call.
764
+ - **It is a claim.** Any client can call with any name. A router's access
765
+ control makes it binding: with mutual TLS, zenohd's ACL can let each
766
+ certificate query only keys ending in its own name
767
+ (`asterism/*/*/*/call/<name>`; docs/protocol.md 4.7). Without such a
768
+ router, use `from` to avoid mistakes, not to decide who may do what.
667
769
 
668
770
  ## Keys and encoding
669
771
 
772
+ Wire protocol 1. The full description, for implementations in other
773
+ languages, is [docs/protocol.md](docs/protocol.md) (with the conformance
774
+ tests); why it has this shape (the review of protocol 0's Ruby-shaped
775
+ parts, and what was decided) is
776
+ [docs/protocol_review.md](docs/protocol_review.md).
777
+
670
778
  | Key | Zenoh | Payload |
671
779
  |---|---|---|
672
- | `asterism/<node>/<app>/<object>/call` | get / queryable | query: `[method, [args...], {kwargs}]`; reply: `["ok", value]` or `["error", class name, message]` |
673
- | `asterism/<node>/<app>/<object>/meta` | get / queryable | reply: `{"methods" => [[name, arity], ...]}` (arity `-1` when not declared). The object may be `*` in the query: one reply per object |
674
- | `asterism/<node>` | liveliness | the node is connected |
780
+ | `asterism/<node>/<app>/<object>/call/<caller>` | get / queryable | query: `{"method" => name, "args" => [...], "kwargs" => {...}}` (args and kwargs left out when empty); reply: `{"value" => value}` or `{"error" => {"kind" => ..., "class" => ..., "message" => ...}}`. `<caller>` is the calling node's ID |
781
+ | `asterism/<node>/<app>/<object>/meta` | get / queryable | reply: `{"methods" => [{"name" => name, "arity" => n}, ...]}` (arity `-1` when not declared). The object may be `*` in the query: one reply per object |
782
+ | `asterism/<node>/<app>` | liveliness | the application is connected (a node is there while any of its applications or objects is) |
675
783
  | `asterism/<node>/<app>/<object>` | liveliness | the object is exposed |
676
- | `asterism/<node>/@profile` | get / queryable, and put | query (parameter `icon=0` for no icon): reply the node's profile, a Hash (`kind`, `host`, `name`, `icon`, `icon_hash`; [Profiles](#profiles)). The node also puts the profile without the icon at connect and on each change. `@profile` is a verbatim chunk, so `asterism/**` does not match it: the object layer's wildcards never see it. An application's own profile would go at `asterism/<node>/<app>/@profile` (not in 0.6.0) |
784
+ | `asterism/<node>/@profile` | get / queryable, and put | query (parameter `icon=0` for no icon): reply the node's profile, a Hash (`kind`, `host`, `name`, `icon`, `icon_hash`; [Profiles](#profiles)). The node also puts the profile without the icon at connect and on each change. `@profile` is a verbatim chunk, so `asterism/**` does not match it: the object layer's wildcards never see it. An application's own profile would go at `asterism/<node>/<app>/@profile` (not yet) |
677
785
 
678
786
  One queryable per application (`asterism/<node>/<app>/**`), and one for the
679
787
  profile (`asterism/<node>/@profile`). A call whose
@@ -728,9 +836,9 @@ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
728
836
 
729
837
  - Objects cannot be passed by reference (a return value is a copy), blocks
730
838
  cannot be sent, there are no events (later stages).
731
- - The node token `asterism/<node>` is shared by every application of that
732
- node; when one of them closes, the others still list the node through
733
- their objects, but a watcher may see the node token go away.
839
+ - The wire protocol is protocol 1 only: nodes of asterism before 0.7.0
840
+ (protocol 0) do not see 0.7.0's objects and a call between the two
841
+ fails (a protocol 0 call is refused with `remote_kind` `protocol`).
734
842
  - No authentication by default (plain TCP): a trusted LAN is assumed, or
735
843
  TLS through a router (CRuby; [Over TLS](#over-tls-a-router-on-the-internet)).
736
844
  - One object-layer connection per process (per VM on the boards).
@@ -0,0 +1,152 @@
1
+ # The credentials of one node or router as one file (CRuby only): the .tar
2
+ # an Asterism console hands out when it issues a certificate ("Save all
3
+ # (.tar)" on the certificate page; the archive is made in the browser that
4
+ # made the key). 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: "alice.tar", app: "game") { |net| ... }
13
+ # Asterism::Zenoh.open(credentials: "alice.tar") { |s| ... }
14
+ # Asterism::ROS.connect(credentials: "alice.tar") { |ros| ... }
15
+ #
16
+ # The file is read into memory (Gem::Package::TarReader, the standard
17
+ # library): nothing is written anywhere. The certificates go to zenoh as
18
+ # base64 (transport/link/tls/*_base64) with enable_mtls, the router's
19
+ # locator and the node ID come from asterism.json unless given, and a file
20
+ # that others may read is warned about, as ssh does with a key.
21
+ require "rubygems/package"
22
+ require "json"
23
+ require "stringio"
24
+
25
+ module Asterism
26
+ class Credentials
27
+ # What a credentials file is missing or holds wrong.
28
+ class Error < ::Asterism::Error; end
29
+
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"
50
+ 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
+
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
106
+
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
119
+ end
120
+ 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
+
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
137
+
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}"
150
+ end
151
+ end
152
+ end
@@ -26,7 +26,7 @@ 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
29
+ %i[expose unexpose poll nodes [] call_async meta connected? reconnecting? exposed on_join on_leave off_join off_leave
30
30
  profile profile_async session].each do |m|
31
31
  define_method(m) do |*args, **kw, &blk|
32
32
  ::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
@@ -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)
@@ -148,17 +156,22 @@ module Asterism
148
156
  def node_id = ::Asterism.node_id
149
157
  def app = ::Asterism.app
150
158
  def connected? = ::Asterism.connected?
159
+ def reconnecting? = ::Asterism.reconnecting?
151
160
  def lost_reason = ::Asterism.lost_reason
152
161
  def nodes = ::Asterism.nodes
153
162
  def exposed = ::Asterism.exposed
154
163
 
155
164
  # Asterism.expose: methods: an Array of names or a Hash name => number
156
- # of arguments. The object's methods run on the receiving thread (or in
165
+ # of arguments; caller: :from passes the calling node's ID as that
166
+ # keyword. The object's methods run on the receiving thread (or in
157
167
  # run, or in whatever polls).
158
- def expose(name, obj, methods:)
159
- ::Asterism.expose(name, obj, methods: methods)
168
+ def expose(name, obj, methods:, caller: nil)
169
+ ::Asterism.expose(name, obj, methods: methods, caller: caller)
160
170
  end
161
171
 
172
+ # Asterism.current_call: the call being answered on this thread, or nil.
173
+ def current_call = ::Asterism.current_call
174
+
162
175
  def unexpose(name)
163
176
  ::Asterism.unexpose(name)
164
177
  end