asterism 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b55c825f8859d86ba09baad0585c0f416a168023b8032c3f871d63bebef0538c
4
- data.tar.gz: 4d6fd26b326e62348e4fba8c6881c48b5b7145dd0b0c701eb7ba1dcc1bf5b261
3
+ metadata.gz: 54a2e8d7f4b96b04dc56b290714d60a4a8d10cdc87d789ccf01bbb2b474f0459
4
+ data.tar.gz: be9f2f6c820e67ea513ea19a3d6f88437589cb2020615bc558b553f83e882673
5
5
  SHA512:
6
- metadata.gz: 0acc8e1c4e4f028124d247a54e35ebda3451449dcfc46da503d345c857502200b1b68b4f15a4d59fbb7a603d9331f6996aa9072b1426e039fea87b43efbddd5e
7
- data.tar.gz: 03b912800f24b881a7e01eb6479c2d8c3c2c32a3346538ea60ae5f65c431f3035e097f5fc90fdcaec7734efbf95496544c4b0dd174b12e6be20cabfec2db3635
6
+ metadata.gz: cb166817303dfd933dd1fd6a4444aecd966ce06b66347a4b058759c49dc0b0cd1397bbef60d05753c72ccf511a8f70ea5c49c8ffdfaa591eb2d2cf2255d8b1da
7
+ data.tar.gz: d0d0549e336a50af534813aa4ab74c068952bf3d43148cc7c2032c0f4447df0aae73c9d1d813f8aa99819310c13ae3e220d59376276195fed408506b34144399
data/CHANGELOG.md CHANGED
@@ -6,6 +6,118 @@ 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.0
10
+
11
+ **This release breaks compatibility: it speaks wire protocol 1 only.** A
12
+ node of 0.7.0 and a node of 0.6.0 or earlier (protocol 0) do not see each
13
+ other's objects, and a call between them fails: a protocol 0 call to a
14
+ 0.7.0 node is refused with the error kind `protocol`, a 0.7.0 call to an
15
+ older node is not answered, and their replies are malformed answers to
16
+ each other. Move every node of a network to 0.7.0 together (boards,
17
+ browsers, consoles, CRuby programs). The Ruby API stays as it was, with
18
+ the additions below. Needs asterism-zenoh / picoruby-asterism-zenoh 0.4.0
19
+ (unchanged), and on mruby a MessagePack that reads and writes bin (README,
20
+ "Using it").
21
+
22
+ Protocol 1 (`docs/protocol.md`, normative; `docs/protocol_review.md` says
23
+ what was decided and why):
24
+
25
+ - A call is a get on `asterism/<node>/<app>/<object>/call/<caller>`, where
26
+ `<caller>` is the calling node's ID. Its payload is a map
27
+ `{"method" => name, "args" => [...], "kwargs" => {...}}` (args and
28
+ kwargs left out when empty) instead of the array `[method, args,
29
+ kwargs]`.
30
+ - A reply is `{"value" => value}` or `{"error" => {"kind" => ..., "class"
31
+ => ..., "message" => ...}}` instead of `["ok", value]` /
32
+ `["error", class, message]`. The kinds: `raised`, `not_exposed`,
33
+ `no_object`, `arguments`, `malformed`, `unsendable`, `nesting`,
34
+ `protocol`.
35
+ - The meta is `{"methods" => [{"name" => name, "arity" => n}, ...]}`.
36
+ - Binary Strings go as MessagePack bin (CRuby: ASCII-8BIT Strings, no more
37
+ compatibility mode; mruby: `String#b`), and a bin arrives as a binary
38
+ String. The profile's icon is a bin.
39
+ - The profile has `"protocol" => 1`.
40
+ - Each application declares its own liveliness token
41
+ `asterism/<node>/<app>`; the node token `asterism/<node>`, which the
42
+ applications of a node shared, is gone. A node is present while any of
43
+ its applications or objects is.
44
+ - A payload is exactly one value: bytes after it, ext types and integers
45
+ outside the signed 64-bit range make it malformed. CRuby raises
46
+ `EncodeError` before sending an Integer outside int64 (it raised
47
+ `RangeError` before).
48
+ - A protocol 0 call (on the five-chunk `.../call` key) is answered with the
49
+ error kind `protocol`.
50
+
51
+ Added
52
+
53
+ - `Asterism.current_call` while an exposed method runs for a call (per
54
+ thread on CRuby, `net.current_call` too): `from` (the calling node),
55
+ `verified` (`:claimed`; `:local` for a call from the same application),
56
+ `object`, `method`. `Asterism::CurrentCall`.
57
+ - `Asterism.expose(name, obj, methods:, caller: :from)` (and
58
+ `net.expose`): passes the calling node's ID as the keyword `from:` and
59
+ refuses a call that brings `from:` itself.
60
+ - `RemoteError#remote_kind`: the error's kind, as above (nil when the
61
+ answer did not say).
62
+ - `proxy.asterism_call(name, *args, **kw)` and `proxy.async.asterism_call`:
63
+ a call by the method's name, for names a proxy cannot forward (`display`,
64
+ `hash`, `class`, `fail`, ...) and names that are not Ruby identifiers.
65
+ - `Asterism::PROTOCOL` (1).
66
+ - The conformance tests (`test/protocol/`) are protocol 1's: 55 values, 54
67
+ incoming queries, 24 outgoing steps; CRuby and mruby pass them all.
68
+
69
+ Changed
70
+
71
+ - `proxy.asterism_meta` returns the new meta form.
72
+ - `Asterism.connect`'s duplicate check also finds a copy of the
73
+ application that exposes nothing (it has its application token now).
74
+ - `host_test/msgpack.rb` (the mruby test's MessagePack) writes a binary
75
+ String as bin and reads a bin as one; the mruby test build adds
76
+ mruby-encoding (to tell a binary String).
77
+ - The documentation of the wire protocol (docs/protocol.md,
78
+ docs/protocol_review.md) and the conformance tests (X0, unreleased in
79
+ 0.6.0) are rewritten for protocol 1.
80
+
81
+ ## 0.6.0
82
+
83
+ One connection for everything and a lighter `Asterism.poll`; every 0.5.x
84
+ call works as before. Needs asterism-zenoh / picoruby-asterism-zenoh 0.4.0
85
+ (unchanged).
86
+
87
+ Added
88
+
89
+ - `Asterism.session` (README "One connection"): the Zenoh session of
90
+ `Asterism.connect` (an `Asterism::Zenoh::Session`), for put, subscribe, a
91
+ ROS 2 node (`Asterism::ROS::Node.new(Asterism.session, ...)`) and the rest
92
+ of the Zenoh API on the same connection. `Disconnected` when not
93
+ connected. The session stays Asterism's: its `close` does nothing (it
94
+ warns once); `Asterism.close` and a lost connection close it, with
95
+ everything declared on it. A router that lets a one-time password in
96
+ once now takes such an application, and its ROS 2 node has the node's
97
+ Zenoh ID (a console draws it inside the node).
98
+ - The CRuby API: `net.session`, the same session as a `Zenoh::Connection`
99
+ on the `Net`'s receiving thread (`shared?`; its `close` does nothing);
100
+ `Asterism::ROS::Connection.new(net.session)` makes ROS 2 nodes on it, and
101
+ its `close` withdraws only its nodes. `Zenoh::Connection#shared?`.
102
+ `ROS::Node#close` on a `ROS::Connection` also stops polling the node.
103
+ - `rake bench:poll` (`test/bench_poll.rb`): the time and the allocations
104
+ of one `Asterism.poll` by the number of nodes and objects.
105
+ - Tests: `test/test_session.rb`, `test/test_api_session.rb`, and on mruby
106
+ `host_test/test_session.rb` with `host_test/peer.rb` (the mruby test
107
+ build adds mruby-eval for the ROS 2 type files).
108
+
109
+ Changed
110
+
111
+ - `Asterism.poll` does less when nothing came: each queue (calls, profile
112
+ queries, liveliness) is read only when its count is not zero, and the
113
+ list of nodes and the comparison behind `on_join` / `on_leave` are made
114
+ again only after a liveliness change, with Hashes instead of `include?`.
115
+ The behaviour is the same (the order and number of the `on_join` /
116
+ `on_leave` calls, the order of `Asterism.nodes`). With 4 other nodes and
117
+ 5 objects, a poll in which nothing changed: CRuby 3.2 us and 49
118
+ allocations -> 0.6 us and 7; mruby (host) 14-17 us -> 2.2 us.
119
+ `Asterism.each` drops duplicates with a Hash.
120
+
9
121
  ## 0.5.0
10
122
 
11
123
  Node profiles; every 0.4.x call works as before. Needs asterism-zenoh /
data/README.md CHANGED
@@ -13,8 +13,9 @@ repository holds the pure Ruby layers, written once for every Ruby:
13
13
  | `data/icon.png` | CRuby's default profile icon (32x32 PNG; [Profiles](#profiles)) |
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
- | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_profile.rb` (node profiles), `test_api_*.rb` (the CRuby API) |
17
- | `host_test/` | `rake test:mruby`: the shared layer on mruby (profiles between two mruby processes) |
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)) |
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
 
20
21
  The Zenoh binding underneath is a separate gem with the same Ruby API on
@@ -39,7 +40,15 @@ asterism-zenoh too). What changed in each version is in
39
40
  ([docs/api_review.md](docs/api_review.md)) and only adds and deprecates
40
41
  (see [Deprecations and 1.0](#deprecations-and-10)); 0.5.0 adds node
41
42
  profiles ([Profiles](#profiles): what a node runs on, where, a name and an
42
- icon).
43
+ icon); 0.6.0 adds `Asterism.session`
44
+ ([One connection](#one-connection-asterismsession): the object layer's
45
+ session for plain Zenoh and ROS 2 too) and makes `Asterism.poll` cheap when
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`.
43
52
 
44
53
  ## Which API?
45
54
 
@@ -59,6 +68,7 @@ Entry points and what they give:
59
68
  | `Asterism::Zenoh::Session.open` | a `Session` (portable) | zenoh-c / zenoh-pico |
60
69
  | `Asterism::Zenoh.open` | a `Zenoh::Connection` (CRuby) | `c.session` is the `Session` |
61
70
  | `Asterism.connect` | `Asterism` itself (portable; one connection per process or VM), or a `Net` to the block (CRuby) | a `Session` of its own |
71
+ | `Asterism.session` | that `Session` (portable), for put, subscribe and a ROS 2 node on the same connection; `net.session` is it as a `Zenoh::Connection` (CRuby) | the object layer's (Asterism closes it) |
62
72
  | `Asterism::ROS::Node.new(session, ...)` | a `Node` (portable) | the `Session` given |
63
73
  | `Asterism::ROS.connect` | a `ROS::Connection` (CRuby) | `ros.zenoh` is the `Zenoh::Connection` |
64
74
 
@@ -87,7 +97,7 @@ build but not yet tested. To install from the repositories instead:
87
97
 
88
98
  ```
89
99
  (cd ../asterism-zenoh && rake gem) && rake gem
90
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.5.0.gem
100
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.7.0.gem
91
101
  ```
92
102
 
93
103
  From the working trees, without installing:
@@ -97,12 +107,14 @@ ruby -I lib -I ../asterism-zenoh/lib your_script.rb # after `rake compile` in
97
107
  ```
98
108
 
99
109
  `Asterism::ROS::TYPE_PATH` is `data/msgs` there; add your own directories.
100
- MessagePack sends every String as str (compatibility mode), as the boards
101
- do: their MessagePack does not read the bin type.
110
+ MessagePack sends a binary (ASCII-8BIT) String as bin and any other String
111
+ as str (protocol 1; [Values](#values)).
102
112
 
103
113
  mruby / PicoRuby: add this repository and picoruby-asterism-zenoh as gems
104
114
  (`conf.gem "path/to/asterism"`; a `MessagePack` module with `pack` /
105
- `unpack` is needed too), and put `data/msgs` on the device's file system at
115
+ `unpack` is needed too: for protocol 1 it must write a binary String
116
+ (`String#b`) as bin and read a bin as one, and refuse ext types, integers
117
+ outside int64 and bytes after the value), and put `data/msgs` on the device's file system at
106
118
  `/usr/share/asterism/msgs` (or add the place to
107
119
  `Asterism::ROS::TYPE_PATH`). Family mruby does this from pinned commits of
108
120
  these repositories.
@@ -116,6 +128,9 @@ rake test:objects # objects and Asterism::ROS between CRuby sessions over a
116
128
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
117
129
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
118
130
  rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
131
+ rake test:protocol # the wire protocol's conformance samples and driver against CRuby (docs/protocol.md)
132
+ rake test:protocol_mruby # the same driver against mruby (builds mruby; not part of `rake`)
133
+ rake bench:poll # the time and allocations of one Asterism.poll by nodes and objects (prints a table)
119
134
  ```
120
135
 
121
136
  `test:mruby` builds mruby with picoruby-asterism-zenoh and this gem
@@ -246,12 +261,14 @@ end
246
261
  |---|---|
247
262
  | `Asterism.connect(...) { \|net\| }` | yields an `Asterism::Net`, `Asterism.close` when the block ends; returns the block's value. Without a block, as before |
248
263
  | `Asterism.net` | the `Net` of the connection made without a block (nil when not connected) |
249
- | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose`. The methods run on the receiving thread, in `run`, or in whatever polls |
264
+ | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose` (also `caller: :from`). The methods run on the receiving thread, in `run`, or in whatever polls |
265
+ | `net.current_call` | `Asterism.current_call`: the call being answered on this thread (the receiving thread while it answers), or nil |
250
266
  | `net[path, timeout: 2.0]` | a proxy (`timeout` in seconds, or `timeout_ms:`) |
251
267
  | `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) |
252
268
  | `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 |
253
269
  | `net.start` / `stop` / `run` / `running?` / `on_error` | as for Zenoh. A method of an exposed object that raises is answered to its caller as a `RemoteError`, as before; `on_error` gets what `on_join` / `on_leave` raise |
254
270
  | `net.profile(**fields)` / `net.profile_of(node, icon: true, timeout: 2.0)` | `Asterism.profile` / `profile_of` ([Profiles](#profiles)); `profile_of` waits for the receiving thread's ticks while it runs |
271
+ | `net.session` | `Asterism.session` as a `Zenoh::Connection` (`subscribe { }`, `put`, `publisher`, ...) on this receiving thread: its blocks run there, between the calls answered. `shared?` is true; its `close` does nothing and `start` / `stop` / `run` are the `Net`'s. A ROS 2 node on it: `Asterism::ROS::Connection.new(net.session).node("name")`. See [One connection](#one-connection-asterismsession) |
255
272
  | `net.nodes`, `node_id`, `app`, `connected?`, `lost_reason`, `poll`, `close` | |
256
273
 
257
274
  ### Over TLS (a router on the internet)
@@ -307,6 +324,7 @@ end
307
324
  | `node.call(name, type, request = nil, request:, timeout: 2.0, **fields)` | waits for the response. Here `timeout:` is always the time limit (see [Reserved keywords](#reserved-keywords)) |
308
325
  | `ros.spin` (`run`) / `start` / `stop` / `running?` / `on_error` / `close` | |
309
326
  | `ros.zenoh` | the `Asterism::Zenoh::Connection` underneath (same receiving thread) |
327
+ | `Asterism::ROS::Connection.new(net.session, domain: 0)` | nodes on the object layer's session (the same Zenoh ID as the objects), spun by the `Net`'s receiving thread. Its `close` withdraws its nodes only |
310
328
 
311
329
  `type` is a generated type or its name (`"geometry_msgs/msg/Twist"`).
312
330
  Messages and `Attachment` take part in pattern matching (`deconstruct_keys`,
@@ -410,20 +428,23 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
410
428
  | Call | Returns | Raises / notes |
411
429
  |---|---|---|
412
430
  | `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). |
413
- | `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. |
431
+ | `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)). |
432
+ | `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)). |
414
433
  | `Asterism.unexpose(name)` | true / false | |
415
- | `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. |
416
- | `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). |
434
+ | `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. |
435
+ | `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). |
417
436
  | `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. |
418
- | `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`). |
437
+ | `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`). |
438
+ | `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`. |
419
439
  | `proxy.async.<method>(...)` | `Future` | Sends and returns at once. `EncodeError` / `Disconnected` are raised here. |
420
440
  | `future.done?` | true / false | Never waits (`Asterism.poll` moves it on). True once the answer came, the time ran out or the connection closed. |
421
441
  | `future.value` | the return value | Waits (polling) if not done; raises like a waiting call. `future.took` (seconds) / `took_ms`: time to the answer. |
422
442
  | `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`.) |
423
- | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [[name, arity], ...]}` |
443
+ | `proxy.asterism_meta` / `proxy.asterism_path` | Hash, String | `{"methods" => [{"name" => name, "arity" => n}, ...]}` |
424
444
  | `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. |
425
445
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
426
446
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
447
+ | `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. |
427
448
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
428
449
  | `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). |
429
450
  | `Asterism.profile_of(node, icon: true, timeout: 2.0)` | Hash or nil | Another node's profile, nil when it does not answer (an older version, a ROS 2 node). |
@@ -436,19 +457,20 @@ and an icon. A console draws it with that; anyone can ask.
436
457
  ```ruby
437
458
  Asterism.profile(name: "Rover", icon: File.binread("rover.png")) # this node's (any time)
438
459
  Asterism.profile
439
- # => {"kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
460
+ # => {"protocol" => 1, "kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
440
461
  Asterism.profile_of("fmruby-aaaaaa")
441
- # => {"kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
462
+ # => {"protocol" => 1, "kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
442
463
  Asterism.profile_of("fmruby-aaaaaa", icon: false) # the same without the icon bytes
443
464
  Asterism.profile_of("an-older-node") # => nil
444
465
  ```
445
466
 
446
467
  | Field | Value | Default |
447
468
  |---|---|---|
469
+ | `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 | |
448
470
  | `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` |
449
471
  | `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 |
450
472
  | `name` | a display name (up to 64 bytes) | none |
451
- | `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` |
473
+ | `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` |
452
474
  | `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) | |
453
475
 
454
476
  - **Setting.** `Asterism.profile(**fields)` changes the fields given and keeps
@@ -480,6 +502,59 @@ Asterism.profile_of("an-older-node") # => nil
480
502
  an acceptable PNG (nil otherwise), for a tool that wants to check an icon
481
503
  first.
482
504
 
505
+ ## One connection: `Asterism.session`
506
+
507
+ An application that also puts, subscribes or runs a ROS 2 node uses the
508
+ object layer's own session for it (0.6.0) instead of opening a second one:
509
+
510
+ ```ruby
511
+ Asterism.connect("tcp/192.0.2.2:7447", node: "fmruby-aaaaaa", app: "game")
512
+ Asterism.expose("othello", game, methods: [:move, :state])
513
+ s = Asterism.session # an Asterism::Zenoh::Session
514
+ s.put("asterism/fmruby-aaaaaa/game/othello/state", MessagePack.pack(state))
515
+ moves = s.subscribe("asterism/*/game/othello/state") # moves.each_pending in the update loop
516
+ node = Asterism::ROS::Node.new(s, "brain") # a ROS 2 node with the same Zenoh ID
517
+ cmd = node.publisher("/cmd_vel", "geometry_msgs/msg/Twist")
518
+ # update loop: Asterism.poll; node.poll
519
+ ```
520
+
521
+ CRuby API:
522
+
523
+ ```ruby
524
+ Asterism.connect(router, node: "mypc", app: "demo") do |net|
525
+ net.session.subscribe("asterism/*/game/**") { |sample| p sample.key } # on the receiving thread
526
+ ros = Asterism::ROS::Connection.new(net.session)
527
+ ros.node("brain").subscribe("/odom", "nav_msgs/msg/Odometry") { |odom| puts odom.pose.pose.position.x }
528
+ net.run
529
+ end
530
+ ```
531
+
532
+ - **Why one.** A router that lets each pass in once (a one-time password
533
+ at a `wss://` door) lets only the first session in; and a ROS 2 node on
534
+ the object layer's session has the node's Zenoh ID, so a console draws it
535
+ inside the node.
536
+ - **Who closes it.** The session is Asterism's. `Asterism.session.close`
537
+ (and `net.session.close`) does nothing but warn once, so a library handed
538
+ the session cannot break the connection. `Asterism.close`, the end of the
539
+ connect block and a lost connection close it, and with it everything
540
+ declared on it: the application's subscribers, publishers, ROS 2 nodes.
541
+ Close those yourself when you are done with them earlier (`sub.close`,
542
+ `node.close`; on CRuby `ros.close` withdraws its nodes and leaves the
543
+ rest).
544
+ - **After a lost connection** (`Asterism.poll` returned false), connect
545
+ again, take `Asterism.session` again (a new object) and declare again
546
+ what you had on the old one.
547
+ - **Polling.** `Asterism.poll` polls the session; `node.poll` polls it too
548
+ and only then serves the node. Each subscriber, queryable and node keeps
549
+ what came for it, so the order does not matter and nothing is taken from
550
+ another. Your own `put` does not come back to your own subscriber on the
551
+ same session.
552
+ - **Keys.** Keep your keys out of the object layer's: `asterism/<node>/<app>/**`
553
+ is answered by its queryable (`get` there is a call), `asterism/<node>/<app>` and
554
+ `asterism/<node>/<app>/<object>` are its liveliness tokens. Putting under
555
+ `asterism/<node>/<app>/<object>/<anything>` (a state, a log) is fine:
556
+ puts and subscriptions do not meet queryables or liveliness.
557
+
483
558
  ## Errors
484
559
 
485
560
  Every error Asterism raises is an `Asterism::Error` (a `StandardError`):
@@ -492,12 +567,18 @@ Asterism::Error (the Zenoh bindings define it)
492
567
  ├── Asterism::TimeoutError (no answer in time; old name Asterism::Timeout)
493
568
  │ └── Asterism::ROS::TimeoutError (old name Asterism::ROS::Timeout)
494
569
  ├── Asterism::EncodeError
495
- ├── Asterism::RemoteError (remote_class, remote_message)
570
+ ├── Asterism::RemoteError (remote_kind, remote_class, remote_message)
496
571
  ├── Asterism::ROS::UnknownType
497
572
  ├── Asterism::CDR::DecodeError
498
573
  └── Asterism::DeprecationError (only with Asterism.deprecations = :raise)
499
574
  ```
500
575
 
576
+ `RemoteError#remote_kind` says what happened on the other side, as a word
577
+ of the protocol (docs/protocol.md 4.4): `raised` (the method raised;
578
+ `remote_class` is its class there), `not_exposed`, `no_object`,
579
+ `arguments`, `malformed`, `unsendable`, `nesting`, `protocol` (the other
580
+ side speaks a different protocol); nil when the answer did not say.
581
+
501
582
  The old names `Asterism::Timeout` and `Asterism::ROS::Timeout` still work
502
583
  (they warn once). Inside `module Asterism`, `Timeout` is Ruby's `Timeout`
503
584
  module again. The ROS layer raises `Zenoh::ClosedError` when its session
@@ -591,26 +672,77 @@ Asterism follows `Asterism::Zenoh`: the connection is closed when the router
591
672
  why, and calls raise `Asterism::Disconnected`. **`Asterism::Zenoh::Error`
592
673
  never comes out of Asterism; it is always wrapped in `Disconnected`** (the
593
674
  message is kept). The exposed objects are gone with the connection: to go
594
- on, the application calls `connect` and `expose` again.
675
+ on, the application calls `connect` and `expose` again, and declares again
676
+ what it had on `Asterism.session` (subscribers, ROS 2 nodes; the old
677
+ session is closed, its entities raise `Zenoh::ClosedError`).
595
678
 
596
679
  ## Values
597
680
 
598
- Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (64 bit),
599
- `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a `String` (also
600
- as a Hash key: keyword arguments arrive as Symbols again). Anything else in
601
- the arguments raises `EncodeError` before sending; in a return value the
602
- caller gets `RemoteError` (`Asterism::EncodeError`). Nesting is limited to
603
- 16 levels.
681
+ Only what MessagePack carries: `nil`, `true`, `false`, `Integer` (signed
682
+ 64 bit), `Float`, `String`, `Array`, `Hash`. A `Symbol` is sent as a
683
+ `String` and arrives as one (also as a Hash key; keyword names become
684
+ Symbols again). A binary String (`String#b`: ASCII-8BIT on CRuby, the
685
+ binary flag on mruby) goes as a MessagePack bin and arrives as a binary
686
+ String; any other String goes as text (str). Anything else in the
687
+ arguments, or an Integer outside the signed 64-bit range, raises
688
+ `EncodeError` before sending; in a return value the caller gets
689
+ `RemoteError` (`unsendable`). Nesting is limited to 16 levels.
690
+
691
+ - Hash keys: use String (or Symbol) and Integer keys for anything another
692
+ language reads (Python and JavaScript refuse others by default).
693
+ - Times: send an Integer of nanoseconds since the epoch (or name the unit:
694
+ `timeout_ms`); there is no time type.
695
+ - A keyword given as `nil` and one left out differ: the callee's default
696
+ applies only to the one left out.
697
+ - A JavaScript caller's `1.0` arrives as the Integer `1`.
698
+
699
+ ## Who called
700
+
701
+ The caller's node ID travels in the call's key (`.../call/<caller>`). An
702
+ exposed method can read it:
703
+
704
+ ```ruby
705
+ class Rover
706
+ def stop
707
+ c = Asterism.current_call # nil outside a call
708
+ log("stop from #{c.from}") # c.verified: :claimed (:local from this app)
709
+ end
710
+
711
+ def drive(v, w, from:) # caller: :from fills from: in
712
+ log("drive from #{from}")
713
+ end
714
+ end
715
+ Asterism.expose("rover", Rover.new, methods: { stop: 0 })
716
+ Asterism.expose("wheels", Rover.new, methods: { drive: 2 }, caller: :from)
717
+ ```
718
+
719
+ - `Asterism.current_call` is set only while a method runs for a call (per
720
+ thread on CRuby). Calls answered while that method waits for its own
721
+ call come on top of it and go when they end.
722
+ - With `caller: :from`, a call that passes `from:` itself is refused
723
+ (`RemoteError`, `remote_kind` `arguments`): `from` is always the one
724
+ Asterism took from the call.
725
+ - **It is a claim.** Any client can call with any name. A router's access
726
+ control makes it binding: with mutual TLS, zenohd's ACL can let each
727
+ certificate query only keys ending in its own name
728
+ (`asterism/*/*/*/call/<name>`; docs/protocol.md 4.7). Without such a
729
+ router, use `from` to avoid mistakes, not to decide who may do what.
604
730
 
605
731
  ## Keys and encoding
606
732
 
733
+ Wire protocol 1. The full description, for implementations in other
734
+ languages, is [docs/protocol.md](docs/protocol.md) (with the conformance
735
+ tests); why it has this shape (the review of protocol 0's Ruby-shaped
736
+ parts, and what was decided) is
737
+ [docs/protocol_review.md](docs/protocol_review.md).
738
+
607
739
  | Key | Zenoh | Payload |
608
740
  |---|---|---|
609
- | `asterism/<node>/<app>/<object>/call` | get / queryable | query: `[method, [args...], {kwargs}]`; reply: `["ok", value]` or `["error", class name, message]` |
610
- | `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 |
611
- | `asterism/<node>` | liveliness | the node is connected |
741
+ | `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 |
742
+ | `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 |
743
+ | `asterism/<node>/<app>` | liveliness | the application is connected (a node is there while any of its applications or objects is) |
612
744
  | `asterism/<node>/<app>/<object>` | liveliness | the object is exposed |
613
- | `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.5.0) |
745
+ | `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) |
614
746
 
615
747
  One queryable per application (`asterism/<node>/<app>/**`), and one for the
616
748
  profile (`asterism/<node>/@profile`). A call whose
@@ -665,9 +797,9 @@ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
665
797
 
666
798
  - Objects cannot be passed by reference (a return value is a copy), blocks
667
799
  cannot be sent, there are no events (later stages).
668
- - The node token `asterism/<node>` is shared by every application of that
669
- node; when one of them closes, the others still list the node through
670
- their objects, but a watcher may see the node token go away.
800
+ - The wire protocol is protocol 1 only: nodes of asterism before 0.7.0
801
+ (protocol 0) do not see 0.7.0's objects and a call between the two
802
+ fails (a protocol 0 call is refused with `remote_kind` `protocol`).
671
803
  - No authentication by default (plain TCP): a trusted LAN is assumed, or
672
804
  TLS through a router (CRuby; [Over TLS](#over-tls-a-router-on-the-internet)).
673
805
  - One object-layer connection per process (per VM on the boards).
@@ -27,7 +27,7 @@ module Asterism
27
27
  # singleton class, so the shared methods themselves are not changed.
28
28
  module Locked
29
29
  %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave
30
- profile profile_async].each do |m|
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) }
33
33
  end
@@ -130,6 +130,21 @@ module Asterism
130
130
  @runner.add { ::Asterism.poll ? 0 : false }
131
131
  end
132
132
 
133
+ # Asterism.session as a Zenoh::Connection (the block API of
134
+ # Asterism::Zenoh.open) on the same receiving thread as the objects:
135
+ # subscribe / queryable blocks run there, between the calls answered.
136
+ # For a ROS 2 node on the same session:
137
+ # Asterism::ROS::Connection.new(net.session).node("name"). The session
138
+ # stays Asterism's: net.session.close does nothing (shared?), net.close
139
+ # closes it. Disconnected when not connected.
140
+ def session
141
+ ::Asterism::LOCK.synchronize do
142
+ s = ::Asterism.session
143
+ @session = nil if @session && !@session.session.equal?(s)
144
+ @session ||= ::Asterism::Zenoh::Connection.new(s, runner: @runner)
145
+ end
146
+ end
147
+
133
148
  def node_id = ::Asterism.node_id
134
149
  def app = ::Asterism.app
135
150
  def connected? = ::Asterism.connected?
@@ -138,12 +153,16 @@ module Asterism
138
153
  def exposed = ::Asterism.exposed
139
154
 
140
155
  # Asterism.expose: methods: an Array of names or a Hash name => number
141
- # of arguments. The object's methods run on the receiving thread (or in
156
+ # of arguments; caller: :from passes the calling node's ID as that
157
+ # keyword. The object's methods run on the receiving thread (or in
142
158
  # run, or in whatever polls).
143
- def expose(name, obj, methods:)
144
- ::Asterism.expose(name, obj, methods: methods)
159
+ def expose(name, obj, methods:, caller: nil)
160
+ ::Asterism.expose(name, obj, methods: methods, caller: caller)
145
161
  end
146
162
 
163
+ # Asterism.current_call: the call being answered on this thread, or nil.
164
+ def current_call = ::Asterism.current_call
165
+
147
166
  def unexpose(name)
148
167
  ::Asterism.unexpose(name)
149
168
  end
@@ -95,12 +95,15 @@ module Asterism
95
95
  self
96
96
  end
97
97
 
98
- # Stops spinning, withdraws the nodes and closes the session.
98
+ # Stops spinning, withdraws the nodes and closes the session. On
99
+ # Asterism's session (ROS::Connection.new(net.session)) it only
100
+ # withdraws the nodes: the receiving thread and the session stay the
101
+ # object layer's.
99
102
  def close
100
- runner.stop
103
+ runner.stop unless @zenoh.shared?
101
104
  @nodes.each { |n| n.close rescue ::Asterism::Zenoh::Error }
102
105
  @nodes = []
103
- @zenoh.close
106
+ @zenoh.close unless @zenoh.shared?
104
107
  nil
105
108
  end
106
109
 
@@ -142,8 +145,17 @@ module Asterism
142
145
 
143
146
  def asterism_runner = @asterism_runner
144
147
 
148
+ # Withdraws the node and stops polling it.
149
+ def close
150
+ @asterism_runner.lock.synchronize do
151
+ super
152
+ @asterism_runner.remove(@asterism_task) if @asterism_task
153
+ @asterism_task = nil
154
+ end
155
+ end
156
+
145
157
  # The calls that change the node take the connection's lock.
146
- %i[subscription client close every cancel_timer].each do |m|
158
+ %i[subscription client every cancel_timer].each do |m|
147
159
  define_method(m) do |*args, **kw, &blk|
148
160
  @asterism_runner.lock.synchronize { super(*args, **kw, &blk) }
149
161
  end
@@ -158,11 +158,29 @@ module Asterism
158
158
  class Connection
159
159
  attr_reader :session, :runner
160
160
 
161
- def initialize(session, interval: Runner::DEFAULT_INTERVAL)
161
+ # runner: an existing Runner to receive on instead of a new one
162
+ # (Asterism::Net#session gives the object layer's: the session and
163
+ # its receiving thread are Asterism's, see shared?). @api private
164
+ def initialize(session, interval: Runner::DEFAULT_INTERVAL, runner: nil)
162
165
  @session = session
163
- @runner = Runner.new(session, interval: interval, name: "zenoh")
164
- # The first task: false (the runner stops) once the session closed.
165
- @runner.add { @session.poll ? 0 : false }
166
+ if runner
167
+ @runner = runner
168
+ @shared = true
169
+ else
170
+ @runner = Runner.new(session, interval: interval, name: "zenoh")
171
+ # The first task: false (the runner stops) once the session closed.
172
+ @runner.add { @session.poll ? 0 : false }
173
+ @shared = false
174
+ end
175
+ end
176
+
177
+ # True for the Connection of Asterism's own session (net.session):
178
+ # its close does nothing, start / stop / run are the object layer's
179
+ # receiving thread (net.start / net.stop / net.run), and
180
+ # Asterism.close (or the end of the connect block) closes the session
181
+ # with everything declared on it.
182
+ def shared?
183
+ @shared
166
184
  end
167
185
 
168
186
  # opts: those of Session#put (attachment:, encoding:, priority:,
@@ -370,15 +388,23 @@ module Asterism
370
388
  def declare_keyexpr(key) = @session.declare_keyexpr(key)
371
389
 
372
390
  # Stops receiving and closes the session (with everything declared on
373
- # it). Idempotent.
391
+ # it). Idempotent. On a shared? Connection (net.session) it does
392
+ # nothing: close what you declared on it (subscriptions, publishers,
393
+ # ROS nodes) one by one, or let Asterism.close close them.
374
394
  def close
395
+ if @shared
396
+ ::Asterism.warn_once(self, "asterism: net.session belongs to Asterism; its close is ignored " \
397
+ "(net.close closes it)")
398
+ return nil
399
+ end
375
400
  @runner.close
376
401
  @session.close
377
402
  nil
378
403
  end
379
404
 
380
405
  def inspect
381
- "#<Asterism::Zenoh::Connection #{closed? ? 'closed' : zid}#{running? ? ' running' : ''}>"
406
+ "#<Asterism::Zenoh::Connection #{closed? ? 'closed' : zid}#{@shared ? ' shared' : ''}" \
407
+ "#{running? ? ' running' : ''}>"
382
408
  end
383
409
 
384
410
  private